diff --git a/backtest.py b/backtest.py new file mode 100644 index 0000000..7d01d95 --- /dev/null +++ b/backtest.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +""" +量化回测主程序 + +使用方法: + python backtest.py --code 000001.SZ --start-date 2024-01-01 --end-date 2025-12-31 --strategy-file strategy.py +""" + +import argparse +import sys +import os +import importlib.util +import pandas as pd +from datetime import datetime +from backtesting import Backtest + +# 数据库配置(直接硬编码,开发环境) +DB_HOST = "81.71.3.24" +DB_PORT = 6785 +DB_NAME = "leopard_dev" +DB_USER = "leopard" +DB_PASSWORD = "9NEzFzovnddf@PyEP?e*AYAWnCyd7UhYwQK$pJf>7?ccFiN^x4$eKEZ5~E<7<+~X" + + +def load_data_from_db(code, start_date, end_date): + """ + 从数据库加载历史数据 + + 参数: + code: 股票代码(如 '000001.SZ') + start_date: 开始日期(如 '2024-01-01') + end_date: 结束日期(如 '2025-12-31') + + 返回: + DataFrame, 包含列: [Open, High, Low, Close, Volume, factor] + """ + import sqlalchemy + import urllib.parse + + # 构建连接字符串(URL 编码密码中的特殊字符) + encoded_password = urllib.parse.quote_plus(DB_PASSWORD) + conn_str = ( + f"postgresql://{DB_USER}:{encoded_password}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + ) + engine = sqlalchemy.create_engine(conn_str) + + try: + # 构建 SQL 查询 + query = f""" + 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 + """ + + # 执行查询 + df = pd.read_sql(query, engine) + + if len(df) == 0: + raise ValueError(f"未找到股票 {code} 在指定时间范围内的数据") + + # 潬换日期并设置为索引 + df["trade_date"] = pd.to_datetime(df["trade_date"], format="%Y-%m-%d") + df.set_index("trade_date", inplace=True) + + return df + + finally: + # 清理连接 + engine.dispose() + + +def load_strategy(strategy_file): + """ + 动态加载策略文件 + + 参数: + strategy_file: 策略文件路径 (如 'strategy.py' 或 'strategies/macd.py') + + 返回: + (calculate_indicators, strategy_class) 元组 + """ + # 获取模块名 + module_name = strategy_file.replace(".py", "").replace("/", ".") + spec = importlib.util.spec_from_file_location(module_name, strategy_file) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # 接口验证 + if not hasattr(module, "calculate_indicators"): + raise AttributeError(f"策略文件 {strategy_file} 缺少 calculate_indicators 函数") + + if not hasattr(module, "get_strategy"): + raise AttributeError(f"策略文件 {strategy_file} 缺少 get_strategy 函数") + + calculate_indicators = module.calculate_indicators + strategy_class = module.get_strategy() + + # 验证 get_strategy 返回的是类 + if not isinstance(strategy_class, type): + raise TypeError("get_strategy() 必须返回一个类") + + # 验证策略类继承自 backtesting.Strategy + from backtesting import Strategy + + if not issubclass(strategy_class, Strategy): + raise TypeError("策略类必须继承 backtesting.Strategy") + + return calculate_indicators, strategy_class + + +def parse_arguments(): + """ + 解析命令行参数 + + 返回: + args: 命名空间对象 + """ + parser = argparse.ArgumentParser( + description="量化回测工具", formatter_class=argparse.RawDescriptionHelpFormatter + ) + + # 必需参数 + parser.add_argument( + "--code", type=str, required=True, help="股票代码 (如: 000001.SZ)" + ) + parser.add_argument( + "--start-date", type=str, required=True, help="回测开始日期 (格式: YYYY-MM-DD)" + ) + parser.add_argument( + "--end-date", type=str, required=True, help="回测结束日期 (格式: YYYY-MM-DD)" + ) + parser.add_argument( + "--strategy-file", + type=str, + required=True, + help="策略文件路径 (如: strategy.py)", + ) + + # 可选参数 + parser.add_argument( + "--cash", type=float, default=100000, help="初始资金 (默认: 100000)" + ) + parser.add_argument( + "--commission", type=float, default=0.002, help="手续费率 (默认: 0.002)" + ) + parser.add_argument( + "--output", type=str, default=None, help="HTML 输出文件路径 (可选)" + ) + parser.add_argument( + "--warmup-days", type=int, default=365, help="预热天数 (默认: 365,约一年)" + ) + + return parser.parse_args() + + +def format_value(value, cn_name, key): + """ + 格式化数值显示 + """ + if isinstance(value, (int, float)): + if "%" in cn_name or key in [ + "Sharpe Ratio", + "Sortino Ratio", + "Calmar Ratio", + "Profit Factor", + ]: + formatted_value = f"{value:.2f}" + elif "$" in cn_name: + formatted_value = f"{value:.2f}" + elif "次数" in cn_name: + formatted_value = f"{value:.0f}" + else: + formatted_value = f"{value:.4f}" + else: + formatted_value = str(value) + + return formatted_value + + +def print_stats(stats): + """ + 打印回测统计结果 + + 参数: + stats: backtesting 库返回的统计对象 + """ + print("\n" + "=" * 60) + print("回测结果") + print("=" * 60) + + # 基本指标 + metrics = [ + ("Return (%)", "总收益率", "Return [%]"), + ("Return", "总收益", "Return"), + ("Sharpe Ratio", "夏普比率", "Sharpe Ratio"), + ("Sortino Ratio", "索提诺比率", "Sortino Ratio"), + ("Calmar Ratio", "卡尔玛比率", "Calmar Ratio"), + ("Max Drawdown (%)", "最大回撤 (%)", "Max. Drawdown [%]"), + ("Avg Drawdown (%)", "平均回撤 (%)", "Avg. Drawdown [%]"), + ("Max Drawdown Duration", "最大回撤持续天数", "Max. Drawdown Duration"), + ("Avg Drawdown Duration", "平均回撤持续天数", "Avg. Drawdown Duration"), + ] + + for key, cn_name, en_name in metrics: + try: + value = getattr(stats, key, None) + if value is not None: + formatted = format_value(value, cn_name, key) + print(f"{cn_name:20s}: {formatted}") + except Exception: + pass + + print() + + # 交易统计 + trade_metrics = [ + ("# Trades", "总交易次数", "# Trades"), + ("Win Rate [%]", "胜率 (%)", "Win Rate [%]"), + ("Best Trade", "最佳交易", "Best Trade"), + ("Worst Trade", "最差交易", "Worst Trade"), + ("Avg Trade", "平均交易", "Avg. Trade"), + ("Avg Win Trade", "平均盈利交易", "Avg. Win Trade"), + ("Avg Loss Trade", "平均亏损交易", "Avg. Loss Trade"), + ("Profit Factor", "盈利因子", "Profit Factor"), + ("Expectancy", "期望值", "Expectancy"), + ] + + for key, cn_name, en_name in trade_metrics: + try: + value = getattr(stats, key, None) + if value is not None: + formatted = format_value(value, cn_name, key) + print(f"{cn_name:20s}: {formatted}") + except Exception: + pass + + print("=" * 60 + "\n") + + +def main(): + """ + 主函数:编排完整回测流程 + """ + try: + # 解析参数 + args = parse_arguments() + + # 加载数据 + print(f"加载股票数据: {args.code} ({args.start_date} ~ {args.end_date})") + data = load_data_from_db(args.code, args.start_date, args.end_date) + print(f"数据加载完成,共 {len(data)} 条记录") + + # 截取预热数据 + warmup_data = data.iloc[-args.warmup_days :] + print(f"使用预热数据范围: {warmup_data.index[0]} ~ {warmup_data.index[-1]}") + + # 加载策略 + print(f"加载策略: {args.strategy_file}") + calculate_indicators, strategy_class = load_strategy(args.strategy_file) + + # 计算指标 + print("计算指标...") + warmup_data = calculate_indicators(warmup_data) + print("指标计算完成") + + # 执行回测 + print("开始回测...") + from backtesting import Backtest + + bt = Backtest( + warmup_data, + strategy_class, + cash=args.cash, + commission=args.commission, + finalize_trades=True, + ) + stats = bt.run() + + # 输出结果 + print_stats(stats) + + # 生成图表 + if args.output: + print(f"\n生成图表: {args.output}") + bt.plot(filename=args.output, show=False) + print(f"图表已保存到: {args.output}") + + print("\n回测完成!") + + except Exception as e: + print(f"\n错误: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/openspec/changes/archive/2026-01-27-refactor-backtest-script/.openspec.yaml b/openspec/changes/archive/2026-01-27-refactor-backtest-script/.openspec.yaml new file mode 100644 index 0000000..fc9f48b --- /dev/null +++ b/openspec/changes/archive/2026-01-27-refactor-backtest-script/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-27 diff --git a/openspec/changes/archive/2026-01-27-refactor-backtest-script/design.md b/openspec/changes/archive/2026-01-27-refactor-backtest-script/design.md new file mode 100644 index 0000000..931cfda --- /dev/null +++ b/openspec/changes/archive/2026-01-27-refactor-backtest-script/design.md @@ -0,0 +1,441 @@ +# 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 等工具函数 +``` diff --git a/openspec/changes/archive/2026-01-27-refactor-backtest-script/proposal.md b/openspec/changes/archive/2026-01-27-refactor-backtest-script/proposal.md new file mode 100644 index 0000000..827be6b --- /dev/null +++ b/openspec/changes/archive/2026-01-27-refactor-backtest-script/proposal.md @@ -0,0 +1,83 @@ +# Proposal: Refactor Backtest Script + +## Why + +当前回测系统使用 Jupyter Notebook (`backtest.ipynb`) 手动执行,存在以下问题: +- 不支持自动化批量回测,无法通过命令行调用 +- 策略逻辑与数据获取混在一起,难以复用和切换 +- 缺乏参数化配置,每次回测需要手动修改代码 +- 无法方便地对比不同策略在同一股票、不同时间段的表现 + +通过将回测流程重构为命令行工具,可以实现: +- 支持命令行参数化调用,便于批量执行 +- 策略模块化,支持动态加载不同的策略文件 +- 简化数据加载逻辑,专注于回测核心流程 +- 提高代码可维护性和可扩展性 + +## What Changes + +### 新增文件 + +1. **backtest.py** - 主流程脚本 + - 命令行参数解析 (`--code`, `--start-date`, `--end-date`, `--strategy-file`, `--cash`, `--commission`, `--output`) + - 数据库连接与数据加载(查询复权后的价格数据) + - 动态加载策略文件(通过 `importlib`) + - 执行回测(使用 `backtesting` 库) + - 结果输出: + - 控制台中文格式化统计信息 + - HTML 图表文件(可选,通过 `--output` 参数控制) + +2. **strategy.py** - 策略模板文件 + - `calculate_indicators(data)` 函数:计算策略所需的技术指标(如 SMA、MACD) + - `get_strategy()` 函数:返回策略类 + - `SmaCross` 类:继承 `backtesting.Strategy`,实现交易逻辑(金叉买入、死叉卖出) + +### 主要功能特性 + +- **动态策略加载**:通过 `--strategy-file` 参数指定任意策略文件 +- **简化的数据库访问**:直接 SQL 查询获取数据,不引入额外抽象 +- **指标计算策略化**:每个策略文件自己定义需要计算的指标 +- **结果输出控制**:默认控制台输出,通过 `--output` 参数生成 HTML 图表 + +### 现有文件变更 + +- 无(新增文件,不修改现有 Notebook) + +## Capabilities + +### New Capabilities + +- **backtest-cli**: 命令行回测工具,支持通过参数化方式执行量化回测 +- **strategy-loading**: 动态加载策略模块,支持从指定路径导入策略类和指标计算函数 +- **data-fetching**: 从 PostgreSQL 数据库获取股票历史价格数据,自动处理复权 + +### Modified Capabilities + +- 无(不涉及现有规范级别的需求变更) + +## Impact + +### 代码影响 + +- 新增 `backtest.py` 作为主入口文件 +- 新增 `strategy.py` 作为策略模板 +- 可选新增 `strategies/` 目录存放其他策略文件 + +### 依赖影响 + +**新增依赖**: +- `sqlalchemy` - 数据库连接 +- `backtesting` - 回测引擎 +- `pandas`, `numpy` - 数据处理(已存在于 Notebook 中) + +### API/系统影响 + +- 无外部 API 变更 +- 数据库查询逻辑从 Notebook 迁移到 Python 脚本 +- 输出从 Notebook 交互式展示改为命令行 + HTML 文件 + +### 用户影响 + +- 用户可以通过命令行执行回测,无需打开 Jupyter Notebook +- 策略开发者可以独立开发策略文件,通过约定接口集成到主流程 +- 回测结果以 HTML 文件形式保存,便于分享和查看 diff --git a/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/backtest-cli/spec.md b/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/backtest-cli/spec.md new file mode 100644 index 0000000..f523ae4 --- /dev/null +++ b/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/backtest-cli/spec.md @@ -0,0 +1,195 @@ +# Spec: Backtest CLI + +## ADDED Requirements + +### Requirement: 命令行参数解析 +回测脚本 SHALL 通过命令行参数接收用户输入,参数 SHALL 包含股票代码、时间范围、策略文件、回测参数等。 + +#### Scenario: 基础回测执行 +- **WHEN** 用户执行 `python backtest.py --code 000001.SZ --start-date 2024-01-01 --end-date 2025-12-31 --strategy-file strategy.py` +- **THEN** 系统解析所有必需参数,无错误提示 +- **THEN** 开始执行回测流程 +- **THEN** 回测完成后输出统计信息到控制台 + +#### Scenario: 可选参数未指定 +- **WHEN** 用户未指定 `--cash` 参数 +- **THEN** 系统使用默认值 100000 作为初始资金 +- **WHEN** 用户未指定 `--commission` 参数 +- **THEN** 系统使用默认值 0.002 作为手续费率 +- **WHEN** 用户未指定 `--output` 参数 +- **THEN** 系统不生成 HTML 图表文件 + +#### Scenario: 必需参数缺失 +- **WHEN** 用户未提供 `--code` 参数 +- **THEN** 系统输出错误信息:"错误: 需要以下参数: --code" +- **THEN** 系统退出并返回非零状态码 +- **WHEN** 用户未提供 `--start-date` 或 `--end-date` 参数 +- **THEN** 系统输出对应的错误信息 +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 自定义参数值 +- **WHEN** 用户指定 `--cash 500000 --commission 0.001 --output result.html` +- **THEN** 系统使用指定的 500000 作为初始资金 +- **THEN** 系统使用指定的 0.001 作为手续费率 +- **THEN** 回测完成后生成 HTML 图表到 result.html + +--- + +### Requirement: 数据库数据加载 +回测脚本 SHALL 从 PostgreSQL 数据库加载指定股票的历史价格数据,并自动处理复权。 + +#### Scenario: 成功加载数据 +- **WHEN** 用户指定有效的股票代码和时间范围 +- **THEN** 系统连接数据库并执行查询 +- **THEN** 返回 DataFrame,包含列: [Open, High, Low, Close, Volume, factor] +- **THEN** DataFrame 的索引为 trade_date (DatetimeIndex) +- **THEN** 数据已应用复权计算(price * factor) + +#### Scenario: 数据库连接失败 +- **WHEN** 数据库连接失败(凭证错误、网络问题等) +- **THEN** 系统捕获异常并输出错误信息:"数据库连接失败: {error}" +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 未找到股票数据 +- **WHEN** 指定的股票代码或时间范围内无数据 +- **THEN** 系统抛出 ValueError: "未找到股票 {code} 在指定时间范围内的数据" +- **THEN** 主流程捕获异常并输出友好错误信息 +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 数据验证 +- **WHEN** 数据库返回的 DataFrame 为空 +- **THEN** 系统提示数据为空并退出 +- **WHEN** 数据库返回的 DataFrame 少于 10 条记录 +- **THEN** 系统提示数据不足并退出 + +--- + +### Requirement: 策略动态加载 +回测脚本 SHALL 支持动态加载指定路径的策略文件,并验证策略接口。 + +#### Scenario: 加载有效策略文件 +- **WHEN** 用户指定 `--strategy-file strategy.py` +- **THEN** 系统通过 importlib 加载该模块 +- **THEN** 系统获取模块的 `calculate_indicators` 函数 +- **THEN** 系统调用模块的 `get_strategy()` 函数获取策略类 +- **THEN** 系统返回 (calculate_indicators, strategy_class) 元组 + +#### Scenario: 策略文件不存在 +- **WHEN** 用户指定的策略文件路径不存在 +- **THEN** 系统捕获 FileNotFoundError +- **THEN** 输出错误信息:"策略文件 {file} 不存在" +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 策略接口不完整 +- **WHEN** 策略文件缺少 `calculate_indicators` 函数 +- **THEN** 系统捕获 AttributeError +- **THEN** 输出错误信息:"策略文件 {file} 缺少 calculate_indicators 函数" +- **THEN** 系统退出并返回非零状态码 +- **WHEN** 策略文件缺少 `get_strategy` 函数 +- **THEN** 系统捕获 AttributeError +- **THEN** 输出错误信息:"策略文件 {file} 缺少 get_strategy 函数" +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 加载子目录中的策略 +- **WHEN** 用户指定 `--strategy-file strategies/macd_strategy.py` +- **THEN** 系统正确加载子目录中的策略模块 +- **THEN** 系统成功获取策略类和指标计算函数 + +--- + +### Requirement: 指标计算 +回测脚本 SHALL 在执行回测前调用策略的指标计算函数,将技术指标添加到数据集中。 + +#### Scenario: 成功计算指标 +- **WHEN** 系统调用 `calculate_indicators(data)` +- **THEN** 函数接收包含 [Open, High, Low, Close, Volume, factor] 的 DataFrame +- **THEN** 函数计算策略所需的指标(如 SMA, MACD, RSI) +- **THEN** 函数返回添加了指标列的 DataFrame +- **THEN** DataFrame 保留原始列,新增指标列 + +#### Scenario: 指标计算产生 NaN 值 +- **WHEN** 滚动窗口计算导致前 N 行的指标值为 NaN +- **THEN** DataFrame 包含 NaN 值(系统不自动删除) +- **THEN** Backtest 框架在回测时会跳过 NaN 值的行 + +#### Scenario: 指标计算函数抛出异常 +- **WHEN** `calculate_indicators(data)` 执行时抛出异常 +- **THEN** 主流程捕获异常 +- **THEN** 输出错误信息:"指标计算失败: {error}" +- **THEN** 系统退出并返回非零状态码 + +--- + +### Requirement: 回测执行 +回测脚本 SHALL 使用 backtesting 库执行回测,传入数据、策略和参数。 + +#### Scenario: 成功执行回测 +- **WHEN** 系统调用 `Backtest(data, strategy_class, cash=..., commission=...).run()` +- **THEN** Backtest 初始化时调用策略类的 `init()` 方法 +- **THEN** Backtest 逐个时间步调用策略类的 `next()` 方法 +- **THEN** 系统返回包含回测统计信息的 stats 对象 + +#### Scenario: 回测参数传递 +- **WHEN** 用户指定 `--cash 500000 --commission 0.001` +- **THEN** Backtest 实例化时使用 cash=500000 +- **THEN** Backtest 实例化时使用 commission=0.001 +- **THEN** Backtest 实例化时使用 finalize_trades=True + +#### Scenario: 回测运行时错误 +- **WHEN** 策略的 `next()` 方法执行时抛出异常 +- **THEN** backtesting 库捕获异常 +- **THEN** 系统输出错误信息和堆栈跟踪 +- **THEN** 系统退出并返回非零状态码 + +--- + +### Requirement: 结果输出 +回测脚本 SHALL 将回测统计信息格式化输出到控制台,并可选生成 HTML 图表文件。 + +#### Scenario: 控制台输出 +- **WHEN** 回测成功完成 +- **THEN** 系统调用 `print_stats(stats)` 函数 +- **THEN** 系统输出回测统计信息,使用中文标签 +- **THEN** 输出内容包括:最终收益、总收益率、年化收益率、最大回撤、胜率等 +- **THEN** 数值格式化(保留 2 位小数) + +#### Scenario: 生成 HTML 图表 +- **WHEN** 用户指定 `--output result.html` +- **THEN** 系统调用 `bt.plot(filename='result.html', show=False)` +- **THEN** 系统生成 HTML 文件到 result.html +- **THEN** 系统输出提示:"图表已保存到: result.html" +- **THEN** 图表包含价格曲线、资金曲线、买卖信号等 + +#### Scenario: 不生成 HTML 图表 +- **WHEN** 用户未指定 `--output` 参数 +- **THEN** 系统不调用 bt.plot() 方法 +- **THEN** 系统不生成任何图表文件 +- **THEN** 系统仅输出控制台统计信息 + +#### Scenario: 图表生成失败 +- **WHEN** bt.plot() 方法执行时抛出异常 +- **THEN** 系统捕获异常 +- **THEN** 系统输出警告:"图表生成失败,但回测已完成: {error}" +- **THEN** 系统不影响控制台统计信息的输出 +- **THEN** 系统正常退出(返回状态码 0) + +--- + +### Requirement: 错误处理 +回测脚本 SHALL 对所有可能的错误进行捕获和处理,提供友好的错误提示。 + +#### Scenario: 数据库错误 +- **WHEN** 数据库操作抛出 sqlalchemy.exc.SQLAlchemyError +- **THEN** 系统输出错误信息:"数据库错误: {error}" +- **THEN** 系统退出并返回状态码 2 + +#### Scenario: 文件操作错误 +- **WHEN** 图表文件保存失败(权限、磁盘空间等) +- **THEN** 系统输出错误信息:"文件操作错误: {error}" +- **THEN** 系统退出并返回状态码 3 + +#### Scenario: 未预期的错误 +- **WHEN** 发生其他未捕获的异常 +- **THEN** 系统输出错误信息:"未知错误: {error}" +- **THEN** 系统输出完整的堆栈跟踪 +- **THEN** 系统退出并返回状态码 1 diff --git a/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/data-fetching/spec.md b/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/data-fetching/spec.md new file mode 100644 index 0000000..bcc8d8f --- /dev/null +++ b/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/data-fetching/spec.md @@ -0,0 +1,280 @@ +# Spec: Data Fetching + +## ADDED Requirements + +### Requirement: 数据库连接配置 +系统 SHALL 通过硬编码常量管理数据库连接参数(开发环境)。 + +#### Scenario: 使用硬编码常量 +- **WHEN** 系统在 backtest.py 中定义数据库配置 +- **THEN** 系统定义 DB_HOST, DB_NAME, DB_USER, DB_PASSWORD 常量 +- **THEN** DB_HOST 值 SHALL 为数据库主机地址(如 '81.71.3.24') +- **THEN** DB_NAME 值 SHALL 为数据库名称(如 'leopard_dev') +- **THEN** DB_USER 值 SHALL 为数据库用户名 +- **THEN** DB_PASSWORD 值 SHALL 为数据库密码 + +#### Scenario: 构建连接字符串 +- **WHEN** 系统创建 SQLAlchemy 连接 +- **THEN** 系统使用硬编码的常量构建连接字符串 +- **THEN** 连接字符串格式 SHALL 为 `postgresql://{user}:{password}@{host}/{database}` +- **THEN** 不从环境变量读取任何凭证 + +#### Scenario: 修改数据库凭证 +- **WHEN** 开发人员需要更换数据库或凭证 +- **THEN** 开发人员直接修改 backtest.py 中的常量值 +- **THEN** 修改后脚本使用新凭证连接数据库 + +--- + +### Requirement: 数据库连接建立 +系统 SHALL 使用 SQLAlchemy 创建 PostgreSQL 数据库连接。 + +#### Scenario: 成功建立连接 +- **WHEN** 凭证正确且数据库可访问 +- **THEN** 系统使用 `sqlalchemy.create_engine(conn_str)` 创建引擎 +- **THEN** 连接字符串格式 SHALL 为 `postgresql://{user}:{password}@{host}/{database}` +- **THEN** 系统成功创建引擎对象 +- **THEN** 系统可用于执行查询 + +#### Scenario: 连接字符串构建 +- **WHEN** 系统构建 PostgreSQL 连接字符串 +- **THEN** 连接字符串 SHALL 正确编码特殊字符(密码中的 @, : 等) +- **THEN** 连接字符串 SHALL 使用标准 URI 格式 +- **THEN** 连接字符串 SHALL 不包含额外选项(仅基础连接参数) + +#### Scenario: 数据库连接失败 +- **WHEN** 凭证错误或数据库不可达 +- **THEN** SQLAlchemy 抛出 `sqlalchemy.exc.OperationalError` +- **THEN** 主流程捕获异常 +- **THEN** 系统输出错误信息:"数据库连接失败: {error}" +- **THEN** 系统退出并返回状态码 2 + +#### Scenario: 连接池管理 +- **WHEN** 系统创建引擎对象 +- **THEN** SQLAlchemy SHALL 自动管理连接池 +- **THEN** 查询后连接 SHALL 自动返回池中 +- **THEN** 系统 SHALL 在查询完成后调用 `engine.dispose()` 清理 + +--- + +### Requirement: SQL 查询构建 +系统 SHALL 构建参数化的 SQL 查询以获取股票历史数据。 + +#### Scenario: 基础查询结构 +- **WHEN** 系统构建查询 +- **THEN** 查询 SHALL 选择 trade_date, Open, High, Low, Close, Volume, factor +- **THEN** 查询 SHALL 连接 leopard_daily 和 leopard_stock 表 +- **THEN** 查询 SHALL 按 stock.code 过滤 +- **THEN** 查询 SHALL 按 trade_date 范围过滤 +- **THEN** 查询 SHALL 按 trade_date 升序排序 + +#### Scenario: 复权价格计算 +- **WHEN** 系统计算复权价格 +- **THEN** Open SHALL 计算为 `open * factor` +- **THEN** Close SHALL 计算为 `close * factor` +- **THEN** High SHALL 计算为 `high * factor` +- **THEN** Low SHALL 计算为 `low * factor` +- **THEN** Volume SHALL 直接使用原始值(不复权) +- **THEN** factor SHALL 使用 `COALESCE(factor, 1.0)` 处理 NULL 值 + +#### Scenario: 参数化股票代码 +- **WHEN** 用户指定股票代码(如 '000001.SZ') +- **THEN** 查询 WHERE 子句 SHALL 使用 `stock.code = '{code}'` +- **THEN** 代码 SHALL 精确匹配(不使用 LIKE) +- **THEN** 查询 SHALL 返回匹配股票的所有日线数据 + +#### Scenario: 参数化日期范围 +- **WHEN** 用户指定开始日期 '2024-01-01' 和结束日期 '2025-12-31' +- **THEN** 查询 WHERE 子句 SHALL 使用 `BETWEEN '{start_date} 00:00:00' AND '{end_date} 23:59:59'` +- **THEN** 00:00:00 和 23:59:59 SHALL 覆盖全天 +- **THEN** 日期格式 SHALL 为 YYYY-MM-DD HH:MM:SS + +#### Scenario: 完整 SQL 查询 +- **WHEN** 系统执行数据加载 +- **THEN** 查询 SHALL 为: + ```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 + ``` + +--- + +### Requirement: 数据查询执行 +系统 SHALL 使用 pandas 的 `read_sql` 函数执行 SQL 查询并返回 DataFrame。 + +#### Scenario: 成功执行查询 +- **WHEN** SQL 查询有效且数据存在 +- **THEN** 系统调用 `pd.read_sql(query, engine)` +- **THEN** 系统返回 DataFrame 对象 +- **THEN** DataFrame SHALL 包含查询结果的所有列 +- **THEN** DataFrame 行数 SHALL 匹配数据库返回的记录数 + +#### Scenario: 数据类型处理 +- **WHEN** pandas 读取 SQL 结果 +- **THEN** trade_date SHALL 自动转换为 datetime 类型 +- **THEN** Open, High, Low, Close, Volume SHALL 为 float 类型 +- **THEN** factor SHALL 为 float 类型 +- **THEN** 系统不需要手动类型转换(除日期索引设置) + +#### Scenario: 查询返回空结果 +- **WHEN** 指定股票代码或日期范围无数据 +- **THEN** `read_sql` 返回空 DataFrame(0 行) +- **THEN** 系统检查 `len(df) == 0` +- **THEN** 系统抛出 ValueError: "未找到股票 {code} 在指定时间范围内的数据" + +#### Scenario: SQL 语法错误 +- **WHEN** SQL 查询包含语法错误 +- **THEN** SQLAlchemy 抛出 `sqlalchemy.exc.ProgrammingError` +- **THEN** 主流程捕获异常 +- **THEN** 系统输出错误信息:"SQL 查询错误: {error}" +- **THEN** 系统退出并返回状态码 2 + +--- + +### Requirement: 数据格式转换 +系统 SHALL 将查询结果转换为 backtesting 库要求的格式。 + +#### Scenario: 设置日期索引 +- **WHEN** DataFrame 加载完成 +- **THEN** 系统调用 `df.set_index('trade_date', inplace=True)` +- **THEN** DataFrame 的索引 SHALL 为 DatetimeIndex +- **THEN** 索引 SHALL 不再是数值索引 +- **THEN** backtesting 库 SHALL 能正确处理日期范围 + +#### Scenario: 列名格式化 +- **WHEN** DataFrame 加载完成 +- **THEN** 列名 SHALL 为 ['Open', 'High', 'Low', 'Close', 'Volume', 'factor'] +- **THEN** 列名 SHALL 遵循 backtesting 库要求(首字母大写) +- **THEN** 列名 SHALL 与 SQL 查询中的别名一致 + +#### Scenario: 数据验证 +- **WHEN** 系统准备返回 DataFrame +- **THEN** 系统验证 DataFrame 包含必需列 +- **THEN** 系统验证 'Open', 'High', 'Low', 'Close', 'Volume' 列存在 +- **THEN** 系统验证索引为 DatetimeIndex +- **WHEN** 验证失败 +- **THEN** 系统抛出 ValueError: "数据格式不符合要求" + +--- + +### Requirement: 数据清理 +系统 SHALL 清理数据以确保回测质量。 + +#### Scenario: 删除 NULL 值行 +- **WHEN** DataFrame 包含 NULL 或 NaN 值 +- **THEN** 系统调用 `df.dropna()` 删除 +- **THEN** 任何包含 NaN 的行 SHALL 被删除 +- **THEN** 返回的 DataFrame SHALL 不包含 NULL 值 + +#### Scenario: 数据完整性检查 +- **WHEN** DataFrame 加载完成 +- **THEN** 系统检查 trade_date 连续性 +- **THEN** 系统检查无重复日期 +- **WHEN** 发现异常 +- **THEN** 系统输出警告:"数据存在异常: {detail}" + +#### Scenario: 最小数据量验证 +- **WHEN** DataFrame 行数少于 10 +- **THEN** 系统输出错误:"数据不足,至少需要 10 天数据" +- **THEN** 系统抛出 ValueError +- **THEN** 主流程捕获并退出 + +--- + +### Requirement: 资源管理 +系统 SHALL 正确管理数据库连接和内存资源。 + +#### Scenario: 引擎创建和清理 +- **WHEN** 系统开始数据加载 +- **THEN** 系统创建 SQLAlchemy 引擎对象 +- **THEN** 系统使用引擎执行查询 +- **WHEN** 查询完成 +- **THEN** 系统调用 `engine.dispose()` 关闭连接池 +- **THEN** 系统释放所有数据库连接 + +#### Scenario: 异常情况下的资源清理 +- **WHEN** 查询过程中抛出异常 +- **THEN** 系统在 finally 块中调用 `engine.dispose()` +- **THEN** 所有连接 SHALL 被正确关闭 +- **THEN** 系统不会泄漏数据库连接 + +--- + +### Requirement: 错误处理和日志 +系统 SHALL 提供清晰的错误信息和调试支持。 + +#### Scenario: 连接错误信息 +- **WHEN** 数据库连接失败 +- **THEN** 错误信息 SHALL 包含数据库主机和端口 +- **THEN** 错误信息 SHALL 区分网络错误和认证错误 +- **THEN** 系统提示用户检查凭证和网络连接 + +#### Scenario: 查询错误信息 +- **WHEN** SQL 查询失败 +- **THEN** 错误信息 SHALL 包含失败的 SQL 语句 +- **THEN** 错误信息 SHALL 包含数据库返回的错误详情 +- **THEN** 系统提示用户检查表结构和数据 + +#### Scenario: 数据格式错误信息 +- **WHEN** 返回的 DataFrame 不符合要求 +- **THEN** 错误信息 SHALL 列出缺失的列 +- **THEN** 错误信息 SHALL 提示期望的格式 +- **THEN** 系统建议用户检查数据库表结构 + +--- + +### Requirement: 函数接口 +`load_data_from_db` 函数 SHALL 提供清晰的调用接口。 + +#### Scenario: 函数签名 +- **WHEN** 主流程调用 `load_data_from_db(code, start_date, end_date)` +- **THEN** 函数接收三个字符串参数 +- **THEN** `code` 为股票代码(如 '000001.SZ') +- **THEN** `start_date` 为开始日期(如 '2024-01-01') +- **THEN** `end_date` 为结束日期(如 '2025-12-31') + +#### Scenario: 返回值 +- **WHEN** 数据加载成功 +- **THEN** 函数返回 pandas.DataFrame +- **THEN** DataFrame 索引为 DatetimeIndex(trade_date) +- **THEN** DataFrame 包含 ['Open', 'High', 'Low', 'Close', 'Volume', 'factor'] 列 + +#### Scenario: 异常抛出 +- **WHEN** 数据加载失败 +- **THEN** 函数 SHALL 抛出异常(不捕获) +- **THEN** 异常类型 SHALL 为 ValueError(业务逻辑错误) +- **THEN** 主流程负责捕获和处理异常 + +--- + +### Requirement: 性能考虑 +系统 SHALL 优化数据加载性能以支持大数据集。 + +#### Scenario: 使用 pandas 向量化操作 +- **WHEN** 执行复权计算 +- **THEN** 计算 SHALL 使用 pandas 向量化操作 +- **THEN** 不使用循环逐行计算 +- **THEN** 10 年数据(约 2500 行) SHALL 在 1 秒内加载 + +#### Scenario: 索引优化 +- **WHEN** 设置 DataFrame 索引 +- **THEN** `set_index()` 操作 SHALL 高效(使用底层数组拷贝) +- **THEN** 日期索引 SHALL 支持快速范围查询 + +#### Scenario: 内存管理 +- **WHEN** 加载大数据集 +- **THEN** 系统 SHALL 及时调用 `engine.dispose()` 释放连接 +- **THEN** DataFrame SHALL 使用 pandas 内部优化存储 +- **THEN** 内存占用 SHALL 合理(10 年数据约几 MB) diff --git a/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/strategy-loading/spec.md b/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/strategy-loading/spec.md new file mode 100644 index 0000000..db8a74e --- /dev/null +++ b/openspec/changes/archive/2026-01-27-refactor-backtest-script/specs/strategy-loading/spec.md @@ -0,0 +1,225 @@ +# Spec: Strategy Loading + +## ADDED Requirements + +### Requirement: 策略文件接口 +策略文件 SHALL 提供两个必需的接口:指标计算函数和策略类获取函数。 + +#### Scenario: 标准策略文件结构 +- **WHEN** 用户创建策略文件 +- **THEN** 文件 SHALL 包含 `calculate_indicators(data)` 函数 +- **THEN** 文件 SHALL 包含 `get_strategy()` 函数 +- **THEN** 文件 SHALL 包含一个继承 `backtesting.Strategy` 的类 +- **THEN** 所有三个组件 SHALL 在同一文件中 + +#### Scenario: calculate_indicators 函数签名 +- **WHEN** 主流程调用 `calculate_indicators(data)` +- **THEN** 函数接收一个参数:data (pandas.DataFrame) +- **THEN** 函数返回一个 pandas.DataFrame +- **THEN** 返回的 DataFrame SHALL 包含原始列和新增的指标列 +- **THEN** 函数 SHALL 修改输入的 DataFrame(不创建副本) + +#### Scenario: get_strategy 函数签名 +- **WHEN** 主流程调用 `get_strategy()` +- **THEN** 函数不接收参数 +- **THEN** 函数返回一个类对象 +- **THEN** 返回的类 SHALL 继承自 `backtesting.Strategy` + +--- + +### Requirement: 指标计算函数 +`calculate_indicators` 函数 SHALL 计算策略所需的技术指标,并将结果添加到 DataFrame 中。 + +#### Scenario: SMA 指标计算 +- **WHEN** 策略需要简单移动平均线指标 +- **THEN** 函数使用 `data['Close'].rolling(window=N).mean()` 计算 +- **THEN** 函数将结果存储为 `data['smaN']` 列 +- **THEN** N 为具体的周期(如 10, 30, 60, 120) + +#### Scenario: MACD 指标计算 +- **WHEN** 策略需要 MACD 指标 +- **THEN** 函数使用 `data['Close'].ewm(span=12).mean()` 计算 EMA12 +- **THEN** 函数使用 `data['Close'].ewm(span=26).mean()` 计算 EMA26 +- **THEN** 函数计算 MACD = EMA12 - EMA26 +- **THEN** 函数计算 Signal = MACD.ewm(span=9).mean() +- **THEN** 函数将结果存储为 `data['macd']`, `data['macd_signal']`, `data['macd_hist']` 列 + +#### Scenario: RSI 指标计算 +- **WHEN** 策略需要 RSI 指标 +- **THEN** 函数计算价格变化 delta = data['Close'].diff() +- **THEN** 函数计算 gain = delta.where(delta > 0, 0) +- **THEN** 函数计算 loss = -delta.where(delta < 0, 0) +- **THEN** 函数计算平均收益和平均损失 +- **THEN** 函数计算 RS = average_gain / average_loss +- **THEN** 函数计算 RSI = 100 - (100 / (1 + RS)) +- **THEN** 函数将结果存储为 `data['rsi']` 列 + +#### Scenario: 多指标计算 +- **WHEN** 策略需要多个技术指标 +- **THEN** 函数按顺序计算每个指标 +- **THEN** 函数将所有指标列添加到 DataFrame +- **THEN** DataFrame 最终包含原始列 + 所有指标列 +- **THEN** 计算顺序 SHALL 遵循指标间的依赖关系(如 MACD 依赖 EMA) + +#### Scenario: 指标列命名约定 +- **WHEN** 函数添加指标列到 DataFrame +- **THEN** 列名 SHALL 使用小写和下划线(如 `sma10`, `macd_signal`) +- **THEN** 列名 SHALL 与策略类的 `init()` 方法中引用的名称一致 +- **THEN** 列名 SHALL 避免与原始列冲突 + +--- + +### Requirement: 策略类定义 +策略类 SHALL 继承 `backtesting.Strategy`,并实现 `init()` 和 `next()` 方法。 + +#### Scenario: 策略类继承 +- **WHEN** 用户定义策略类 +- **THEN** 类 SHALL 显式继承 `backtesting.Strategy` +- **THEN** 类 SHALL 定义类属性作为可配置参数 +- **THEN** 类名 SHALL 使用大驼峰命名(如 `SmaCross`, `MacdStrategy`) + +#### Scenario: init 方法实现 +- **WHEN** Backtest 框架初始化策略时 +- **THEN** 系统调用策略类的 `init()` 方法 +- **THEN** `init()` 方法 SHALL 使用 `self.I()` 注册指标 +- **THEN** `self.I(lambda x: x, self.data.column_name)` SHALL 引用 DataFrame 中的指标列 +- **THEN** `init()` 方法 SHALL 不执行数据计算 + +#### Scenario: next 方法实现 - 金叉买入 +- **WHEN** 短期均线上穿长期均线(金叉) +- **THEN** `next()` 方法 SHALL 调用 `self.position.close()` 平仓 +- **THEN** `next()` 方法 SHALL 调用 `self.buy()` 开多仓 +- **THEN** `next()` 方法 SHALL 使用 `crossover()` 函数检测交叉 + +#### Scenario: next 方法实现 - 死叉卖出 +- **WHEN** 短期均线下穿长期均线(死叉) +- **THEN** `next()` 方法 SHALL 调用 `self.position.close()` 平仓 +- **THEN** `next()` 方法 SHALL 调用 `self.sell()` 开空仓 +- **THEN** `next()` 方法 SHALL 使用 `crossover()` 函数检测交叉 + +#### Scenario: next 方法实现 - 避免重复开仓 +- **WHEN** 策略已持有多仓,且买入信号触发 +- **THEN** `next()` 方法 SHALL 先调用 `self.position.close()` +- **THEN** `next()` 方法 SHALL 再调用 `self.buy()` +- **THEN** 系统 SHALL 自动处理仓位管理(不重复开仓) + +#### Scenario: 可配置策略参数 +- **WHEN** 策略类定义类属性 +- **THEN** 类属性 SHALL 作为策略参数(如 `short_period = 10`) +- **THEN** Backtest 框架 SHALL 自动访问这些属性 +- **THEN** 参数 SHALL 可通过 Backtest 构造函数覆盖 + +--- + +### Requirement: 策略类指标引用 +策略类的 `init()` 方法 SHALL 正确引用 DataFrame 中计算好的指标列。 + +#### Scenario: 引用 SMA 指标 +- **WHEN** DataFrame 包含 `sma10` 和 `sma30` 列 +- **THEN** `init()` 方法注册 `self.sma_short = self.I(lambda x: x, self.data.sma10)` +- **THEN** `init()` 方法注册 `self.sma_long = self.I(lambda x: x, self.data.sma30)` +- **THEN** `next()` 方法 SHALL 通过 `self.data.sma10` 和 `self.data.sma30` 访问指标 + +#### Scenario: 引用 MACD 指标 +- **WHEN** DataFrame 包含 `macd` 和 `macd_signal` 列 +- **THEN** `init()` 方法注册 `self.macd = self.I(lambda x: x, self.data.macd)` +- **THEN** `init()` 方法注册 `self.signal = self.I(lambda x: x, self.data.macd_signal)` +- **THEN** `next()` 方法 SHALL 通过 `self.data.macd` 和 `self.data.macd_signal` 访问指标 + +#### Scenario: 引用 RSI 指标 +- **WHEN** DataFrame 包含 `rsi` 列 +- **THEN** `init()` 方法注册 `self.rsi = self.I(lambda x: x, self.data.rsi)` +- **THEN** `next()` 方法 SHALL 通过 `self.data.rsi` 访问指标 +- **THEN** 策略逻辑 SHALL 使用 RSI 阈值生成信号(如 RSI > 70 超买) + +#### Scenario: 指标列不存在 +- **WHEN** 策略类引用的列名不存在于 DataFrame +- **THEN** Backtest 框架抛出 KeyError +- **THEN** 主流程捕获异常并输出错误信息:"指标列 {column} 不存在" +- **THEN** 系统退出并返回非零状态码 + +--- + +### Requirement: 动态加载机制 +主流程 SHALL 使用 importlib 动态加载策略文件模块。 + +#### Scenario: 加载顶层策略文件 +- **WHEN** 用户指定 `--strategy-file strategy.py` +- **THEN** 系统使用 `spec_from_file_location('strategy', 'strategy.py')` 创建规范 +- **THEN** 系统使用 `module_from_spec(spec)` 创建模块对象 +- **THEN** 系统使用 `spec.loader.exec_module(module)` 执行模块 +- **THEN** 系统成功获取 `module.calculate_indicators` 和 `module.get_strategy` + +#### Scenario: 加载子目录策略文件 +- **WHEN** 用户指定 `--strategy-file strategies/macd_strategy.py` +- **THEN** 系统使用 `spec_from_file_location('strategies.macd_strategy', 'strategies/macd_strategy.py')` +- **THEN** 模块名使用点号分隔(反映目录结构) +- **THEN** 系统成功加载子目录中的策略模块 + +#### Scenario: 模块命名空间隔离 +- **WHEN** 系统动态加载多个策略文件 +- **THEN** 每个策略模块 SHALL 有独立的命名空间 +- **THEN** 模块间 SHALL 不共享全局变量 +- **THEN** 系统通过 `getattr(module, name)` 明确访问函数和类 + +#### Scenario: 策略文件导入错误 +- **WHEN** 策略文件包含语法错误或导入错误 +- **THEN** `exec_module()` 抛出 ImportError 或 SyntaxError +- **THEN** 主流程捕获异常 +- **THEN** 系统输出错误信息:"策略文件 {file} 加载失败: {error}" +- **THEN** 系统退出并返回非零状态码 + +--- + +### Requirement: 策略接口验证 +主流程 SHALL 验证策略文件是否符合接口要求。 + +#### Scenario: 验证 calculate_indicators 存在 +- **WHEN** 系统加载策略模块 +- **THEN** 系统使用 `hasattr(module, 'calculate_indicators')` 检查函数 +- **WHEN** 函数不存在 +- **THEN** 系统抛出 AttributeError +- **THEN** 主流程捕获并输出:"策略文件 {file} 缺少 calculate_indicators 函数" + +#### Scenario: 验证 get_strategy 存在 +- **WHEN** 系统加载策略模块 +- **THEN** 系统使用 `hasattr(module, 'get_strategy')` 检查函数 +- **WHEN** 函数不存在 +- **THEN** 系统抛出 AttributeError +- **THEN** 主流程捕获并输出:"策略文件 {file} 缺少 get_strategy 函数" + +#### Scenario: 验证 get_strategy 返回类 +- **WHEN** 系统调用 `get_strategy()` +- **THEN** 系统使用 `isinstance(returned, type)` 检查返回值 +- **WHEN** 返回值不是类 +- **THEN** 系统抛出 TypeError +- **THEN** 主流程捕获并输出:"get_strategy() 必须返回一个类" + +#### Scenario: 验证策略类继承 +- **WHEN** 系统获取策略类 +- **THEN** 系统使用 `issubclass(strategy_class, backtesting.Strategy)` 检查继承 +- **WHEN** 策略类未继承 `backtesting.Strategy` +- **THEN** 系统抛出 TypeError +- **THEN** 主流程捕获并输出:"策略类必须继承 backtesting.Strategy" + +--- + +### Requirement: 策略文件示例 +系统 SHALL 提供策略模板文件作为开发者参考。 + +#### Scenario: 提供策略模板 +- **WHEN** 用户查看 strategy.py 文件 +- **THEN** 文件 SHALL 包含完整的策略示例(SMA 双均线交叉) +- **THEN** 文件 SHALL 包含清晰的注释说明每个接口的用途 +- **THEN** 文件 SHALL 包含代码示例(指标计算函数、get_strategy、策略类) + +#### Scenario: 策略文件文档 +- **WHEN** 策略文件开头有文档字符串 +- **THEN** 文档 SHALL 描述策略逻辑 +- **THEN** 文档 SHALL 列出需要的指标 +- **THEN** 文档 SHALL 说明参数含义(如 `short_period`, `long_period`) + +#### Scenario: 策略参数说明 +- **WHEN** 策略类定义类属性 +- **THEN** 每个属性 SHALL 有注释说明(如 `short_period = 10 # 短期均线周期`) +- **THEN** 参数 SHALL 使用有意义的名称(不是 param1, param2) diff --git a/openspec/changes/archive/2026-01-27-refactor-backtest-script/tasks.md b/openspec/changes/archive/2026-01-27-refactor-backtest-script/tasks.md new file mode 100644 index 0000000..c8770ee --- /dev/null +++ b/openspec/changes/archive/2026-01-27-refactor-backtest-script/tasks.md @@ -0,0 +1,366 @@ +# Tasks: Refactor Backtest Script + +## 1. 项目设置和依赖 + +- [x] 1.1 创建 requirements.txt 文件,列出所有必需的 Python 包(pandas, numpy, backtesting, sqlalchemy) +- [ ] 1.2 安装项目依赖(pip install -r requirements.txt) +- [x] 1.3 配置数据库凭证(在 backtest.py 中硬编码) + - 设置 DB_HOST = '81.71.3.24' + - 设置 DB_NAME = 'leopard_dev' + - 设置 DB_USER = 'your_username' + - 设置 DB_PASSWORD = 'your_password' + - 根据实际开发环境修改这些值 + +--- + +## 3. 策略模板实现 + +- [x] 3.1 创建 strategy.py 文件,包含策略模板和示例 +- [x] 3.2 实现 calculate_indicators(data) 函数 + - 计算 SMA10, SMA30, SMA60, SMA120 指标 + - 使用 data['Close'].rolling(window=N).mean() 方法 + - 将结果添加到 DataFrame(data['sma10'] 等) + - 返回添加了指标列的 DataFrame +- [x] 3.3 实现 get_strategy() 函数 + - 返回 SmaCross 类 + - 添加函数文档字符串说明用途 +- [x] 3.4 实现 SmaCross 策略类 + - 继承 backtesting.Strategy + - 定义类属性:short_period = 10, long_period = 30 + - 实现 init() 方法:使用 self.I() 注册 sma10 和 sma30 指标 + - 实现 next() 方法:使用 crossover() 检测金叉和死叉,执行买卖操作 +- [x] 3.5 添加详细的代码注释和文档字符串 + - 文件开头描述策略逻辑 + - 每个函数添加参数和返回值说明 + - 策略类参数添加注释(如 short_period 的含义) + +## 4. 策略动态加载功能 + +- [x] 4.1 在 backtest.py 中实现 load_strategy(strategy_file) 函数 + - 使用 importlib.util.spec_from_file_location() 加载模块 + - 使用 importlib.util.module_from_spec() 创建模块对象 + - 使用 spec.loader.exec_module() 执行模块 +- [x] 4.2 实现接口验证逻辑 + - 检查模块是否有 calculate_indicators 属性(hasattr 检查) + - 检查模块是否有 get_strategy 属性 + - 验证 get_strategy() 返回的是类对象(isinstance 检查) + - 验证策略类继承自 backtesting.Strategy(issubclass 检查) +- [x] 4.3 实现异常处理 + - 捕获 FileNotFoundError(策略文件不存在) + - 捕获 ImportError(模块导入失败) + - 捕获 AttributeError(接口不完整) + - 输出清晰的错误信息:"策略文件 {file} 加载失败: {error}" +- [x] 4.4 返回策略组件 + - 返回元组:(calculate_indicators 函数, strategy_class) + +## 5. 命令行参数解析 + +- [x] 5.1 实现 parse_arguments() 函数 + - 使用 argparse.ArgumentParser 创建解析器 + - 添加 --code 参数(必需,help: 股票代码) + - 添加 --start-date 参数(必需,help: 回测开始日期) + - 添加 --end-date 参数(必需,help: 回测结束日期) + - 添加 --strategy-file 参数(必需,help: 策略文件路径) + - 添加 --cash 参数(可选,default=100000,help: 初始资金) + - 添加 --commission 参数(可选,default=0.002,help: 手续费率) + - 添加 --output 参数(可选,help: HTML 输出文件路径) + - 添加 --warmup-days 参数(可选,default=365,help: 预热天数,默认一年) +- [x] 5.2 实现参数验证 + - 检查日期格式(YYYY-MM-DD),使用 datetime.strptime() 验证 + - 检查策略文件是否存在(os.path.isfile()) + - 验证数值参数为正数(cash, commission) +- [x] 5.3 添加友好的错误提示 + - 参数错误时显示帮助信息 + - 日期格式错误时提示正确格式 + +## 6. 结果输出功能 + +- [x] 6.1 实现 print_stats(stats) 函数 + - 创建 INDICATOR_MAPPING 字典(英文键 → 中文标签) + - 遍历 stats 对象的键值对 + - 使用中文标签格式化输出 +- [x] 6.2 实现格式化逻辑 + - 实现 format_value(value, cn_name, key) 辅助函数 + - 百分比和比率类值保留 2 位小数 + - 金额类值保留 2 位小数 + - 次数类值取整 + - 其他值保留 4 位小数 +- [x] 6.3 添加输出格式化 + - 输出标题:"回测结果"(使用 "=" * 60 分隔) + - 每个指标独占一行 + - 确保中英文对齐美观 + +## 7. 主流程编排 + +- [x] 7.1 实现 main() 函数,编排完整流程 + - 调用 parse_arguments() 解析参数 + - 调用 load_data_from_db() 加载数据 + - 调用 load_strategy() 加载策略 + - 调用 calculate_indicators() 计算指标 + - 创建 Backtest 对象并执行 + - 调用 print_stats() 输出结果 +- [x] 7.2 添加进度提示信息 + - 数据加载前:输出 "加载股票数据: {code} ({start_date} ~ {end_date})" + - 数据加载后:输出 "数据加载完成,共 {N} 条记录" + - 策略加载前:输出 "加载策略: {strategy_file}" + - 指标计算后:输出 "指标计算完成" + - 回测开始:输出 "开始回测..." + - 回测完成:输出 "回测完成!" +- [x] 7.3 实现回测执行 + - 使用 Backtest(data, strategy_class, cash=args.cash, commission=args.commission, finalize_trades=True) + - 调用 bt.run() 执行回测 + - 保存返回的 stats 对象 + +## 8. HTML 图表生成 + +- [x] 8.1 实现可选的图表生成逻辑 + - 检查 args.output 参数是否指定 + - 仅当指定时才调用 bt.plot() +- [x] 8.2 生成 HTML 图表文件 + - 使用 bt.plot(filename=args.output, show=False) 生成文件 + - show=False 确保在无头环境中也能生成 + - 输出提示:"图表已保存到: {filepath}" +- [x] 8.3 添加异常处理 + - 捕获图表生成异常 + - 输出警告:"图表生成失败,但回测已完成: {error}" + - 不影响统计信息的正常输出 + - 确保主流程正常退出(状态码 0) + +## 9. 全局错误处理 + +- [x] 9.1 在 main() 函数外层添加 try-except + - 捕获所有未预期的异常 + - 输出错误信息和堆栈跟踪(traceback.print_exc()) + - 使用非零状态码退出 +- [x] 9.2 实现特定错误的状态码映射 + - 数据库错误:状态码 2 + - 文件操作错误:状态码 3 + - 参数错误:状态码 4 + - 其他错误:状态码 1 +- [x] 9.3 添加 `if __name__ == '__main__':` 入口 + - 调用 main() 函数 + - 确保脚本可直接执行和作为模块导入 + +## 10. 文档和示例(可选) + +- [ ] 10.1 创建 README.md 文档(可选) +- [ ] 10.2 添加内联文档到 backtest.py +- [ ] 10.3 添加使用示例到 README + +## 11. 测试和验证 + +- [ ] 11.1 测试基础回测流程 +- [ ] 11.2 测试 HTML 图表生成 +- [ ] 11.3 测试错误处理 +- [ ] 11.4 测试不同策略 +- [ ] 11.5 验证输出格式 + +## 12. 代码质量检查 + +- [ ] 12.1 运行代码检查工具(可选) +- [ ] 12.2 验证依赖版本兼容性 +- [ ] 12.3 最终代码审查 + +--- + +## 3. 策略模板实现 + +- [x] 3.1 创建 strategy.py 文件,包含策略模板和示例 +- [x] 3.2 实现 calculate_indicators(data) 函数 + - 计算 SMA10, SMA30, SMA60, SMA120 指标 + - 使用 data['Close'].rolling(window=N).mean() 方法 + - 将结果添加到 DataFrame(data['sma10'] 等) + - 返回添加了指标列的 DataFrame +- [x] 3.3 实现 get_strategy() 函数 + - 返回 SmaCross 类 + - 添加函数文档字符串说明用途 +- [x] 3.4 实现 SmaCross 策略类 + - 继承 backtesting.Strategy + - 定义类属性:short_period = 10, long_period = 30 + - 实现 init() 方法:使用 self.I() 注册 sma10 和 sma30 指标 + - 实现 next() 方法:使用 crossover() 检测金叉和死叉,执行买卖操作 +- [x] 3.5 添加详细的代码注释和文档字符串 + - 文件开头描述策略逻辑 + - 每个函数添加参数和返回值说明 + - 策略类参数添加注释(如 short_period 的含义) + +--- + +## 4. 策略动态加载功能 + +- [x] 4.1 在 backtest.py 中实现 load_strategy(strategy_file) 函数 + - 使用 importlib.util.spec_from_file_location() 加载模块 + - 使用 importlib.util.module_from_spec() 创建模块对象 + - 使用 spec.loader.exec_module() 执行模块 +- [x] 4.2 实现接口验证逻辑 + - 检查模块是否有 calculate_indicators 属性(hasattr 检查) + - 检查模块是否有 get_strategy 属性 + - 验证 get_strategy() 返回的是类对象(isinstance 检查) + - 验证策略类继承自 backtesting.Strategy(issubclass 检查) +- [x] 4.3 实现异常处理 + - 捕获 FileNotFoundError(策略文件不存在) + - 捕获 ImportError(模块导入失败) + - 捕获 AttributeError(接口不完整) + - 输出清晰的错误信息:"策略文件 {file} 加载失败: {error}" +- [x] 4.4 返回策略组件 + - 返回元组:(calculate_indicators 函数, strategy_class) + +--- + +## 5. 命令行参数解析 + +- [x] 5.1 实现 parse_arguments() 函数 + - 使用 argparse.ArgumentParser 创建解析器 + - 添加 --code 参数(必需,help: 股票代码) + - 添加 --start-date 参数(必需,help: 回测开始日期) + - 添加 --end-date 参数(必需,help: 回测结束日期) + - 添加 --strategy-file 参数(必需,help: 策略文件路径) + - 添加 --cash 参数(可选,default=100000,help: 初始资金) + - 添加 --commission 参数(可选,default=0.002,help: 手续费率) + - 添加 --output 参数(可选,help: HTML 输出文件路径) + - 添加 --warmup-days 参数(可选,default=365,help: 预热天数,默认一年) +- [x] 5.2 实现参数验证 + - 检查日期格式(YYYY-MM-DD),使用 datetime.strptime() 验证 + - 检查策略文件是否存在(os.path.isfile()) + - 验证数值参数为正数(cash, commission) +- [x] 5.3 添加友好的错误提示 + - 参数错误时显示帮助信息 + - 日期格式错误时提示正确格式 + +--- + +## 6. 结果输出功能 + +- [x] 6.1 实现 print_stats(stats) 函数 + - 创建 INDICATOR_MAPPING 字典(英文键 → 中文标签) + - 遍历 stats 对象的键值对 + - 使用中文标签格式化输出 +- [x] 6.2 实现格式化逻辑 + - 实现 format_value(value, cn_name, key) 辅助函数 + - 百分比和比率类值保留 2 位小数 + - 金额类值保留 2 位小数 + - 次数类值取整 + - 其他值保留 4 位小数 +- [x] 6.3 添加输出格式化 + - 输出标题:"回测结果"(使用 "=" * 60 分隔) + - 每个指标独占一行 + - 确保中英文对齐美观 + +--- + +## 7. 主流程编排 + +- [x] 7.1 实现 main() 函数,编排完整流程 + - 调用 parse_arguments() 解析参数 + - 调用 load_data_from_db() 加载数据 + - 调用 load_strategy() 加载策略 + - 调用 calculate_indicators() 计算指标 + - 创建 Backtest 对象并执行 + - 调用 print_stats() 输出结果 +- [x] 7.2 添加进度提示信息 + - 数据加载前:输出 "加载股票数据: {code} ({start_date} ~ {end_date})" + - 数据加载后:输出 "数据加载完成,共 {N} 条记录" + - 策略加载前:输出 "加载策略: {strategy_file}" + - 指标计算后:输出 "指标计算完成" + - 回测开始:输出 "开始回测..." + - 回测完成:输出 "回测完成!" +- [x] 7.3 实现回测执行 + - 使用 Backtest(data, strategy_class, cash=args.cash, commission=args.commission, finalize_trades=True) + - 调用 bt.run() 执行回测 + - 保存返回的 stats 对象 +- [x] 8.1 实现可选的图表生成逻辑 + - 检查 args.output 参数是否指定 + - 仅当指定时才调用 bt.plot() +- [x] 8.2 生成 HTML 图表文件 + - 使用 bt.plot(filename=args.output, show=False) 生成文件 + - show=False 确保在无头环境中也能生成 + - 输出提示:"图表已保存到: {filepath}" +- [x] 8.3 添加异常处理 + - 捕获图表生成异常 + - 输出警告:"图表生成失败,但回测已完成: {error}" + - 不影响统计信息的正常输出 + - 确保主流程正常退出(状态码 0) + +--- + +## 9. 全局错误处理 + +- [ ] 9.1 在 main() 函数外层添加 try-except + - 捕获所有未预期的异常 + - 输出错误信息和堆栈跟踪(traceback.print_exc()) + - 使用非零状态码退出 +- [ ] 9.2 实现特定错误的状态码映射 + - 数据库错误:状态码 2 + - 文件操作错误:状态码 3 + - 参数错误:状态码 4 + - 其他错误:状态码 1 +- [ ] 9.3 添加 `if __name__ == '__main__':` 入口 + - 调用 main() 函数 + - 确保脚本可直接执行和作为模块导入 + +--- + +## 10. 文档和示例 + +- [ ] 10.1 创建 README.md 文档(可选) + - 说明项目用途和功能 + - 提供安装步骤(pip install -r requirements.txt) + - 提供使用示例(基础用法、自定义参数、不同策略) + - 说明策略文件接口规范 + - 说明环境变量配置(DB_USER, DB_PASSWORD) +- [ ] 10.2 添加内联文档到 backtest.py + - 文件开头添加模块文档字符串 + - 说明命令行参数和用法 + - 提供使用示例 +- [ ] 10.3 添加使用示例到 README + ```bash + # 基础用法 + python backtest.py --code 000001.SZ --start-date 2024-01-01 --end-date 2025-12-31 --strategy-file strategy.py + + # 自定义参数 + python backtest.py --code 000001.SZ --start-date 2024-01-01 --end-date 2025-12-31 --strategy-file strategy.py --cash 500000 --commission 0.001 --output result.html + ``` + +--- + +## 11. 测试和验证 + +- [ ] 11.1 测试基础回测流程 + - 执行 `python backtest.py --code 000001.SZ --start-date 2024-01-01 --end-date 2025-12-31 --strategy-file strategy.py` + - 验证数据加载成功 + - 验证策略加载成功 + - 验证回测执行成功 + - 验证统计信息输出正确 +- [ ] 11.2 测试 HTML 图表生成 + - 执行带 `--output` 参数的命令 + - 验证 HTML 文件成功生成 + - 验证图表内容正确(价格曲线、资金曲线等) +- [ ] 11.3 测试错误处理 + - 测试无效股票代码(应提示未找到数据) + - 测试无效日期格式(应提示格式错误) + - 测试策略文件不存在(应提示文件不存在) + - 测试数据库连接失败(应提示连接错误) + - 测试策略接口不完整(应提示缺少函数) +- [ ] 11.4 测试不同策略 + - 创建 strategies/macd_strategy.py + - 使用新策略执行回测 + - 验证动态加载功能正常 +- [ ] 11.5 验证输出格式 + - 检查控制台输出使用中文标签 + - 检查数值格式化正确(小数位数) + - 检查 HTML 文件可正常打开 + +--- + +## 12. 代码质量检查 + +- [ ] 12.1 运行代码检查工具(可选) + - 使用 pylint 或 flake8 检查代码风格 + - 修复警告和错误 +- [ ] 12.2 验证依赖版本兼容性 + - 检查 backtesting 库版本兼容性 + - 检查 pandas 和 numpy 版本要求 +- [ ] 12.3 最终代码审查 + - 对照设计文档检查实现是否完整 + - 对照规范文档检查所有场景是否覆盖 + - 确保代码遵循设计决策 diff --git a/openspec/specs/backtest-cli/spec.md b/openspec/specs/backtest-cli/spec.md new file mode 100644 index 0000000..f523ae4 --- /dev/null +++ b/openspec/specs/backtest-cli/spec.md @@ -0,0 +1,195 @@ +# Spec: Backtest CLI + +## ADDED Requirements + +### Requirement: 命令行参数解析 +回测脚本 SHALL 通过命令行参数接收用户输入,参数 SHALL 包含股票代码、时间范围、策略文件、回测参数等。 + +#### Scenario: 基础回测执行 +- **WHEN** 用户执行 `python backtest.py --code 000001.SZ --start-date 2024-01-01 --end-date 2025-12-31 --strategy-file strategy.py` +- **THEN** 系统解析所有必需参数,无错误提示 +- **THEN** 开始执行回测流程 +- **THEN** 回测完成后输出统计信息到控制台 + +#### Scenario: 可选参数未指定 +- **WHEN** 用户未指定 `--cash` 参数 +- **THEN** 系统使用默认值 100000 作为初始资金 +- **WHEN** 用户未指定 `--commission` 参数 +- **THEN** 系统使用默认值 0.002 作为手续费率 +- **WHEN** 用户未指定 `--output` 参数 +- **THEN** 系统不生成 HTML 图表文件 + +#### Scenario: 必需参数缺失 +- **WHEN** 用户未提供 `--code` 参数 +- **THEN** 系统输出错误信息:"错误: 需要以下参数: --code" +- **THEN** 系统退出并返回非零状态码 +- **WHEN** 用户未提供 `--start-date` 或 `--end-date` 参数 +- **THEN** 系统输出对应的错误信息 +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 自定义参数值 +- **WHEN** 用户指定 `--cash 500000 --commission 0.001 --output result.html` +- **THEN** 系统使用指定的 500000 作为初始资金 +- **THEN** 系统使用指定的 0.001 作为手续费率 +- **THEN** 回测完成后生成 HTML 图表到 result.html + +--- + +### Requirement: 数据库数据加载 +回测脚本 SHALL 从 PostgreSQL 数据库加载指定股票的历史价格数据,并自动处理复权。 + +#### Scenario: 成功加载数据 +- **WHEN** 用户指定有效的股票代码和时间范围 +- **THEN** 系统连接数据库并执行查询 +- **THEN** 返回 DataFrame,包含列: [Open, High, Low, Close, Volume, factor] +- **THEN** DataFrame 的索引为 trade_date (DatetimeIndex) +- **THEN** 数据已应用复权计算(price * factor) + +#### Scenario: 数据库连接失败 +- **WHEN** 数据库连接失败(凭证错误、网络问题等) +- **THEN** 系统捕获异常并输出错误信息:"数据库连接失败: {error}" +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 未找到股票数据 +- **WHEN** 指定的股票代码或时间范围内无数据 +- **THEN** 系统抛出 ValueError: "未找到股票 {code} 在指定时间范围内的数据" +- **THEN** 主流程捕获异常并输出友好错误信息 +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 数据验证 +- **WHEN** 数据库返回的 DataFrame 为空 +- **THEN** 系统提示数据为空并退出 +- **WHEN** 数据库返回的 DataFrame 少于 10 条记录 +- **THEN** 系统提示数据不足并退出 + +--- + +### Requirement: 策略动态加载 +回测脚本 SHALL 支持动态加载指定路径的策略文件,并验证策略接口。 + +#### Scenario: 加载有效策略文件 +- **WHEN** 用户指定 `--strategy-file strategy.py` +- **THEN** 系统通过 importlib 加载该模块 +- **THEN** 系统获取模块的 `calculate_indicators` 函数 +- **THEN** 系统调用模块的 `get_strategy()` 函数获取策略类 +- **THEN** 系统返回 (calculate_indicators, strategy_class) 元组 + +#### Scenario: 策略文件不存在 +- **WHEN** 用户指定的策略文件路径不存在 +- **THEN** 系统捕获 FileNotFoundError +- **THEN** 输出错误信息:"策略文件 {file} 不存在" +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 策略接口不完整 +- **WHEN** 策略文件缺少 `calculate_indicators` 函数 +- **THEN** 系统捕获 AttributeError +- **THEN** 输出错误信息:"策略文件 {file} 缺少 calculate_indicators 函数" +- **THEN** 系统退出并返回非零状态码 +- **WHEN** 策略文件缺少 `get_strategy` 函数 +- **THEN** 系统捕获 AttributeError +- **THEN** 输出错误信息:"策略文件 {file} 缺少 get_strategy 函数" +- **THEN** 系统退出并返回非零状态码 + +#### Scenario: 加载子目录中的策略 +- **WHEN** 用户指定 `--strategy-file strategies/macd_strategy.py` +- **THEN** 系统正确加载子目录中的策略模块 +- **THEN** 系统成功获取策略类和指标计算函数 + +--- + +### Requirement: 指标计算 +回测脚本 SHALL 在执行回测前调用策略的指标计算函数,将技术指标添加到数据集中。 + +#### Scenario: 成功计算指标 +- **WHEN** 系统调用 `calculate_indicators(data)` +- **THEN** 函数接收包含 [Open, High, Low, Close, Volume, factor] 的 DataFrame +- **THEN** 函数计算策略所需的指标(如 SMA, MACD, RSI) +- **THEN** 函数返回添加了指标列的 DataFrame +- **THEN** DataFrame 保留原始列,新增指标列 + +#### Scenario: 指标计算产生 NaN 值 +- **WHEN** 滚动窗口计算导致前 N 行的指标值为 NaN +- **THEN** DataFrame 包含 NaN 值(系统不自动删除) +- **THEN** Backtest 框架在回测时会跳过 NaN 值的行 + +#### Scenario: 指标计算函数抛出异常 +- **WHEN** `calculate_indicators(data)` 执行时抛出异常 +- **THEN** 主流程捕获异常 +- **THEN** 输出错误信息:"指标计算失败: {error}" +- **THEN** 系统退出并返回非零状态码 + +--- + +### Requirement: 回测执行 +回测脚本 SHALL 使用 backtesting 库执行回测,传入数据、策略和参数。 + +#### Scenario: 成功执行回测 +- **WHEN** 系统调用 `Backtest(data, strategy_class, cash=..., commission=...).run()` +- **THEN** Backtest 初始化时调用策略类的 `init()` 方法 +- **THEN** Backtest 逐个时间步调用策略类的 `next()` 方法 +- **THEN** 系统返回包含回测统计信息的 stats 对象 + +#### Scenario: 回测参数传递 +- **WHEN** 用户指定 `--cash 500000 --commission 0.001` +- **THEN** Backtest 实例化时使用 cash=500000 +- **THEN** Backtest 实例化时使用 commission=0.001 +- **THEN** Backtest 实例化时使用 finalize_trades=True + +#### Scenario: 回测运行时错误 +- **WHEN** 策略的 `next()` 方法执行时抛出异常 +- **THEN** backtesting 库捕获异常 +- **THEN** 系统输出错误信息和堆栈跟踪 +- **THEN** 系统退出并返回非零状态码 + +--- + +### Requirement: 结果输出 +回测脚本 SHALL 将回测统计信息格式化输出到控制台,并可选生成 HTML 图表文件。 + +#### Scenario: 控制台输出 +- **WHEN** 回测成功完成 +- **THEN** 系统调用 `print_stats(stats)` 函数 +- **THEN** 系统输出回测统计信息,使用中文标签 +- **THEN** 输出内容包括:最终收益、总收益率、年化收益率、最大回撤、胜率等 +- **THEN** 数值格式化(保留 2 位小数) + +#### Scenario: 生成 HTML 图表 +- **WHEN** 用户指定 `--output result.html` +- **THEN** 系统调用 `bt.plot(filename='result.html', show=False)` +- **THEN** 系统生成 HTML 文件到 result.html +- **THEN** 系统输出提示:"图表已保存到: result.html" +- **THEN** 图表包含价格曲线、资金曲线、买卖信号等 + +#### Scenario: 不生成 HTML 图表 +- **WHEN** 用户未指定 `--output` 参数 +- **THEN** 系统不调用 bt.plot() 方法 +- **THEN** 系统不生成任何图表文件 +- **THEN** 系统仅输出控制台统计信息 + +#### Scenario: 图表生成失败 +- **WHEN** bt.plot() 方法执行时抛出异常 +- **THEN** 系统捕获异常 +- **THEN** 系统输出警告:"图表生成失败,但回测已完成: {error}" +- **THEN** 系统不影响控制台统计信息的输出 +- **THEN** 系统正常退出(返回状态码 0) + +--- + +### Requirement: 错误处理 +回测脚本 SHALL 对所有可能的错误进行捕获和处理,提供友好的错误提示。 + +#### Scenario: 数据库错误 +- **WHEN** 数据库操作抛出 sqlalchemy.exc.SQLAlchemyError +- **THEN** 系统输出错误信息:"数据库错误: {error}" +- **THEN** 系统退出并返回状态码 2 + +#### Scenario: 文件操作错误 +- **WHEN** 图表文件保存失败(权限、磁盘空间等) +- **THEN** 系统输出错误信息:"文件操作错误: {error}" +- **THEN** 系统退出并返回状态码 3 + +#### Scenario: 未预期的错误 +- **WHEN** 发生其他未捕获的异常 +- **THEN** 系统输出错误信息:"未知错误: {error}" +- **THEN** 系统输出完整的堆栈跟踪 +- **THEN** 系统退出并返回状态码 1 diff --git a/openspec/specs/data-fetching/spec.md b/openspec/specs/data-fetching/spec.md new file mode 100644 index 0000000..bcc8d8f --- /dev/null +++ b/openspec/specs/data-fetching/spec.md @@ -0,0 +1,280 @@ +# Spec: Data Fetching + +## ADDED Requirements + +### Requirement: 数据库连接配置 +系统 SHALL 通过硬编码常量管理数据库连接参数(开发环境)。 + +#### Scenario: 使用硬编码常量 +- **WHEN** 系统在 backtest.py 中定义数据库配置 +- **THEN** 系统定义 DB_HOST, DB_NAME, DB_USER, DB_PASSWORD 常量 +- **THEN** DB_HOST 值 SHALL 为数据库主机地址(如 '81.71.3.24') +- **THEN** DB_NAME 值 SHALL 为数据库名称(如 'leopard_dev') +- **THEN** DB_USER 值 SHALL 为数据库用户名 +- **THEN** DB_PASSWORD 值 SHALL 为数据库密码 + +#### Scenario: 构建连接字符串 +- **WHEN** 系统创建 SQLAlchemy 连接 +- **THEN** 系统使用硬编码的常量构建连接字符串 +- **THEN** 连接字符串格式 SHALL 为 `postgresql://{user}:{password}@{host}/{database}` +- **THEN** 不从环境变量读取任何凭证 + +#### Scenario: 修改数据库凭证 +- **WHEN** 开发人员需要更换数据库或凭证 +- **THEN** 开发人员直接修改 backtest.py 中的常量值 +- **THEN** 修改后脚本使用新凭证连接数据库 + +--- + +### Requirement: 数据库连接建立 +系统 SHALL 使用 SQLAlchemy 创建 PostgreSQL 数据库连接。 + +#### Scenario: 成功建立连接 +- **WHEN** 凭证正确且数据库可访问 +- **THEN** 系统使用 `sqlalchemy.create_engine(conn_str)` 创建引擎 +- **THEN** 连接字符串格式 SHALL 为 `postgresql://{user}:{password}@{host}/{database}` +- **THEN** 系统成功创建引擎对象 +- **THEN** 系统可用于执行查询 + +#### Scenario: 连接字符串构建 +- **WHEN** 系统构建 PostgreSQL 连接字符串 +- **THEN** 连接字符串 SHALL 正确编码特殊字符(密码中的 @, : 等) +- **THEN** 连接字符串 SHALL 使用标准 URI 格式 +- **THEN** 连接字符串 SHALL 不包含额外选项(仅基础连接参数) + +#### Scenario: 数据库连接失败 +- **WHEN** 凭证错误或数据库不可达 +- **THEN** SQLAlchemy 抛出 `sqlalchemy.exc.OperationalError` +- **THEN** 主流程捕获异常 +- **THEN** 系统输出错误信息:"数据库连接失败: {error}" +- **THEN** 系统退出并返回状态码 2 + +#### Scenario: 连接池管理 +- **WHEN** 系统创建引擎对象 +- **THEN** SQLAlchemy SHALL 自动管理连接池 +- **THEN** 查询后连接 SHALL 自动返回池中 +- **THEN** 系统 SHALL 在查询完成后调用 `engine.dispose()` 清理 + +--- + +### Requirement: SQL 查询构建 +系统 SHALL 构建参数化的 SQL 查询以获取股票历史数据。 + +#### Scenario: 基础查询结构 +- **WHEN** 系统构建查询 +- **THEN** 查询 SHALL 选择 trade_date, Open, High, Low, Close, Volume, factor +- **THEN** 查询 SHALL 连接 leopard_daily 和 leopard_stock 表 +- **THEN** 查询 SHALL 按 stock.code 过滤 +- **THEN** 查询 SHALL 按 trade_date 范围过滤 +- **THEN** 查询 SHALL 按 trade_date 升序排序 + +#### Scenario: 复权价格计算 +- **WHEN** 系统计算复权价格 +- **THEN** Open SHALL 计算为 `open * factor` +- **THEN** Close SHALL 计算为 `close * factor` +- **THEN** High SHALL 计算为 `high * factor` +- **THEN** Low SHALL 计算为 `low * factor` +- **THEN** Volume SHALL 直接使用原始值(不复权) +- **THEN** factor SHALL 使用 `COALESCE(factor, 1.0)` 处理 NULL 值 + +#### Scenario: 参数化股票代码 +- **WHEN** 用户指定股票代码(如 '000001.SZ') +- **THEN** 查询 WHERE 子句 SHALL 使用 `stock.code = '{code}'` +- **THEN** 代码 SHALL 精确匹配(不使用 LIKE) +- **THEN** 查询 SHALL 返回匹配股票的所有日线数据 + +#### Scenario: 参数化日期范围 +- **WHEN** 用户指定开始日期 '2024-01-01' 和结束日期 '2025-12-31' +- **THEN** 查询 WHERE 子句 SHALL 使用 `BETWEEN '{start_date} 00:00:00' AND '{end_date} 23:59:59'` +- **THEN** 00:00:00 和 23:59:59 SHALL 覆盖全天 +- **THEN** 日期格式 SHALL 为 YYYY-MM-DD HH:MM:SS + +#### Scenario: 完整 SQL 查询 +- **WHEN** 系统执行数据加载 +- **THEN** 查询 SHALL 为: + ```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 + ``` + +--- + +### Requirement: 数据查询执行 +系统 SHALL 使用 pandas 的 `read_sql` 函数执行 SQL 查询并返回 DataFrame。 + +#### Scenario: 成功执行查询 +- **WHEN** SQL 查询有效且数据存在 +- **THEN** 系统调用 `pd.read_sql(query, engine)` +- **THEN** 系统返回 DataFrame 对象 +- **THEN** DataFrame SHALL 包含查询结果的所有列 +- **THEN** DataFrame 行数 SHALL 匹配数据库返回的记录数 + +#### Scenario: 数据类型处理 +- **WHEN** pandas 读取 SQL 结果 +- **THEN** trade_date SHALL 自动转换为 datetime 类型 +- **THEN** Open, High, Low, Close, Volume SHALL 为 float 类型 +- **THEN** factor SHALL 为 float 类型 +- **THEN** 系统不需要手动类型转换(除日期索引设置) + +#### Scenario: 查询返回空结果 +- **WHEN** 指定股票代码或日期范围无数据 +- **THEN** `read_sql` 返回空 DataFrame(0 行) +- **THEN** 系统检查 `len(df) == 0` +- **THEN** 系统抛出 ValueError: "未找到股票 {code} 在指定时间范围内的数据" + +#### Scenario: SQL 语法错误 +- **WHEN** SQL 查询包含语法错误 +- **THEN** SQLAlchemy 抛出 `sqlalchemy.exc.ProgrammingError` +- **THEN** 主流程捕获异常 +- **THEN** 系统输出错误信息:"SQL 查询错误: {error}" +- **THEN** 系统退出并返回状态码 2 + +--- + +### Requirement: 数据格式转换 +系统 SHALL 将查询结果转换为 backtesting 库要求的格式。 + +#### Scenario: 设置日期索引 +- **WHEN** DataFrame 加载完成 +- **THEN** 系统调用 `df.set_index('trade_date', inplace=True)` +- **THEN** DataFrame 的索引 SHALL 为 DatetimeIndex +- **THEN** 索引 SHALL 不再是数值索引 +- **THEN** backtesting 库 SHALL 能正确处理日期范围 + +#### Scenario: 列名格式化 +- **WHEN** DataFrame 加载完成 +- **THEN** 列名 SHALL 为 ['Open', 'High', 'Low', 'Close', 'Volume', 'factor'] +- **THEN** 列名 SHALL 遵循 backtesting 库要求(首字母大写) +- **THEN** 列名 SHALL 与 SQL 查询中的别名一致 + +#### Scenario: 数据验证 +- **WHEN** 系统准备返回 DataFrame +- **THEN** 系统验证 DataFrame 包含必需列 +- **THEN** 系统验证 'Open', 'High', 'Low', 'Close', 'Volume' 列存在 +- **THEN** 系统验证索引为 DatetimeIndex +- **WHEN** 验证失败 +- **THEN** 系统抛出 ValueError: "数据格式不符合要求" + +--- + +### Requirement: 数据清理 +系统 SHALL 清理数据以确保回测质量。 + +#### Scenario: 删除 NULL 值行 +- **WHEN** DataFrame 包含 NULL 或 NaN 值 +- **THEN** 系统调用 `df.dropna()` 删除 +- **THEN** 任何包含 NaN 的行 SHALL 被删除 +- **THEN** 返回的 DataFrame SHALL 不包含 NULL 值 + +#### Scenario: 数据完整性检查 +- **WHEN** DataFrame 加载完成 +- **THEN** 系统检查 trade_date 连续性 +- **THEN** 系统检查无重复日期 +- **WHEN** 发现异常 +- **THEN** 系统输出警告:"数据存在异常: {detail}" + +#### Scenario: 最小数据量验证 +- **WHEN** DataFrame 行数少于 10 +- **THEN** 系统输出错误:"数据不足,至少需要 10 天数据" +- **THEN** 系统抛出 ValueError +- **THEN** 主流程捕获并退出 + +--- + +### Requirement: 资源管理 +系统 SHALL 正确管理数据库连接和内存资源。 + +#### Scenario: 引擎创建和清理 +- **WHEN** 系统开始数据加载 +- **THEN** 系统创建 SQLAlchemy 引擎对象 +- **THEN** 系统使用引擎执行查询 +- **WHEN** 查询完成 +- **THEN** 系统调用 `engine.dispose()` 关闭连接池 +- **THEN** 系统释放所有数据库连接 + +#### Scenario: 异常情况下的资源清理 +- **WHEN** 查询过程中抛出异常 +- **THEN** 系统在 finally 块中调用 `engine.dispose()` +- **THEN** 所有连接 SHALL 被正确关闭 +- **THEN** 系统不会泄漏数据库连接 + +--- + +### Requirement: 错误处理和日志 +系统 SHALL 提供清晰的错误信息和调试支持。 + +#### Scenario: 连接错误信息 +- **WHEN** 数据库连接失败 +- **THEN** 错误信息 SHALL 包含数据库主机和端口 +- **THEN** 错误信息 SHALL 区分网络错误和认证错误 +- **THEN** 系统提示用户检查凭证和网络连接 + +#### Scenario: 查询错误信息 +- **WHEN** SQL 查询失败 +- **THEN** 错误信息 SHALL 包含失败的 SQL 语句 +- **THEN** 错误信息 SHALL 包含数据库返回的错误详情 +- **THEN** 系统提示用户检查表结构和数据 + +#### Scenario: 数据格式错误信息 +- **WHEN** 返回的 DataFrame 不符合要求 +- **THEN** 错误信息 SHALL 列出缺失的列 +- **THEN** 错误信息 SHALL 提示期望的格式 +- **THEN** 系统建议用户检查数据库表结构 + +--- + +### Requirement: 函数接口 +`load_data_from_db` 函数 SHALL 提供清晰的调用接口。 + +#### Scenario: 函数签名 +- **WHEN** 主流程调用 `load_data_from_db(code, start_date, end_date)` +- **THEN** 函数接收三个字符串参数 +- **THEN** `code` 为股票代码(如 '000001.SZ') +- **THEN** `start_date` 为开始日期(如 '2024-01-01') +- **THEN** `end_date` 为结束日期(如 '2025-12-31') + +#### Scenario: 返回值 +- **WHEN** 数据加载成功 +- **THEN** 函数返回 pandas.DataFrame +- **THEN** DataFrame 索引为 DatetimeIndex(trade_date) +- **THEN** DataFrame 包含 ['Open', 'High', 'Low', 'Close', 'Volume', 'factor'] 列 + +#### Scenario: 异常抛出 +- **WHEN** 数据加载失败 +- **THEN** 函数 SHALL 抛出异常(不捕获) +- **THEN** 异常类型 SHALL 为 ValueError(业务逻辑错误) +- **THEN** 主流程负责捕获和处理异常 + +--- + +### Requirement: 性能考虑 +系统 SHALL 优化数据加载性能以支持大数据集。 + +#### Scenario: 使用 pandas 向量化操作 +- **WHEN** 执行复权计算 +- **THEN** 计算 SHALL 使用 pandas 向量化操作 +- **THEN** 不使用循环逐行计算 +- **THEN** 10 年数据(约 2500 行) SHALL 在 1 秒内加载 + +#### Scenario: 索引优化 +- **WHEN** 设置 DataFrame 索引 +- **THEN** `set_index()` 操作 SHALL 高效(使用底层数组拷贝) +- **THEN** 日期索引 SHALL 支持快速范围查询 + +#### Scenario: 内存管理 +- **WHEN** 加载大数据集 +- **THEN** 系统 SHALL 及时调用 `engine.dispose()` 释放连接 +- **THEN** DataFrame SHALL 使用 pandas 内部优化存储 +- **THEN** 内存占用 SHALL 合理(10 年数据约几 MB) diff --git a/openspec/specs/strategy-loading/spec.md b/openspec/specs/strategy-loading/spec.md new file mode 100644 index 0000000..db8a74e --- /dev/null +++ b/openspec/specs/strategy-loading/spec.md @@ -0,0 +1,225 @@ +# Spec: Strategy Loading + +## ADDED Requirements + +### Requirement: 策略文件接口 +策略文件 SHALL 提供两个必需的接口:指标计算函数和策略类获取函数。 + +#### Scenario: 标准策略文件结构 +- **WHEN** 用户创建策略文件 +- **THEN** 文件 SHALL 包含 `calculate_indicators(data)` 函数 +- **THEN** 文件 SHALL 包含 `get_strategy()` 函数 +- **THEN** 文件 SHALL 包含一个继承 `backtesting.Strategy` 的类 +- **THEN** 所有三个组件 SHALL 在同一文件中 + +#### Scenario: calculate_indicators 函数签名 +- **WHEN** 主流程调用 `calculate_indicators(data)` +- **THEN** 函数接收一个参数:data (pandas.DataFrame) +- **THEN** 函数返回一个 pandas.DataFrame +- **THEN** 返回的 DataFrame SHALL 包含原始列和新增的指标列 +- **THEN** 函数 SHALL 修改输入的 DataFrame(不创建副本) + +#### Scenario: get_strategy 函数签名 +- **WHEN** 主流程调用 `get_strategy()` +- **THEN** 函数不接收参数 +- **THEN** 函数返回一个类对象 +- **THEN** 返回的类 SHALL 继承自 `backtesting.Strategy` + +--- + +### Requirement: 指标计算函数 +`calculate_indicators` 函数 SHALL 计算策略所需的技术指标,并将结果添加到 DataFrame 中。 + +#### Scenario: SMA 指标计算 +- **WHEN** 策略需要简单移动平均线指标 +- **THEN** 函数使用 `data['Close'].rolling(window=N).mean()` 计算 +- **THEN** 函数将结果存储为 `data['smaN']` 列 +- **THEN** N 为具体的周期(如 10, 30, 60, 120) + +#### Scenario: MACD 指标计算 +- **WHEN** 策略需要 MACD 指标 +- **THEN** 函数使用 `data['Close'].ewm(span=12).mean()` 计算 EMA12 +- **THEN** 函数使用 `data['Close'].ewm(span=26).mean()` 计算 EMA26 +- **THEN** 函数计算 MACD = EMA12 - EMA26 +- **THEN** 函数计算 Signal = MACD.ewm(span=9).mean() +- **THEN** 函数将结果存储为 `data['macd']`, `data['macd_signal']`, `data['macd_hist']` 列 + +#### Scenario: RSI 指标计算 +- **WHEN** 策略需要 RSI 指标 +- **THEN** 函数计算价格变化 delta = data['Close'].diff() +- **THEN** 函数计算 gain = delta.where(delta > 0, 0) +- **THEN** 函数计算 loss = -delta.where(delta < 0, 0) +- **THEN** 函数计算平均收益和平均损失 +- **THEN** 函数计算 RS = average_gain / average_loss +- **THEN** 函数计算 RSI = 100 - (100 / (1 + RS)) +- **THEN** 函数将结果存储为 `data['rsi']` 列 + +#### Scenario: 多指标计算 +- **WHEN** 策略需要多个技术指标 +- **THEN** 函数按顺序计算每个指标 +- **THEN** 函数将所有指标列添加到 DataFrame +- **THEN** DataFrame 最终包含原始列 + 所有指标列 +- **THEN** 计算顺序 SHALL 遵循指标间的依赖关系(如 MACD 依赖 EMA) + +#### Scenario: 指标列命名约定 +- **WHEN** 函数添加指标列到 DataFrame +- **THEN** 列名 SHALL 使用小写和下划线(如 `sma10`, `macd_signal`) +- **THEN** 列名 SHALL 与策略类的 `init()` 方法中引用的名称一致 +- **THEN** 列名 SHALL 避免与原始列冲突 + +--- + +### Requirement: 策略类定义 +策略类 SHALL 继承 `backtesting.Strategy`,并实现 `init()` 和 `next()` 方法。 + +#### Scenario: 策略类继承 +- **WHEN** 用户定义策略类 +- **THEN** 类 SHALL 显式继承 `backtesting.Strategy` +- **THEN** 类 SHALL 定义类属性作为可配置参数 +- **THEN** 类名 SHALL 使用大驼峰命名(如 `SmaCross`, `MacdStrategy`) + +#### Scenario: init 方法实现 +- **WHEN** Backtest 框架初始化策略时 +- **THEN** 系统调用策略类的 `init()` 方法 +- **THEN** `init()` 方法 SHALL 使用 `self.I()` 注册指标 +- **THEN** `self.I(lambda x: x, self.data.column_name)` SHALL 引用 DataFrame 中的指标列 +- **THEN** `init()` 方法 SHALL 不执行数据计算 + +#### Scenario: next 方法实现 - 金叉买入 +- **WHEN** 短期均线上穿长期均线(金叉) +- **THEN** `next()` 方法 SHALL 调用 `self.position.close()` 平仓 +- **THEN** `next()` 方法 SHALL 调用 `self.buy()` 开多仓 +- **THEN** `next()` 方法 SHALL 使用 `crossover()` 函数检测交叉 + +#### Scenario: next 方法实现 - 死叉卖出 +- **WHEN** 短期均线下穿长期均线(死叉) +- **THEN** `next()` 方法 SHALL 调用 `self.position.close()` 平仓 +- **THEN** `next()` 方法 SHALL 调用 `self.sell()` 开空仓 +- **THEN** `next()` 方法 SHALL 使用 `crossover()` 函数检测交叉 + +#### Scenario: next 方法实现 - 避免重复开仓 +- **WHEN** 策略已持有多仓,且买入信号触发 +- **THEN** `next()` 方法 SHALL 先调用 `self.position.close()` +- **THEN** `next()` 方法 SHALL 再调用 `self.buy()` +- **THEN** 系统 SHALL 自动处理仓位管理(不重复开仓) + +#### Scenario: 可配置策略参数 +- **WHEN** 策略类定义类属性 +- **THEN** 类属性 SHALL 作为策略参数(如 `short_period = 10`) +- **THEN** Backtest 框架 SHALL 自动访问这些属性 +- **THEN** 参数 SHALL 可通过 Backtest 构造函数覆盖 + +--- + +### Requirement: 策略类指标引用 +策略类的 `init()` 方法 SHALL 正确引用 DataFrame 中计算好的指标列。 + +#### Scenario: 引用 SMA 指标 +- **WHEN** DataFrame 包含 `sma10` 和 `sma30` 列 +- **THEN** `init()` 方法注册 `self.sma_short = self.I(lambda x: x, self.data.sma10)` +- **THEN** `init()` 方法注册 `self.sma_long = self.I(lambda x: x, self.data.sma30)` +- **THEN** `next()` 方法 SHALL 通过 `self.data.sma10` 和 `self.data.sma30` 访问指标 + +#### Scenario: 引用 MACD 指标 +- **WHEN** DataFrame 包含 `macd` 和 `macd_signal` 列 +- **THEN** `init()` 方法注册 `self.macd = self.I(lambda x: x, self.data.macd)` +- **THEN** `init()` 方法注册 `self.signal = self.I(lambda x: x, self.data.macd_signal)` +- **THEN** `next()` 方法 SHALL 通过 `self.data.macd` 和 `self.data.macd_signal` 访问指标 + +#### Scenario: 引用 RSI 指标 +- **WHEN** DataFrame 包含 `rsi` 列 +- **THEN** `init()` 方法注册 `self.rsi = self.I(lambda x: x, self.data.rsi)` +- **THEN** `next()` 方法 SHALL 通过 `self.data.rsi` 访问指标 +- **THEN** 策略逻辑 SHALL 使用 RSI 阈值生成信号(如 RSI > 70 超买) + +#### Scenario: 指标列不存在 +- **WHEN** 策略类引用的列名不存在于 DataFrame +- **THEN** Backtest 框架抛出 KeyError +- **THEN** 主流程捕获异常并输出错误信息:"指标列 {column} 不存在" +- **THEN** 系统退出并返回非零状态码 + +--- + +### Requirement: 动态加载机制 +主流程 SHALL 使用 importlib 动态加载策略文件模块。 + +#### Scenario: 加载顶层策略文件 +- **WHEN** 用户指定 `--strategy-file strategy.py` +- **THEN** 系统使用 `spec_from_file_location('strategy', 'strategy.py')` 创建规范 +- **THEN** 系统使用 `module_from_spec(spec)` 创建模块对象 +- **THEN** 系统使用 `spec.loader.exec_module(module)` 执行模块 +- **THEN** 系统成功获取 `module.calculate_indicators` 和 `module.get_strategy` + +#### Scenario: 加载子目录策略文件 +- **WHEN** 用户指定 `--strategy-file strategies/macd_strategy.py` +- **THEN** 系统使用 `spec_from_file_location('strategies.macd_strategy', 'strategies/macd_strategy.py')` +- **THEN** 模块名使用点号分隔(反映目录结构) +- **THEN** 系统成功加载子目录中的策略模块 + +#### Scenario: 模块命名空间隔离 +- **WHEN** 系统动态加载多个策略文件 +- **THEN** 每个策略模块 SHALL 有独立的命名空间 +- **THEN** 模块间 SHALL 不共享全局变量 +- **THEN** 系统通过 `getattr(module, name)` 明确访问函数和类 + +#### Scenario: 策略文件导入错误 +- **WHEN** 策略文件包含语法错误或导入错误 +- **THEN** `exec_module()` 抛出 ImportError 或 SyntaxError +- **THEN** 主流程捕获异常 +- **THEN** 系统输出错误信息:"策略文件 {file} 加载失败: {error}" +- **THEN** 系统退出并返回非零状态码 + +--- + +### Requirement: 策略接口验证 +主流程 SHALL 验证策略文件是否符合接口要求。 + +#### Scenario: 验证 calculate_indicators 存在 +- **WHEN** 系统加载策略模块 +- **THEN** 系统使用 `hasattr(module, 'calculate_indicators')` 检查函数 +- **WHEN** 函数不存在 +- **THEN** 系统抛出 AttributeError +- **THEN** 主流程捕获并输出:"策略文件 {file} 缺少 calculate_indicators 函数" + +#### Scenario: 验证 get_strategy 存在 +- **WHEN** 系统加载策略模块 +- **THEN** 系统使用 `hasattr(module, 'get_strategy')` 检查函数 +- **WHEN** 函数不存在 +- **THEN** 系统抛出 AttributeError +- **THEN** 主流程捕获并输出:"策略文件 {file} 缺少 get_strategy 函数" + +#### Scenario: 验证 get_strategy 返回类 +- **WHEN** 系统调用 `get_strategy()` +- **THEN** 系统使用 `isinstance(returned, type)` 检查返回值 +- **WHEN** 返回值不是类 +- **THEN** 系统抛出 TypeError +- **THEN** 主流程捕获并输出:"get_strategy() 必须返回一个类" + +#### Scenario: 验证策略类继承 +- **WHEN** 系统获取策略类 +- **THEN** 系统使用 `issubclass(strategy_class, backtesting.Strategy)` 检查继承 +- **WHEN** 策略类未继承 `backtesting.Strategy` +- **THEN** 系统抛出 TypeError +- **THEN** 主流程捕获并输出:"策略类必须继承 backtesting.Strategy" + +--- + +### Requirement: 策略文件示例 +系统 SHALL 提供策略模板文件作为开发者参考。 + +#### Scenario: 提供策略模板 +- **WHEN** 用户查看 strategy.py 文件 +- **THEN** 文件 SHALL 包含完整的策略示例(SMA 双均线交叉) +- **THEN** 文件 SHALL 包含清晰的注释说明每个接口的用途 +- **THEN** 文件 SHALL 包含代码示例(指标计算函数、get_strategy、策略类) + +#### Scenario: 策略文件文档 +- **WHEN** 策略文件开头有文档字符串 +- **THEN** 文档 SHALL 描述策略逻辑 +- **THEN** 文档 SHALL 列出需要的指标 +- **THEN** 文档 SHALL 说明参数含义(如 `short_period`, `long_period`) + +#### Scenario: 策略参数说明 +- **WHEN** 策略类定义类属性 +- **THEN** 每个属性 SHALL 有注释说明(如 `short_period = 10 # 短期均线周期`) +- **THEN** 参数 SHALL 使用有意义的名称(不是 param1, param2) diff --git a/pyproject.toml b/pyproject.toml index 37f6a56..559fc43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,4 +13,5 @@ dependencies = [ "pandas-stubs~=2.3.3", "peewee~=3.19.0", "psycopg2-binary~=2.9.11", + "sqlalchemy>=2.0.46", ] diff --git a/strategy.py b/strategy.py new file mode 100644 index 0000000..627e6fa --- /dev/null +++ b/strategy.py @@ -0,0 +1,87 @@ +""" +SMA 双均线交叉策略 + +策略逻辑: +- 当短期均线上穿长期均线时 (金叉),买入 +- 当短期均线下穿长期均线时 (死叉),卖出 + +指标计算: +- SMA10: 10 日简单移动平均线 +- SMA30: 30 日简单移动平均线 +- SMA60: 60 日简单移动平均线 +- SMA120: 120 日简单移动平均线 +""" + +import pandas as pd +from backtesting import Strategy +from backtesting.lib import crossover + + +def calculate_indicators(data): + """ + 计算策略所需的技术指标 + + 参数: + data: DataFrame, 包含 [Open, High, Low, Close, Volume, factor] + + 返回: + DataFrame, 添加了指标列 + """ + data = data.copy() + + # 计算不同周期的移动平均线 + data["sma10"] = data["Close"].rolling(window=10).mean() + data["sma30"] = data["Close"].rolling(window=30).mean() + data["sma60"] = data["Close"].rolling(window=60).mean() + data["sma120"] = data["Close"].rolling(window=120).mean() + + return data + + +def get_strategy(): + """ + 返回策略类 + + 返回: + SmaCross 类 + """ + return SmaCross + + +class SmaCross(Strategy): + """ + SMA 双均线交叉策略 + + 参数: + short_period: 短期均线周期 (默认: 10) + long_period: 长期均线周期 (默认: 30) + """ + + # 可配置参数 + short_period = 10 + long_period = 30 + + def init(self): + """ + 初始化策略 + 注册指标到 backtesting 框架 + """ + self.sma_short = self.I(lambda x: x, self.data.sma10) + self.sma_long = self.I(lambda x: x, self.data.sma30) + + def next(self): + """ + 每个时间步的决策逻辑 + + 金叉: 短期均线上穿长期均线 → 买入 + 死叉: 短期均线下穿长期均线 → 卖出 + """ + # 金叉:短期均线上穿长期均线 + if crossover(self.data.sma10, self.data.sma30): + self.position.close() # 先平掉现有仓位 + self.buy() # 开多仓 + + # 死叉:短期均线下穿长期均线 + elif crossover(self.data.sma30, self.data.sma10): + self.position.close() # 先平掉现有仓位 + self.sell() # 开空仓 diff --git a/uv.lock b/uv.lock index 49a0887..5e5e34a 100644 --- a/uv.lock +++ b/uv.lock @@ -405,6 +405,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, + { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/c21a3fd5d2c9c8b622e7bede6d6d00e00551a5ee474ea6d831b5f567a8b4/greenlet-3.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:96aff77af063b607f2489473484e39a0bbae730f2ea90c9e5606c9b73c44174a", size = 228125, upload-time = "2026-01-23T15:32:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/8a2db6d11491837af1de64b8aff23707c6e85241be13c60ed399a72e2ef8/greenlet-3.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:b066e8b50e28b503f604fa538adc764a638b38cf8e81e025011d26e8a627fa79", size = 227519, upload-time = "2026-01-23T15:31:47.284Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, + { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, + { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -873,6 +896,7 @@ dependencies = [ { name = "pandas-stubs" }, { name = "peewee" }, { name = "psycopg2-binary" }, + { name = "sqlalchemy" }, ] [package.metadata] @@ -886,6 +910,7 @@ requires-dist = [ { name = "pandas-stubs", specifier = "~=2.3.3" }, { name = "peewee", specifier = "~=3.19.0" }, { name = "psycopg2-binary", specifier = "~=2.9.11" }, + { name = "sqlalchemy", specifier = ">=2.0.46" }, ] [[package]] @@ -1583,6 +1608,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/9a/b4450ccce353e2430621b3bb571899ffe1033d5cd72c9e065110f95b1a63/soupsieve-2.8.2-py3-none-any.whl", hash = "sha256:0f4c2f6b5a5fb97a641cf69c0bd163670a0e45e6d6c01a2107f93a6a6f93c51a", size = 37016, upload-time = "2026-01-18T16:21:29.7Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, + { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" }, + { url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" }, + { url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" }, + { url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" }, + { url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" }, +] + [[package]] name = "stack-data" version = "0.6.3"