# Design: Refactor Backtest Script ## Context ### 当前状态 现有的回测系统基于 Jupyter Notebook (`backtest.ipynb`),包含以下手动执行步骤: 1. 通过 SQL magic 查询数据库获取股票价格数据(含复权) 2. 数据预处理(重命名列、设置索引) 3. 计算技术指标(SMA10, SMA30, SMA60, SMA120) 4. 定义策略类(SmaCross,金叉买入、死叉卖出) 5. 执行回测并打印结果 6. 生成交互式图表(Bokeh) ### 约束条件 - 数据库:PostgreSQL (leopard_dev@81.71.3.24) - 数据表:`leopard_daily` (日线数据), `leopard_stock` (股票信息) - 回测引擎:`backtesting` Python 库 - 复权逻辑:`price * factor`(factor 从数据库获取) - 输出格式:中文标签 + Bokeh HTML 图表 ### 利益相关者 - 量化研究员:需要快速测试不同策略、不同股票的回测表现 - 策略开发者:需要独立开发策略,通过标准接口集成 - 运维人员:需要支持批量自动化回测任务 ## Goals / Non-Goals ### Goals 1. **命令行化执行** - 通过命令行参数完成回测,无需交互式环境 2. **策略模块化** - 策略逻辑与主流程分离,支持动态加载不同策略文件 3. **参数化配置** - 支持股票代码、时间范围、初始资金、手续费率等参数 4. **简化的数据访问** - 保持简单的数据库连接逻辑,不引入过度抽象 5. **清晰的结果输出** - 控制台中文统计 + 可选的 HTML 图表文件 ### Non-Goals - ❌ 不支持多时间周期(仅日线) - ❌ 不支持多股票组合回测(仅单股票) - ❌ 不支持参数优化(固定策略参数) - ❌ 不支持实盘交易接口 - ❌ 不引入复杂的依赖注入或插件系统 - ❌ 不实现 Web UI 或 API 接口 ## Decisions ### D1: 文件结构 - 单一入口文件 + 策略文件 **决策**: - `backtest.py` - 包含所有主流程逻辑(参数解析、数据加载、回测执行、结果输出) - `strategy.py` - 策略模板(指标计算函数 + 策略类) - 可选 `strategies/` 目录 - 存放其他策略文件 **理由**: - 用户要求简化文件数量,保持流程集中 - 单一入口文件便于理解和维护 - 策略文件独立,便于多人协作开发 **替代方案**: - 将数据加载、结果输出拆分为独立模块 - 被用户拒绝("设计的文件太多了,需要简化") --- ### D2: 策略接口 - 两个必需函数 + 策略类 **决策**: 策略文件必须提供: 1. **`calculate_indicators(data)` 函数** ```python def calculate_indicators(data: pd.DataFrame) -> pd.DataFrame: """计算策略所需的技术指标,返回添加了指标列的 DataFrame""" ``` 2. **`get_strategy()` 函数** ```python def get_strategy() -> type: """返回策略类(Strategy 的子类)""" ``` 3. **策略类定义** ```python from backtesting import Strategy class MyStrategy(Strategy): def init(self): """注册指标到 backtesting 框架""" pass def next(self): """每个时间步的决策逻辑""" pass ``` **理由**: - 将指标计算与交易逻辑分离,主流程可以预处理所有数据 - `get_strategy()` 函数提供清晰的加载接口 - 遵循 `backtesting` 库的接口规范 **替代方案**: - 将 `calculate_indicators` 作为策略类的方法 - 问题:主流程无法先计算指标,必须在 Strategy 类中注册 --- ### D3: 策略动态加载 - 使用 `importlib` **决策**: ```python import importlib.util def load_strategy(strategy_file): """动态加载策略文件""" spec = importlib.util.spec_from_file_location(module_name, strategy_file) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) calculate_indicators = module.calculate_indicators strategy_class = module.get_strategy() return calculate_indicators, strategy_class ``` **理由**: - 支持任意路径的策略文件(如 `strategy.py`, `strategies/macd.py`) - 无需预定义策略列表或配置文件 - Python 标准库,无额外依赖 **替代方案**: - 约定式加载(所有策略放在 `strategies/` 目录) - 灵活性不足 - 配置文件映射策略名称和文件路径 - 增加维护成本 --- ### D4: 数据库连接 - 简化 SQLAlchemy 连接 **决策**: ```python import sqlalchemy conn_str = f"postgresql://{user}:{password}@{host}/{database}" engine = sqlalchemy.create_engine(conn_str) df = pd.read_sql(query, engine) engine.dispose() ``` **理由**: - 用户要求"数据库访问保持简单,不需要太多抽象" - SQLAlchemy 提供基础连接池和 SQL 注入防护 - 支持参数化查询(未来扩展) **SQL 查询**: ```sql SELECT trade_date, open * factor AS Open, close * factor AS Close, high * factor AS High, low * factor AS Low, volume AS Volume, COALESCE(factor, 1.0) AS factor FROM leopard_daily daily LEFT JOIN leopard_stock stock ON stock.id = daily.stock_id WHERE stock.code = '{code}' AND daily.trade_date BETWEEN '{start_date} 00:00:00' AND '{end_date} 23:59:59' ORDER BY daily.trade_date ``` **替代方案**: - 直接使用 `psycopg2` - 需要手动处理游标和类型转换 - 引入 ORM 模型 - 过度抽象,与"保持简单"要求矛盾 --- ### D5: 执行顺序 - 先计算指标,再执行回测 **决策**: ``` 1. load_data_from_db() → 获取原始价格数据 2. calculate_indicators(data) → 添加指标列到 DataFrame 3. Backtest(data, strategy_class) → 执行回测 ``` **理由**: - 指标计算与回测分离,便于调试和验证 - 避免在 Strategy 类的 `init()` 中重复计算 - 支持可视化指标(如果需要) **示例流程**: ```python data = load_data_from_db('000001.SZ', '2024-01-01', '2025-12-31') # data 包含: Open, High, Low, Close, Volume, factor data = calculate_indicators(data) # data 新增: sma10, sma30, sma60, sma120 bt = Backtest(data, SmaCross, cash=100000, commission=0.002) stats = bt.run() ``` **替代方案**: - 在 Strategy 类的 `init()` 中计算指标 - 导致指标逻辑分散,难以调试 --- ### D6: 输出格式 - 控制台 + 可选 HTML 文件 **决策**: **控制台输出**: - 始终打印回测统计信息(中文格式化) - 使用 notebook 中定义的 `INDICATOR_MAPPING` 映射 **HTML 输出**: - 仅当指定 `--output` 参数时生成 - 使用 `backtesting` 库的 `bt.plot(filename=..., show=False)` 方法 - 生成独立的 HTML 文件,无需浏览器环境 **理由**: - 用户要求"输出包括命令行输出和 html 文件输出,使用一个参数控制" - 控制台输出便于快速查看,HTML 文件便于分享和详细分析 - `show=False` 确保在无头环境中也能生成文件 **示例用法**: ```bash # 仅控制台输出 python backtest.py --code 000001.SZ --start-date 2024-01-01 --end-date 2025-12-31 --strategy-file strategy.py # 控制台 + HTML 文件 python backtest.py --code 000001.SZ --start-date 2024-01-01 --end-date 2025-12-31 --strategy-file strategy.py --output result.html ``` **替代方案**: - 始终生成 HTML 文件 - 增加不必要的磁盘 I/O - 自动在浏览器打开 - 不适用于服务器环境 --- ### D8: 预热天数 - 命令行参数控制 **决策**: ```python parser.add_argument('--warmup-days', type=int, default=365, help='预热天数(默认: 365,约一年)') ``` **执行逻辑**: 1. 用户从数据库查询的日期范围:`--start-date` 到 `--end-date` 2. 回测前,从数据中截取最后 N 天(由 `--warmup-days` 指定) 3. 截取的数据用于指标计算和回测 **理由**: - 用户明确要求:"如果命令行参数指定了,就用参数指定的时长,否则默认预热时长为一年" - 简化实现,不需要自动计算各策略所需的最长预热期 - 灵活性高,用户可根据需要调整预热天数 - 避免复杂化:不解析策略代码以确定最长指标周期 **示例**: ```python # 查询 2024-01-01 到 2025-12-31 的数据(2 年) data = load_data_from_db('000001.SZ', '2024-01-01', '2025-12-31') # 约 500 条记录 # 默认预热 365 天,取最后 1 年的数据用于回测 data = data.iloc[-365:] # 2025-01-01 到 2025-12-31 # 用户指定预热 180 天 data = data.iloc[-180:] # 2025-07-01 到 2025-12-31 ``` **替代方案**: - 自动计算策略所需的最长指标周期 - 需要解析策略代码,复杂度高 - 不截取数据,依赖策略自己处理 NaN - 但用户明确要求预热天数控制 --- ### D7: 数据库凭证 - 环境变量 **决策**: ```python # 数据库配置(开发环境,直接硬编码) DB_HOST = '81.71.3.24' DB_NAME = 'leopard_dev' DB_USER = 'your_username' DB_PASSWORD = 'your_password' ``` **理由**: - 用户明确要求:"数据库凭证不使用环境变量,开发人员直接硬编码到代码里即可" - 开发环境仅内部使用,无安全风险 - 简化实现,无需环境变量管理 - 不引入额外的配置文件或库 **替代方案**: - 使用环境变量 - 用户明确拒绝 - 使用配置文件 - 增加维护成本,用户明确不需要 --- ## Risks / Trade-offs ### R1: SQL 注入风险 **风险**: 当前查询使用字符串拼接,存在 SQL 注入风险 **缓解措施**: - 用户要求"数据库访问保持简单",暂不实现参数化查询 - 文档中明确说明输入格式(股票代码、日期) - 后续可在 `load_data_from_db()` 中添加输入验证 --- ### R2: 策略文件加载失败 **风险**: 动态加载策略文件时,文件不存在或代码错误会导致运行时崩溃 **缓解措施**: - 使用 `try-except` 捕获 `ImportError` 和 `AttributeError` - 提供清晰的错误信息:"策略文件 {file} 加载失败: {error}" - 在文档中说明策略文件的标准接口 --- ### R3: 指标计算性能 **风险**: 大数据集(如 10 年日线数据)计算指标可能较慢 **缓解措施**: - 使用 pandas 的向量化操作(已实现) - 考虑在文档中提示:首次运行可能较慢,后续可缓存指标数据 - 当前不优化(属于非目标范围) --- ### R4: 策略接口兼容性 **风险**: 用户编写的策略文件可能不符合接口要求(缺少 `calculate_indicators` 或 `get_strategy`) **缓解措施**: - 提供 `strategy.py` 作为标准模板 - 在 `load_strategy()` 中进行接口检查 - 运行时捕获 `AttributeError` 并提示缺失的函数 --- ### R5: 图表生成失败 **风险**: Bokeh 生成 HTML 文件时可能因数据格式或依赖问题失败 **缓解措施**: - 仅在用户指定 `--output` 参数时才尝试生成图表 - 使用 `try-except` 捕获异常,不影响统计信息输出 - 错误提示:"图表生成失败,但回测已完成: {error}" --- ### R6: 时区和日期处理 **风险**: 数据库中的日期与用户输入的日期可能存在时区差异 **缓解措施**: - 当前 SQL 查询使用 `BETWEEN 'start_date 00:00:00' AND 'end_date 23:59:59'` 覆盖全天 - 假设数据库和用户输入使用相同的时区(本地时间) - 文档中说明日期格式为 `YYYY-MM-DD` --- ## Resolved Decisions 1. **数据库凭证管理**: ✅ 已决定 - 直接硬编码在代码中 - 实现方式:在 backtest.py 中定义 DB_HOST, DB_NAME, DB_USER, DB_PASSWORD 常量 - 不使用环境变量、不使用配置文件 - 开发人员可直接修改代码中的凭证 - 无安全风险(仅开发环境内部使用) 2. **错误处理详细程度**: ✅ 已决定 - 仅打印到控制台,不写入日志文件 - 实现方式:所有错误信息直接使用 `print()` 输出到 stdout/stderr - 不引入日志库(logging) - 保持输出简洁,便于管道处理 3. **指标预热期**: ✅ 已决定 - 通过 `--warmup-days` 命令行参数控制 - 实现方式:默认 365 天(约 1 年),用户可指定其他值 - 不自动计算策略所需的最长指标周期 - 使用 `data.iloc[-warmup_days:]` 截取数据 4. **多策略并行**: ✅ 已决定 - 不支持一次回测运行多个策略 - 实现方式:每次命令执行只支持单个策略文件 - 如需对比策略,用户需多次执行命令 - 不实现多进程/多线程并行回测 --- ## Implementation Overview ### 核心流程 ``` main() ├─ parse_arguments() # 解析命令行参数 ├─ load_data_from_db() # 从数据库获取价格数据 │ └─ 返回 DataFrame: [Open, High, Low, Close, Volume, factor] ├─ load_strategy() # 动态加载策略文件 │ └─ 返回: (calculate_indicators, strategy_class) ├─ calculate_indicators(data) # 计算技术指标 │ └─ 返回添加了指标列的 DataFrame ├─ Backtest(data, strategy) # 执行回测 │ └─ 返回 stats 对象 ├─ print_stats(stats) # 控制台输出中文统计 └─ bt.plot(filename=..., show=False) # 可选:生成 HTML 图表 ``` ### 文件结构 ``` leopard_analysis/ ├── backtest.py # 主流程脚本 ├── strategy.py # SMA 策略模板 ├── strategies/ # 其他策略(可选) │ ├── macd_strategy.py │ ├── rsi_strategy.py │ └── ... ├── .env # 数据库凭证(可选) ├── requirements.txt # 依赖列表 └── README.md # 使用说明(可选) ``` ### 依赖关系 ``` backtest.py ├─ argparse # 命令行参数解析 ├─ sqlalchemy # 数据库连接 ├─ pandas # 数据处理 ├─ importlib # 动态模块加载 └─ backtesting # 回测引擎 strategy.py ├─ pandas # DataFrame 操作 ├─ backtesting # Strategy 基类 └─ backtesting.lib # crossover 等工具函数 ```