diff --git a/README.md b/README.md index 53b3e10..0c5f59b 100644 --- a/README.md +++ b/README.md @@ -1,460 +1,168 @@ # hqbacktest - A股量化策略回测与交易模拟引擎

- - + + +

-`hqbacktest` 是 A 股日频策略回测与交易模拟引擎。它给量化研究者一个**确定性的、可复现的、与实盘严格隔离**的回测沙盒:策略只通过受控的 `Context` / `DataView` 读写数据、提交订单和查询组合,不接触数据源实现或内部账本;引擎负责时钟、撮合、规则与成本、指标和可审计的结果导出。 - -## 当前能力(按模块) - -- **领域模型:** 不可变 `Order`(含 `frozen=True` 与 `tuple` `fill_ids`)、`Fill` / `Position` / `Portfolio` / `AccountSnapshot`、订单状态机(`ACCEPTED → PENDING → FILLED` 或 `REJECTED` / `CANCELLED`)、`Decimal` 精度 + `ROUND_HALF_EVEN`。 -- **数据层:** `MarketDataPortal` Protocol,两个实现——`HqDataCsvPortal` 只读 `~/.hqdata/{source}/` 下的 CSV 快照(`calendar.csv` + `stock_list|stock_daily|stock_factor/{YYYYMMDD}.csv`);`InMemoryDataPortal` 用于测试与示例。`DataView` 强制 `visible_through` 边界,禁止策略读未来数据。`.BJ` 默认过滤,`include_bj=True` 保留。 -- **引擎:** 五阶段日频时钟 `SESSION_START → BEFORE_TRADING_START → OPEN_MATCH → BAR_CLOSE → AFTER_TRADING_END`;按阶段切换数据可见性,全程可追溯事件日志。`BacktestEngine` 编排策略生命周期、撮合、回测收尾。 -- **策略 API:** `BaseStrategy` 暴露 `initialize` / `before_trading_start` / `on_bar` / `after_trading_end` 四个回调;`Context` 提供只读访问(`cash` / `positions` / `universe` / `pending_orders` / `history` / `current_price` / `total_equity`)与下单意图(`order` / `order_value` / `order_target` / `order_target_value` / `order_target_percent` / `cancel_order`)。Universe 锁定、`Order` 不可变、`DataView` 越界即报错,三件事共同把策略隔离在沙盒里。 -- **撮合:** `SimulatedBroker` 在 `OPEN_MATCH` 按当日 `bar.open` 全额成交市价单。同批先 SELL 后 BUY,A 股「卖出资金当日可用」语义;SELL 允许零股(`order_target(0)` 可清零股)。 -- **规则与成本:** `TradingRuleSet` 默认六条 A 股规则(`LongOnly` / `LotSize` / `NonTradingDay` / `InvalidPrice` / `InsufficientCash` / `T1Sellable`)。`CostModel` 默认费率显式配置:0.025% 佣金(5 元保底)+ 0.1% 卖出印花税 + 0 过户费。T+1 日终结算;末交易日 `BACKTEST_ENDED` 自动撤销未成交订单。 -- **公司行为:** `adjustment_policy` 严格只接受 `"none"`;`CorporateActionProvider` 是设计草案(10 个权威字段),v0.1 不接入真实分红 /送配会计。因子诊断 (`analyze_factor_series`) 在持仓期因子跳变时(阈值 0.1%)自动生成 `DATA_WARNING` + `FactorDiagnostic`,结果写入 `summary.json` / `events.jsonl`,账本与净值完全不变。 -- **结果与指标:** `BacktestResult` 含 `equity_curve` / `orders_table` / `fills_table` / `positions_table` / `costs_table` / `PerformanceMetrics` / `events.jsonl` / `data_version` / `factor_diagnostics`。`save(dir)` / `load(dir)` 导出 CSV + JSON 并可重建。指标公式(手算见 `tests/engine/test_metrics.py` 与 `tests/engine/test_metrics_baseline.py`): - - 累计收益 `(final_equity / initial_cash) - 1` - - 日收益 `EquityPoint.daily_return`(引擎写入,首日锚定到 `initial_cash`) - - 年化收益 `(1 + total_return) ** (N / annual_trading_days) - 1`,N < 2 时为 `None` - - 日波动率 `stdev(daily_returns)`(样本,ddof=1),序列 < 2 时为 `None` - - 年化波动率 `daily_volatility * sqrt(annual_trading_days)` - - 夏普比率 `(annualized_return - risk_free_rate) / annualized_volatility`,零波动时为 `None` - - 最大回撤 `max(peak - current) / peak`(从净值曲线取) - - 换手率 `(sum(BUY 成交额) + sum(SELL 成交额)) / 2 / initial_cash`(单边均值) - - 交易次数 `len(fills)` - - 胜率 SELL 且 `成交价 > 当时平均成本` / SELL 总数(按时间顺序单遍重放),无 SELL 时为 `None` -- **CLI:** `hqbacktest run --config FILE --output DIR`(`hqbacktest/cli/` 包,TOML 配置 + 校验 + 策略导入 + 元数据 + 独立输出目录,绝不泄露凭证 / 完整环境 / 本地绝对路径)。`run_metadata.json` 记录包版本、Python 版本、平台、UTC 时间戳、`config_path`(相对 cwd)、`output_directory`(相对 cwd)、`data_root`、`adjustment_policy`、`git_commit`。 -- **示例:** `examples/buy_and_hold.py` 与 `examples/moving_average.py` 用公共 API 跑通端到端流程,并有 7 天确定性 `InMemoryDataPortal` 数据 fixture。 -- **CI:** `.github/workflows/ci.yml` 覆盖 Python 3.10 / 3.11 / 3.12、`black`、`pytest`、`pytest-cov`、示例 smoke 与 CLI smoke;`python -m build` 产出 sdist + wheel。 - -## 已知限制 - -`hqbacktest` 是个**故意做窄**的日频回测沙盒。下面这些明确**不做**——见 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md) §8「非目标」: - -- **精确分红 / 送配 / 配股 / 税费会计**(`adjustment_policy=none`)。跨除权日的回测净值会**系统性低估**(少分红现金)。`analyze_factor_series` 会显式记录跨除权日的因子跳变,但**不**据此伪造任何公司行为账目。长区间结果不可直接用于收益评估,必须先评估 factor 跳变并改用 `factor_total`(v0.1 之后的能力)。 -- **ST / 涨跌停 / 新股 / 北交所细则**:v0.1 没有可靠证券状态数据支撑,`.BJ` 默认过滤,其他规则也不实现。 -- **限价 / 止损单、成交量参与率、部分成交、滑点模型**:全部未实现。 -- **指数基准与归因**:在 `hqdata` 尚未提供指数日线前,不将基准收益率作为引擎的必备功能。 -- **多账户、多币种、融资融券、卖空、期货、期权**:v0.1 全部不支持。 -- **分钟线 / Tick / 盘中回测 / 异步多市场时钟**:v0.1 只做日频。 - -本文描述的用法、命令行和功能表与 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md) 一致;其中的示例已经可以按 §示例 章节运行。功能表区分「已实现」与「路线图 / 计划中」;任何契约变更必须先更新契约文档(见 [`docs/design/mvp-contract.md` §9](docs/design/mvp-contract.md#9-修订记录))。 +`hqbacktest` 是 HonestQuant 量化系统的**策略回测与交易模拟层**,面向 A 股日线策略。它给量化研究者一个**确定性的、可复现的、与实盘严格隔离**的回测沙盒:策略只通过受控的 `Context` / `DataView` 读写数据、提交订单和查询组合,不接触数据源实现或内部账本;引擎负责时钟、撮合、规则与成本、指标和可审计的结果导出。 ## 定位 -`hqbacktest` 是 HonestQuant 量化系统的**策略回测与交易模拟层**,面向 A 股日线策略: +- **对下:** 只读 `hqdata` CLI 已落盘的 CSV 快照(默认 `~/.hqdata/{source}/`),不导入 `hqdata`、不调用任何数据源 SDK、也不在回测运行时访问网络。 +- **对中:** 提供严格的交易日事件时钟、数据可见性控制、订单生命周期、虚拟经纪商、持仓账本和交易规则。 +- **对上:** 让策略只通过 `Context` / `DataView` 读取数据、提交订单和查询组合,不接触数据源实现或修改内部账本。 +- **对外:** 输出可复现的净值、订单、成交、持仓、费用和绩效指标,用于研究和模拟,不连接真实券商。 -- 对下:只读 `hqdata` CLI 已落盘的 CSV 快照,默认根目录为 `~/.hqdata`;不导入 `hqdata`、不直接调用 Tushare、RiceQuant 或 AkShare SDK,也不在回测运行时访问网络。 -- 对中:提供严格的交易日事件时钟、数据可见性控制、订单生命周期、虚拟经纪商、持仓账本和交易规则。 -- 对上:让策略只通过受控的 `Context` 和 `DataView` 读取数据、提交订单和查询组合,不接触数据源实现或修改内部账本。 -- 对外:输出可复现的净值、订单、成交、持仓、费用和绩效指标,用于研究和模拟,不连接真实券商。 +## 已实现的能力 -```text -策略 ──> Context / DataView ──> BacktestEngine ──> SimulatedBroker ──> Portfolio - │ - └──> MarketDataPortal ──> hqdata CSV snapshot -``` +| 功能 | 目标接口 / 产物 | 首版语义 | +| --- | --- | --- | +| 日频事件时钟 | `BacktestEngine` | 五阶段固定顺序 `SESSION_START → BEFORE_TRADING_START → OPEN_MATCH → BAR_CLOSE → AFTER_TRADING_END`;盘前 D-1、收盘 D 的可见性切换;事件日志记录日期与阶段 | +| 交易日与历史股票池 | `MarketDataPortal` | 按回测日获取交易日和股票池,避免以今日股票列表产生幸存者偏差;`.BJ` 默认过滤,`include_bj=True` 保留 | +| 日线数据可见性 | `DataView.history()` | 盘前最多看到前一交易日;当天收盘后才可读取当天日线;首日盘前哨兵 `visible_through="00000000"` 不抛异常 | +| 缺行 / 停牌 / 估值口径 | `get_bars` / `DataView.current_price` / 日终估值 | `get_bars` 允许逐日间隙;停牌持仓按 20 日回看最近收盘估值并写 `DATA_WARNING`;整日快照缺失 → `SnapshotFileMissingError`;`Bar.volume` 单位「手」 | +| 策略生命周期 | `BaseStrategy`、`Context` | `initialize` / `before_trading_start` / `on_bar` / `after_trading_end` 四回调;只读 `Context` 暴露 `cash` / `positions` / `universe` / `pending_orders` / `history` / `current_price` / `total_equity`;下单意图 `order` / `order_value` / `order_target` / `order_target_value` / `order_target_percent` / `cancel_order` | +| 下单与撤单 | `Context.order_*()` | 首版只支持市价委托,仅盘前与收盘回调可下单;订单创建 / 撤销写入事件日志 | +| 虚拟撮合与账本 | `SimulatedBroker`、`Portfolio` | 盘前订单按当日开盘价撮合;收盘订单最早次日开盘成交;同批 SELL 先于 BUY;回测结束时未成交订单 `BACKTEST_ENDED` 撤销 | +| A 股基础规则 | `TradingRuleSet`、`CostModel` | 买入整手(卖出允许零股)、T+1、停牌 / 无价拒绝、现货多头、显式费率(佣金 0.025% + 5 元保底、印花税 0.1% 卖出) | +| 公司行为扩展 | `CorporateActionProvider`、`AdjustmentPolicy` | v0.1 仅 `adjustment_policy="none"`;`CorporateActionProvider` 是设计草案;因子诊断接口存在但默认不启用自动诊断 | +| 端到端示例 | `examples/buy_and_hold.py`、`examples/moving_average.py` | 仅用公共 API + 7 天 `InMemoryDataPortal` 确定性数据;`tests/examples/` 覆盖买-持、均线、T+1、费用、净值与指标 | +| 结果与分析 | `BacktestResult` | 净值曲线、订单 / 成交 / 持仓 / 费用 CSV + `summary.json` + `events.jsonl`;`PerformanceMetrics` 含累计 / 年化 / 波动 / 夏普 / 最大回撤 / 换手 / 胜率 | +| 配置与命令行 | `hqbacktest run` | TOML 配置 + 校验 + 策略导入 + 独立输出目录;`run_metadata.json` 不含凭证 / 完整环境;本地绝对路径脱敏为相对 cwd 路径 | + +> 「已实现」与「不做」的完整边界见 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md)。模块级细节见 [`docs/`](.) 下的专题文档。 + +## 支持的数据源 + +| 数据 | 来源 | 适用场景 | +| --- | --- | --- | +| 真实日线 | `hqdata` CLI 落盘的 CSV 快照(`tushare` / `ricequant`) | 任何需要真实行情的回测 | +| 内存 fixture | `InMemoryDataPortal` | 单元测试、示例、`tests/examples/` 端到端 fixture | -## 计划支持的主要功能 - -| 功能 | 目标接口/产物 | 首版语义 | 当前状态 | -| --- | --- | --- | :---: | -| 交易日与历史股票池 | `MarketDataPortal` | 按回测日获取交易日和股票池,避免以今日股票列表产生幸存者偏差;`.BJ` 股票默认过滤,`include_bj=True` 可保留 | 已实现 | -| 日线数据可见性 | `DataView.history()` | 盘前最多看到前一交易日;当天收盘后才可读取当天日线;首日盘前哨兵 `visible_through="00000000"` 不抛异常 | 已实现 | -| 缺行/停牌/估值口径 | `get_bars`/`DataView.current_price`/日终估值 | `get_bars` 允许逐日间隙;停牌持仓按 20 日回看最近收盘估值并写 `DATA_WARNING`;整日快照缺失抛 `SnapshotFileMissingError`;`Bar.volume` 单位「手」 | 已实现 | -| 日频事件时钟 | `BacktestEngine`、`EventLog` | 五阶段固定顺序;盘前 D-1、收盘 D 的可见性切换;事件日志记录日期、阶段与错误原因;策略异常带日期和阶段 | 已实现 | -| 策略生命周期 | `BaseStrategy`、`Context` | `initialize`/`before_trading_start`/`on_bar`/`after_trading_end` 四个回调;只读 `Context` 暴露 `cash` / `positions` / `universe` / `pending_orders` / `history` / `current_price` / `total_equity`;下单意图(`order`/`order_value`/`order_target`/`order_target_value`/`order_target_percent`/`cancel_order`);市价单且与数据可见性 / 账本严格隔离 | 已实现 | -| 下单与撤单 | `Context.order_*()` | 首版只支持市价委托,策略只能提交意图,不能直接改账户;仅盘前与收盘回调可下单,订单创建/撤销写入事件日志 | 已实现 | -| 虚拟撮合与账本 | `SimulatedBroker`、`Portfolio` | 盘前订单按当日开盘价撮合;收盘订单最早次日开盘成交;回测结束时未成交订单以 `BACKTEST_ENDED` 撤销 | 已实现 | -| A 股基础规则 | `TradingRuleSet`、`CostModel` | 买入整手(卖出允许零股)、T+1、停牌/无价拒绝、现货多头与显式 A 股费率(佣金 0.025% + 5 元保底;卖出印花税 0.1%);所有费率在配置与 README 中显式声明 | 已实现 | -| 公司行为扩展 | `CorporateActionProvider`、`AdjustmentPolicy`、`analyze_factor_series` | v0.1 仅 `adjustment_policy="none"`;`CorporateActionProvider` 为设计草案(10 个权威字段);`factor_total_return` 准入标准(7 项会计语义)已条目化;因子诊断分析器可对缺失/零负/异常跳变/跨源不一致生成可追溯诊断,引擎默认不自动启用;任何其他 `adjustment_policy` 在配置校验时拒绝 | 部分实现 | -| 端到端示例 | `examples/buy_and_hold.py`、`examples/moving_average.py` | 仅用公共 `BaseStrategy` + `Context` API;7 天 `InMemoryDataPortal` 确定性数据;10 项端到端回归测试覆盖买入、下单、次日成交、T+1、费用、净值与指标导出 | 已实现 | -| 结果与分析 | `BacktestResult`、`PerformanceMetrics` | 净值曲线、订单/成交/持仓/费用 CSV + `summary.json` + `events.jsonl`;累计收益 / 年化 / 日与年化波动率 / 夏普 / 最大回撤 / 换手率 / 交易次数 / 胜率 / 边界 notes;持仓无价即运行失败(`DATA_ERROR`);公式在 `engine/metrics.py` 注释中显式 | 已实现 | -| 配置与命令行 | `hqbacktest run`、`cli/config.py`、`cli/runner.py` | TOML 配置 + 校验 + 策略导入 + 独立输出目录(`config.toml` / `run_metadata.json` / `events.jsonl` / 五个 CSV + `summary.json`);`rule_set` 从快照中剥离以保 summary.json 字节稳定;CLI 测试覆盖端到端、配置验证、可复现性与错误信息(见 `tests/cli/`);`run_metadata.json` 不写入 token / 完整环境变量;本地绝对路径在写入时脱敏为相对 cwd 的相对路径 | 已实现 | +`hqdata` 当前 `akshare` 适配器不稳定,按其官方说明**不**作为本项目首选数据源。需要日线请使用 `tushare` 或 `ricequant`;具体数据下载与落盘见 [`hqdata` README](https://github.com/HonestQuantTech/hqdata)。 ## 首个可用版本的范围 -首版以正确、可验证和可复现为优先,而不是一次覆盖所有交易品种和交易细则。 +### 已支持 -### 已规划的支持范围 - -- **市场与频率:** 沪深普通股票的日线回测;标的使用 `600000.SH`、`000001.SZ` 这类统一代码。`.BJ`(北交所)股票默认从 `get_universe` 中过滤,需要时通过 `include_bj=True` 显式启用。 +- **市场与频率:** 沪深普通股票的日线回测;统一代码 `600000.SH` / `000001.SZ`。 - **账户:** 单个人民币现金账户、股票现货多头;不使用杠杆或保证金。 -- **数据:** 每次回测固定使用一个 `hqdata` 数据源。需要日线时,首选 Tushare 或 RiceQuant;当前 `hqdata` 的 AkShare 适配器不提供稳定的日线能力,不能作为首版回测数据源。 -- **时间:** 日期一律使用 `YYYYMMDD`。`before_trading_start(D)` 只能访问 D-1 及以前的数据,可在 D 开盘参与撮合;`on_bar(D)` 在 D 收盘后才看到 D 日线,所提交订单最早在 D+1 开盘处理。 -- **缺行与停牌(任务 14):** `get_bars` 允许逐日间隙(窗口内无任何行返回 `[]`);个股当日缺行(停牌 / 未上市 / 已退市)属正常业务结果;**整日快照文件缺失** 是基础设施错误,必须中止运行。停牌持仓估值采用「最近 20 个交易日内最近一个有效收盘价」并写入 `DATA_WARNING` 事件;首日盘前哨兵 `visible_through="00000000"` 不抛异常。 -- **成交量单位:** `Bar.volume` 单位为「**手**」(1 手 = 100 股;与 Tushare `hqdata` 适配器口径一致);需要股数时应乘以 `LOT_SIZE`。 -- **成交:** 首版市价单按符合规则的开盘价全额成交;订单、拒绝、费用和成交都要保留可追溯记录。 -- **复权:** 成交、现金账本和 v0.1 净值均使用未复权价格,且 `adjustment_policy` 固定为 `none`。同源复权因子可用于数据质量诊断,但不用于伪造现金分红、送配、配股或税务会计。 -- **结果:** 每次运行应导出净值曲线、订单、成交、每日持仓、成本、配置和运行元数据。 - -### 首版不支持 - -- 实盘交易、券商连接、实时行情和自动下单; -- 分钟线、Tick、盘中撮合、成交量参与率、限价单和止损单; -- 融资融券、卖空、期货、期权、多账户、多币种和组合级保证金; -- 没有可靠证券状态数据支撑的 ST、新股首日、涨跌停全部细则、北交所和复杂停复牌规则; -- 仅根据复权因子推断精确的现金分红、送配、配股及税费; -- 在 `hqdata` 尚未提供相应指数 API 前,把基准收益率作为运行的必需输入。 +- **数据:** 每次回测固定使用一个 `hqdata` CSV 快照;`data_root` 默认 `~/.hqdata`,`source` 指向其下的数据源目录。 +- **时间:** `YYYYMMDD` 8 位日期;`before_trading_start(D)` 看到 D-1 及以前,可在 D 开盘撮合;`on_bar(D)` 收盘后看到 D 日线,最早 D+1 开盘成交。 +- **成交量单位:** `Bar.volume` = 手(1 手 = 100 股;与 `hqdata` `tushare` 适配器口径一致)。 +- **成交:** 市价单按符合规则的开盘价全额成交;订单、拒绝、费用与成交全程留痕。 +- **复权:** 成交、现金账本和 v0.1 净值使用未复权价格;`adjustment_policy="none"`。 +- **结果:** 每次运行产出净值曲线、订单、成交、每日持仓、成本、配置和运行元数据。 + +### 明确不支持 + +- 实盘交易、券商连接、实时行情和自动下单。 +- 分钟线、Tick、盘中撮合、成交量参与率、限价单和止损单。 +- 融资融券、卖空、期货、期权、多账户、多币种和组合级保证金。 +- 没有可靠证券状态数据支撑的 ST / 涨跌停 / 新股首日 / 北交所细则。 +- 仅由复权因子推断精确的现金分红、送配、配股及税费。 +- 在 `hqdata` 尚未提供指数日线前,把基准收益率作为运行的必需输入。 ## 安装 -### 当前阶段 - -`hqbacktest` v0.1 已完成可安装包、领域模型、受限 CSV 数据层、日频事件时钟、受控策略接口、开盘市价撮合、可替换的 A 股规则与成本模型、公司行为扩展的设计门槛、可审计的结果对象与绩效指标、端到端示例与回归基准,以及 TOML 配置 + `hqbacktest run` CLI(任务 2–12):买入并持有与均线策略在 7 天 `InMemoryDataPortal` 上产出确定的现金、持仓、成交、费用明细与净值曲线,规则触发的拒绝原因会出现在事件日志中,`adjustment_policy` 在配置校验层被严格收敛到 `"none"`,`BacktestResult` 记录策略、因子诊断、五个 CSV 表与 `PerformanceMetrics`,`save(dir)` / `load(dir)` 可重建,CLI 输出目录在两次相同输入下字节相同(去除 runtime 内存地址后)。CI(任务 13)覆盖 Python 3.10/3.11/3.12、black、`pytest`、`pytest-cov`、示例脚本与 CLI smoke,并产出可安装的 `sdist` + `wheel`。 - ```bash git clone git@github.com:HonestQuantTech/hqbacktest.git cd hqbacktest -``` - -### 开发环境 -本仓库使用 Python `>=3.10`,与 `hqdata` 的 Python 下限保持一致。`pyproject.toml` 同时声明 `3.10`、`3.11`、`3.12` 作为目标版本。 - -```bash # 创建虚拟环境 python -m venv .venv source .venv/bin/activate -# 安装本地数据层及所需数据源支持(以 tushare 为例) +# 装数据层(按需选择数据源) pip install -e "../hqdata[tushare]" -# 可编辑安装回测引擎与开发依赖 +# 可编辑安装本项目 + 开发依赖 pip install -e ".[dev]" ``` -### 包布局 - -```text -hqbacktest/ -├── pyproject.toml # 构建、依赖、pytest 与 black 配置 -├── src/hqbacktest/ # 引擎源码(src 布局) -│ ├── __init__.py # 版本及稳定的公开 API 导出 -│ ├── __main__.py # `hqbacktest run` CLI 入口(任务 12) -│ ├── domain/ # 任务 3 的模型、状态机、精度与序列化 -│ ├── data/ # 任务 4 的数据门户、DataView、缓存与校验 -│ ├── engine/ # 任务 5–6 的事件时钟、调度器、BacktestEngine 与受控策略接口 -│ └── cli/ # 任务 12 的 TOML 配置解析、CLI runner、退出码 -├── tests/ # 单元测试,必须不依赖网络或本地行情文件 -├── examples/ # 端到端示例(任务 11:buy_and_hold / moving_average) -└── docs/design/ # 设计文档(如 mvp-contract.md) -``` +`pyproject.toml` 声明的 Python 目标版本为 3.10 / 3.11 / 3.12。 ## 配置数据源 -`hqbacktest` 只读 hqdata CLI 已落盘的本地 CSV,不接触任何数据源 token,也不在回测运行时调用 `hqdata.api`。数据下载和更新须在回测前由 `hqdata` CLI 完成。 - -回测配置使用 `data_root` 和 `source` 来定位已落盘数据。`data_root` 默认为 `~/.hqdata`,`source` 是其下的数据源目录名: +`hqbacktest` 不接触任何数据源 token,回测配置通过 `data_root` + `source` 定位 `hqdata` 已落盘的 CSV。 | 写法 | 含义 | | --- | --- | | `data_root="~/.hqdata"`, `source="tushare"` | 使用 `~/.hqdata/tushare` | | `data_root="/mnt/market-data"`, `source="ricequant"` | 使用 `/mnt/market-data/ricequant` | -数据集必须包含 `calendar.csv`,以及按交易日组织的 `stock_list/{YYYYMMDD}.csv`、`stock_daily/{YYYYMMDD}.csv` 与 `stock_factor/{YYYYMMDD}.csv`。这些文件由 `hqdata` CLI 在回测前写入;hqbacktest 既不下载数据,也不保存凭证。任何真实 token、账户号或私密配置都**不应**提交到仓库,也不应出现在回测结果目录中。 - -### 性能与内存(任务 15) - -`HqDataCsvPortal` 在单次回测中按「按日文件缓存 + 按 symbol 累积序列」两层缓存: - -- 每个 `stock_daily/{D}.csv` / `stock_factor/{D}.csv` 在一次运行中最多解析一次;解析结果以 `{date: {symbol: Bar}}` 形式缓存。 -- 每个 symbol 在内存中维护一个按日期升序的累积序列;`get_bars` / `get_factor` 在该序列上做 `bisect` 切片,单次调用 O(log N)。 -- `DataView.history` 走累积缓存,单次 `get_bars` 切片即可;`DataView.current_price` 仅需一次 `get_calendar`(有缓存)确定 20 个交易日回看起点 + 一次 `get_bars`,二者都避免了旧实现的逐日 `get_bars(day, day)` 往返。 -- `Bar` / 因子对象在重叠窗口间复用,仅返回列表的防御性拷贝。 - -真实数据基准(`~/.hqdata/tushare`,20260105–20260731,139 个交易日,每个 daily 文件约 5000 行): - -| 场景 | 总耗时(含首次数据加载) | 任务 15 目标 | -| --- | --- | --- | -| 5 stocks × 139 days MA 策略 | ~7.6 s | < 10 s ✅ | -| 300 stocks × 139 days MA 策略 | ~9.4 s | < 120 s ✅ | - -内存量级:每个 `Bar` 约 200 字节。覆盖完整窗口(5000 symbols × 139 days ≈ 70 万 Bar)约 140 MB;策略触及的 universe 通常远小于全市场。`_symbol_bars` 累积只对真实访问过的 symbol 增长。 +`source` 接受名称或绝对路径,底层 CSV 布局由 `hqdata` CLI 在回测前写入;`hqbacktest` 既不下载数据,也不保存凭证。 -性能冒烟测试在 `tests/data/test_task15_performance.py`,50 symbols × 250 days 全量 `history(bar_count=20)` 在 15 秒阈值内完成。 +## 使用 -### 撮合与账本口径(任务 16) - -- **同日撮合顺序:** 单个 `OPEN_MATCH(today)` batch 内**所有 SELL 先撮合、再撮合 BUY**(A 股「卖出资金当日可用」),同侧内保持策略提交顺序。资金检查用**滚动现金**而非撮合前快照。 -- **整手取整:** 仅 BUY 按 100 股整手向下取整;SELL 允许任意正整数股(含零股),静默截到整手违反契约。`order_target(symbol, 0)` 必须能清仓含零股的持仓。 -- **T+1 可卖不足:** 整单拒绝(`INSUFFICIENT_SHARES`),不支持部分截断。 -- **`realized_pnl` 不含费用**:仅 `(sell_price - avg_cost) × quantity`;费用(commission / stamp_tax / other_fee)只走现金账。 -- **金额量化:** 全部 `ROUND_HALF_EVEN`(`quantize_cash` 0.01 元 / `quantize_price` 0.0001 元),与券商「四舍五入到分」存在 1 分级差异。 -- **`Fill.BUY` 不携带 `stamp_tax`:** 印花税仅在 SELL 收取;`Fill.__post_init__` 拒绝 BUY 携带非零 stamp_tax 以保证账本与 costs 表一致。 -- **`intents.target_quantity_for_value(0)` 返回 0**(flatten),与 docstring 一致。 -- **CLI `initial_cash` 拒绝 float**(TOML 字面 `100000.0` 报错),与 `BacktestConfig` 严格度对齐(contract rule 5)。 - -完整口径见 `docs/design/mvp-contract.md` §3.4。手算回归测试在 `tests/engine/test_task16_matching.py`。 - -### 净值与绩效指标口径(任务 17) - -- **首日 P&L 进入曲线:** `daily_return[0] = total_equity[0] / initial_cash - 1`、`drawdown[0] = (initial_cash - total_equity[0]) / initial_cash`,不再硬编码 0;**后续日回撤 running peak = `max(initial_cash, 历史 total_equity)`**(峰值序列以 `initial_cash` 为初始峰值);满足恒等式 `∏(1 + daily_return) = 1 + total_return`(Decimal 精度内)。 -- **波动率样本不足返回 `None`:** `< 2` 个日收益时 `daily_volatility` / `annualized_volatility` / `sharpe_ratio` 返回 `None` 并附 note,禁止错报 0。真正 0 波动率才返回 `Decimal('0')`。 -- **幂运算桥接:** `(1 + total_return) ** (n / 252)` 通过 `Decimal(str(float(...)))` 重建为 Decimal,避免 `Decimal(float(...))` 直接继承二进制浮点。 -- **Decimal 量化:** 所有 `float` 桥接的 Decimal 输出统一 quantize 到 `Decimal('0.000000000001')`,保证 `summary.json` 干净。 -- **`positions.sellable_quantity` 口径:** **结转后**(D 行快照显示 D+1 起始时可卖数)。engine 在 `_snapshot_equity` 前调用 `settle_t1`,D 行的 `sellable_quantity` 已包含当日成交的滚动。 - -完整口径见 `docs/design/mvp-contract.md` §3.5。手算回归测试在 `tests/engine/test_task17_metrics.py`。 - -### 策略隔离与审计完整性(任务 18) - -- **`Order` 不可变:** `@dataclass(frozen=True)`,策略通过 `Context.pending_orders()` 拿到 Order 后无法修改任何字段(`quantity` / `avg_fill_price` / `fill_ids` 等)。`transition` / `record_fill` 用 `object.__setattr__` 写入冻结字段,仅 engine / broker 可调用。 -- **`DataView.portal` 私有:** 字段名 `_portal`,策略无法通过 `view.portal.get_bars(sym, future_date)` 读到 `visible_through` 之后的数据;所有数据访问走 `view.history` / `view.current_price` / `view.universe`。 -- **Universe 生效:** `set_universe([...])` 后对未声明符号下单立即拒绝(`RejectReason.OUT_OF_UNIVERSE`,含 ORDER_CREATED + ORDER_REJECTED 事件,Order 不经过 broker、停在 `_out_of_universe_orders` 并在 result 构造时折入 `orders_table`);未设 universe 时不限制。 -- **历史股票池:** `Context.historical_universe()` 返回 `visible_through` 当日的 portal 股票池(默认排除 `.BJ`),受可见性约束,不暴露 raw portal。 -- **返回值防御性:** `pending_orders()` / `universe()` / `historical_universe()` 均返回 list 副本;Bar / Factor 跨查询复用(任务 15)。 - -完整口径见 `docs/design/mvp-contract.md` §3.6。回归测试在 `tests/engine/test_task18_isolation.py`。 - -### 因子诊断与分红偏差显性化(任务 19) - -> ⚠️ **`adjustment_policy=none` 下,跨除权日的净值系统性低估(少分红现金)。** - -`v0.1` 严格不实现分红会计(契约任务 9)。任务 19 让偏差**可见**: - -- 引擎对**当前持仓**标的的因子跳变(|Δfactor/factor| > **0.1%**)自动生成 `DATA_WARNING` 事件、`FactorDiagnostic` 记录;写入 `result.factor_diagnostics`、`summary.json`、`events.jsonl`。清仓后(持仓归零)停止告警,持有期结束。 -- **账本零影响**:诊断是只读观测,cash / position / equity 与无诊断时 byte-identical(`test_diagnostics_do_not_change_ledger` 锁定)。 -- CLI 末尾打印一行汇总:`warning: N corporate-action factor jumps detected during holding periods; NAV excludes dividends (adjustment_policy=none), see summary.json`。 -- 跨除权日长区间结果不可用于收益评估,必须先评估 factor 跳变并改用精确的公司行为会计(v0.1 之后的能力);README 显著位置保留此声明并链接诊断输出。 - -完整口径见 `docs/design/mvp-contract.md` §3.7。复刻 600000.SH 2026-07-16 除权案例的回归测试在 `tests/engine/test_factor_diagnostics.py`。 - -## Python 用法(已实现) - -> 下面的代码就是 `examples/moving_average.py` 的简化版,演示了 `Context` 与 `DataView` 的基本交互。**端到端运行请用 CLI(见 §命令行)或直接 `python -m hqbacktest run`**;完整示例见 `examples/`。 +最小 Python 示例(公共 API,详见 `examples/`): ```python from decimal import Decimal - -from hqbacktest import BacktestConfig, BacktestEngine, BaseStrategy, EventType +from hqbacktest import BacktestConfig, BacktestEngine, BaseStrategy from hqbacktest.data import DataView class MovingAverageStrategy(BaseStrategy): - def initialize(self, context): - context.set_universe(["600000.SH"]) - - def on_bar(self, context, data): - closes = data.history("600000.SH", field="close", bar_count=5) - if len(closes) < 5: - return - avg = sum(closes) / len(closes) - if closes[-1] > avg: - context.order_target_percent("600000.SH", Decimal("0.95")) - else: - context.order_target("600000.SH", 0) - - -config = BacktestConfig( - start_date="20240102", - end_date="20240110", - initial_cash="100000", - source="tushare", -) - -result = BacktestEngine(config, strategy=MovingAverageStrategy()).run() - -print("total_return", result.metrics.total_return) + def initialize(self, context): + context.set_universe(["600000.SH"]) + + def on_bar(self, context, data): + closes = data.history("600000.SH", field="close", bar_count=5) + if len(closes) < 5: + return + avg = sum(closes) / len(closes) + if closes[-1] > avg: + context.order_target_percent("600000.SH", Decimal("0.95")) + else: + context.order_target("600000.SH", 0) + + +result = BacktestEngine( + BacktestConfig(start_date="20240102", end_date="20240110", initial_cash="100000", source="tushare"), + strategy=MovingAverageStrategy(), +).run() result.save("results/moving-average") ``` -### 策略回调与订单时点 - -| 回调 | 能看到的数据 | 市价单最早成交时间 | 适合的工作 | -| --- | --- | --- | --- | -| `initialize(context)` | 无逐日行情(引擎强制,读取即报错) | 不可下单(引擎强制) | 设置固定参数和初始股票池 | -| `before_trading_start(context, data)` | 前一交易日及以前 | 当日开盘 | 根据已知历史生成开盘订单 | -| `on_bar(context, data)` | 当日收盘后包含当天日线 | 下一交易日开盘 | 计算收盘信号并提交次日订单 | -| `after_trading_end(context)` | 当日完成后的账户快照 | 不可下单 | 记录、检查和分析 | - -这套顺序是避免未来函数的核心约束:策略不会在看到当天收盘价后又以当天开盘价成交。 - -## 配置参数 - -| 参数 | 示例 | 说明 | -| --- | --- | --- | -| `start_date` / `end_date` | `"20240102"` | 回测的包含式日期区间,格式为 `YYYYMMDD` | -| `initial_cash` | `"100000"` | 初始人民币现金;账本层将其作为 `Decimal` 字段使用 | -| `data_root` | `"~/.hqdata"` | hqdata CSV 的根目录;可使用绝对路径覆盖 | -| `source` | `"tushare"` | 本次运行唯一的数据源目录名;裸名(如 `"tushare"`)解析为 `{data_root}/{source}`,绝对路径(如 `~/.hqdata/tushare`)拆为 `(parent_dir, basename)` 后定位快照(任务 22.1);不能在一次回测中混用 | -| `adjustment_policy` | `"none"` | v0.1 唯一合法值;精确公司行为会计完成并验证前,不支持因子总回报调整 | -| `universe` | `["600000.SH"]` | 可由策略在 `initialize` 中声明的目标股票池 | -| `cost_model` | 配置节 | 佣金、最低佣金、印花税和可选过户费;费率必须显式配置 | - -> 这张表是 v0.1 已实现的 `BacktestConfig` 字段。CLI 配置 TOML 用同名 key;CLI 还会接受 `--output` 覆盖 `[output].directory`。 - -## 命令行(任务 12 已实现) - -`hqbacktest run` 命令读取一份 TOML 配置,运行回测,将结果写入独立目录。退出码非零时唯一一行错误输出到 stderr。 - -### 安装 / 入口 - -`hqbacktest` 通过 `pyproject.toml` 的 `[project.scripts]` 注册为 console script。可编辑安装后可直接使用: +命令行(推荐用于 CI 与可复现实验): ```bash hqbacktest run --config configs/moving_average.toml --output results/moving-average -# 或: -python -m hqbacktest run --config configs/moving_average.toml --output results/moving-average ``` -**策略模块解析(任务 20):** `hqbacktest run` 会把 config 文件所在目录和当前工作目录加入 `sys.path`,让 `[strategy].module = "my_strategy"` 这类不带点号的写法能直接 import 成功(与 `python -m hqbacktest run` 行为一致)。 - -`--output` 可选:省略时使用配置中 `[output].directory`;提供时覆盖该值(例如 CI 里把结果重定向到临时目录)。 - -### 配置 schema - -```toml -[start] -start_date = "20240102" # YYYYMMDD,必填 -end_date = "20240105" # YYYYMMDD,必填 - -[capital] -initial_cash = "100000" # Decimal 字符串,必填,>= 0 - -[data] -source = "tushare" # 数据源名称或绝对路径,必填 -data_root = "~/.hqdata" # 可选;默认 ~/.hqdata,指向 CSV 快照根目录 - -[strategy] -module = "examples.buy_and_hold" # 可导入的 Python 模块,必填 -class_name = "BuyAndHold" # 可选;省略时使用模块内第一个 BaseStrategy 子类 -[strategy.kwargs] # 可选:传给策略构造函数的参数表 -answer = 42 - -[cost_model] # 可选:不填则使用 v0.1 默认费率 -commission_rate = "0.00025" # 0.025% -min_commission = "5.00" # 5 元保底 -stamp_tax_rate = "0.001" # 0.1%(仅 SELL) -transfer_fee_rate = "0.0" # 过户费(v0.1 默认 0) - -[output] -directory = "results/run-1" # 输出目录,必填;不存在则创建;可被 CLI --output 覆盖 -``` - -任何未知 section 或 key 都会触发 `ConfigError` 并产生非零退出码,便于 CI 即时报错。 - -### 输出目录 - -每次运行在 `directory` 下生成以下文件(互不重叠、可独立读出): - -```text -results/run-1/ -├── config.toml # 你传入的原始 TOML(精确字节) -├── run_metadata.json # hqbacktest / Python / 平台 / 时间戳 / git commit -├── events.jsonl # 引擎事件日志(每行一条 JSON) -├── equity_curve.csv # 日期、现金、持仓市值、总资产、日收益、回撤 -├── orders.csv # 全部订单及状态 -├── fills.csv # 全部成交与费用 -├── positions.csv # 每日持仓和估值 -├── costs.csv # 每次成交的费用拆分 -└── summary.json # 配置快照 + 指标 + 因子诊断 -``` - -`summary.json` 跨运行字节级稳定(去掉 `rule_set` 等含内存地址的运行时对象);同输入同数据 → 同输出,可用 `diff` 复核差异。 - -### 错误信息 - -| 情况 | 退出码 | 错误示例 | -| --- | --- | --- | -| 配置文件缺失 / 不可读 | 2 | `config file not found: configs/missing.toml` | -| TOML 语法错 | 2 | `config file configs/bad.toml is not valid TOML: ...` | -| 必填字段缺失 | 2 | `[start] missing required key 'start_date'` | -| 未知 section / key | 2 | `unknown config sections: ['extra']; allowed: [...]` | -| 日期格式错 | 2 | `[start].start_date: not a valid calendar date: '20241399' (...)` 或 `must be 8 digits, got '2024-01-02'` | -| `initial_cash = nan` / `inf` | 2 | `[capital].initial_cash=NaN must be a finite number ...` | -| `initial_cash = float` | 2 | `[capital].initial_cash must be int/str/Decimal; float is forbidden ...` | -| 空交易窗口 | 2 | `no trading days in [...] for source 'memory'; ...` | -| 策略模块无法导入 | 2 | `could not import strategy module 'examples.foo': ...` | -| 策略类非 BaseStrategy 子类 | 2 | `MyStrategy is not a BaseStrategy subclass` | -| 策略无 class_name 且模块无 BaseStrategy | 2 | `no BaseStrategy subclass found in ...` | -| 输出目录不可创建 / 不可写 | 3 | `cannot create output directory ...: ...` | -| 输出目录已含旧结果文件 | 3 | `output directory ... already contains prior-run files; pass force=True ...` | -| 引擎异常(非 RunFailed) | 4 | `backtest run failed: ...` | -| 成功 | 0 | stdout: `hqbacktest: wrote results to results/run-1` | - -CLI 同时支持 `--force` 覆盖已有输出目录:`hqbacktest run --config FILE --output DIR --force`。 - -### 复现性 - -两次相同输入 + 相同数据 + 相同 `data_root` 的运行: - -- `events.jsonl` 字节相同 -- `equity_curve.csv` / `orders.csv` / `fills.csv` / `positions.csv` / `costs.csv` 字节相同 -- `summary.json` 字段值相同(去除内存地址等运行时常量后) -- 唯一的非确定性字段是 `run_metadata.json` 的 `timestamp_utc`(记录运行时刻,非结果数据) - -## 输出与指标 - -`BacktestResult.save(dir)` 写出以下文件(任务10实现,公式在 `src/hqbacktest/engine/metrics.py` 顶部注释中显式): - -| 文件 | 内容 | -| --- | --- | -| `equity_curve.csv` | 日期、现金、持仓市值、总资产、日收益、回撤 | -| `orders.csv` | 委托方向、数量、订单类型、状态、关联成交与拒绝原因 | -| `fills.csv` | 成交日期、价格、数量、金额、佣金、印花税、关联订单 | -| `positions.csv` | 每日每持仓标的的余额、可卖量、均价、当日收盘价、市值 | -| `costs.csv` | 每次成交的费用拆分(佣金、印花税、过户费、净额) | -| `events.jsonl` | 完整事件日志(每行一个 JSON 事件,含订单/成交 ID 与错误原因) | -| `summary.json` | 配置快照、交易日、调整策略、数据来源(`data_version`)、因子诊断、指标 | - -指标公式(手算见 `tests/engine/test_metrics.py` 与 `tests/engine/test_task17_metrics.py`): - -- 累计收益 `(final_equity / initial_cash) - 1` -- 日收益 `EquityPoint.daily_return`(引擎写入,首日锚定到 `initial_cash`——见任务 23) -- 年化收益 `(1 + total_return) ** (N / annual_trading_days) - 1`,N < 2 时为 `None` -- 日波动率 `stdev(equity_curve.daily_returns)`(样本标准差,ddof=1),序列 < 2 时为 `None` -- 年化波动率 `daily_volatility * sqrt(annual_trading_days)` -- 夏普比率 `(annualized_return - risk_free_rate) / annualized_volatility`,零波动时为 `None` -- 最大回撤 `max(peak - current) / peak`(从净值曲线取) -- 换手率 `(sum(BUY 成交额) + sum(SELL 成交额)) / 2 / initial_cash`(单边均值) -- 交易次数 `len(fills)` -- 胜率 `SELL 且 fill.price > 当时平均成本` / `SELL 总数`(按时间顺序单遍重放),无 SELL 时为 `None` - -`risk_free_rate`(年化)与 `annual_trading_days`(默认 252)走 `MetricsConfig`,可追溯到配置;零分母与样本不足会在 `notes` 字段里显式说明。持仓标的在某日无有效收盘价时运行失败并记录 `DATA_ERROR` 事件(契约 §4:不使用前收、插值或静默按零估值)。指标不构成投资建议,也不应被解释为未来收益承诺。 - ## 示例 -端到端示例用 7 天确定性 `InMemoryDataPortal` 数据展示完整的下单-撮合-导出流程,不访问网络、不需要任何凭证: - ```bash python examples/buy_and_hold.py python examples/moving_average.py ``` -两份示例都只使用公开 `BaseStrategy` + `Context` API(`set_universe` / `order` / `order_target` / `order_target_percent` / `history` / `current_price`),从不接触引擎或账本的私有字段。`tests/examples/` 下有 10 项端到端回归测试,锁定了: - -- 买-持策略的最终现金、持仓、净值与总收益(手算) -- 均线策略的完整往返:买入、次日开盘成交、T+1 卖出、末交易日订单撤销(`BACKTEST_ENDED`) -- `result.save(dir)` + `BacktestResult.load(dir)` 的 CSV/JSON 往返 -- 均线策略对公共 API 的隔离(不允许触碰 `_portfolio` / `_event_log` 等内部字段) - -如果要切换到真实 CSV 数据源,编辑 `examples/buy_and_hold.py` 里 `_build_portal_inline()`: +两份示例都用 7 天确定性 `InMemoryDataPortal` 数据走通端到端流程,不访问网络、不需要任何凭证。`tests/examples/` 下有 10 项端到端回归测试,覆盖买-持、均线、T+1、费用、净值与指标。 -```python -from hqbacktest import HqDataCsvPortal -portal = HqDataCsvPortal(source="tushare", data_root="~/.hqdata") -``` - -`source` 可以是名称(如 `"tushare"`)或绝对路径(如 `"/home//.hqdata/tushare"`),底层 CSV 布局由 hqdata CLI 在回测前写入;hqbacktest 不会下载数据。 - -## 测试与开发 - -项目的验证入口为: +## 测试 ```bash pytest tests/ -v ``` -`tests/examples/` 中的端到端测试会再次 `python examples/*.py` 作为子进程,确认示例在干净环境下也能跑。单元测试必须使用内存数据或 mock,不依赖网络和本地行情文件。确需在真实数据上验证 Tushare 或 RiceQuant 适配的集成测试必须在 `~/.hqdata/{name}` 目录不存在或不可读时自动跳过。每一项公开 API、订单规则、时间语义和绩效公式都应有可手算的回归测试。 +单元测试必须使用内存数据或 mock,不依赖网络和本地行情文件;确需在真实数据上验证 `tushare` / `ricequant` 适配的集成测试必须在 `~/.hqdata/{name}` 不存在或不可读时自动跳过。 -## 模块边界 +## 文档导览 -`hqbacktest` 的模块边界在 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md) 固化。任何对默认值、术语、事件顺序或不可变规则的修改,必须**先**改契约,再同步调整 README,并在契约第 9 节追加一条变更记录。 +| 文档 | 内容 | +| --- | --- | +| [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md) | v0.1 产品契约:术语、模块边界、日事件顺序、不可变规则、非目标 | +| [`docs/cli.md`](docs/cli.md) | `hqbacktest run` 详细配置 schema、输出目录、错误码、复现性 | +| [`docs/output.md`](docs/output.md) | `BacktestResult.save(dir)` 输出文件结构与 `PerformanceMetrics` 字段含义 | +| [`docs/strategy-api.md`](docs/strategy-api.md) | 策略回调与下单时点、`Context` / `DataView` 可见性矩阵 | +| [`docs/matching.md`](docs/matching.md) | 撮合顺序、整手 / 零股、费用量化、`realized_pnl` 口径 | +| [`docs/metrics.md`](docs/metrics.md) | 首日 P&L、波动率样本、幂运算桥接、metrics 输出约定 | +| [`docs/isolation.md`](docs/isolation.md) | `Order` 不可变、`DataView` 私有 portal、universe 生效、历史股票池 | +| [`docs/factor-diagnostics.md`](docs/factor-diagnostics.md) | 因子诊断接入、分红偏差显性化、CLI 警告 | +| [`docs/performance.md`](docs/performance.md) | 双层缓存、真实数据基准、性能冒烟测试 | ## 免责声明 diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..a953b7d --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,172 @@ +# 命令行 + +> 适用版本:v0.1。`hqbacktest run` 命令读取 TOML 配置,运行回测,将结果写入独立目录。 + +## 1. 安装 / 入口 + +`hqbacktest` 通过 `pyproject.toml` 的 `[project.scripts]` 注册为 console script: + +```bash +hqbacktest run --config configs/moving_average.toml --output results/moving-average +# 等价: +python -m hqbacktest run --config configs/moving_average.toml --output results/moving-average +``` + +### 1.1 策略模块解析 + +`hqbacktest run` 把 config 文件所在目录和当前工作目录加入 `sys.path`,让 `[strategy].module = "my_strategy"` 这类**不带点号的写法**可以直接 `import` 成功,行为与 `python -m hqbacktest run` 一致。 + +### 1.2 `--output` 覆盖 + +- `--output` 可选;省略时使用配置中 `[output].directory`。 +- 提供时覆盖该值(例如 CI 把结果重定向到临时目录)。 + +### 1.3 `--force` + +输出目录已存在且非空时默认拒绝(exit 3);`--force` 强制覆盖: + +```bash +hqbacktest run --config FILE --output DIR --force +``` + +## 2. 配置 schema + +```toml +[start] +start_date = "20240102" # YYYYMMDD,必填 +end_date = "20240105" # YYYYMMDD,必填 + +[capital] +initial_cash = "100000" # Decimal 字符串,必填,>= 0 + +[data] +source = "tushare" # 数据源名称或绝对路径,必填 +data_root = "~/.hqdata" # 可选;默认 ~/.hqdata + +[strategy] +module = "examples.buy_and_hold" # 可导入的 Python 模块,必填 +class_name = "BuyAndHold" # 可选;省略时使用模块内第一个 BaseStrategy 子类 +[strategy.kwargs] # 可选:传给策略构造函数的参数表 +answer = 42 + +[output] +directory = "results/run-1" # 必填;不存在则创建;可被 --output 覆盖 +``` + +`[cost_model]` 可选;省略时使用 v0.1 默认费率(见下表)。 + +### 2.1 `[cost_model]` + +| 字段 | 默认 | 说明 | +| --- | --- | --- | +| `commission_rate` | `"0.00025"` | 0.025%,Decimal 字符串 | +| `min_commission` | `"5.00"` | 5 元保底 | +| `stamp_tax_rate` | `"0.001"` | 0.1%,仅 SELL | +| `transfer_fee_rate` | `"0.0"` | 过户费(v0.1 默认 0) | + +### 2.2 `source` 路径解析 + +| 写法 | 含义 | +| --- | --- | +| `source="tushare"` | 裸名,解析为 `{data_root}/{source}` | +| `source="/home/me/.hqdata/tushare"` | 绝对路径,拆为 `(parent_dir, basename)` | +| `source="~/.hqdata/tushare"` | 绝对路径同上(同 `pathlib.Path` 行为) | + +不能在一次回测中混用多个数据源。 + +### 2.3 验证规则 + +任何未知 section 或 key 触发 `ConfigError` + 退出码 2。详细验证见 §4「错误信息」。 + +## 3. 输出目录 + +每次运行在 `directory` 下生成以下文件(互不重叠、可独立读出): + +```text +results/run-1/ +├── config.toml # 你传入的原始 TOML(精确字节) +├── run_metadata.json # hqbacktest / Python / 平台 / 时间戳 / git commit +├── events.jsonl # 引擎事件日志(每行一条 JSON) +├── equity_curve.csv # 日期、现金、持仓市值、总资产、日收益、回撤 +├── orders.csv # 全部订单及状态 +├── fills.csv # 全部成交与费用 +├── positions.csv # 每日持仓和估值 +├── costs.csv # 每次成交的费用拆分 +└── summary.json # 配置快照 + 指标 + 因子诊断 +``` + +每个文件的 schema 与字段说明见 [`docs/output.md`](output.md)。 + +### 3.1 `run_metadata.json` + +| 字段 | 来源 | 示例 | +| --- | --- | --- | +| `hqbacktest_version` | `hqbacktest.__version__` | `"0.1.4"` | +| `python_version` | `sys.version` | `"3.11.5 ..."` | +| `platform` | `platform.platform()` | `"Linux-..."` | +| `timestamp_utc` | `datetime.utcnow().isoformat()` | `"2026-08-25T03:11:42+00:00"` | +| `git_commit` | **hqbacktest 自身**的 git commit(不是用户 cwd) | `"a1b2c3d..."` | +| `config_path` | `os.path.relpath(config_path, cwd)` | `"configs/moving_average.toml"` | +| `output_directory` | `os.path.relpath(output_dir, cwd)` | `"results/run-1"` | +| `data_root` | 配置给定 | `"~/.hqdata"` | +| `adjustment_policy` | 配置给定 | `"none"` | + +> `summary.json` **不**写入 token / 完整环境 / 本地绝对路径;`config_path` / `output_directory` 在写入时用相对 cwd 路径脱敏。 + +### 3.2 `summary.json` 稳定性 + +`summary.json` 跨运行字节级稳定(去掉 `rule_set` 等含内存地址的运行时对象);同输入同数据 → 同输出,可用 `diff` 复核差异。`rule_set` 在 `summary` 序列化时被剥离以避免内存地址入文件。 + +## 4. 错误信息 + +退出码分布: + +| 退出码 | 类别 | +| --- | --- | +| 0 | 成功 | +| 2 | 配置错误 | +| 3 | 输出目录错误 | +| 4 | 引擎异常 | + +具体错误一览: + +| 情况 | 退出码 | 错误示例(stderr 唯一一行) | +| --- | --- | --- | +| 配置文件缺失 / 不可读 | 2 | `config file not found: configs/missing.toml` | +| TOML 语法错 | 2 | `config file configs/bad.toml is not valid TOML: ...` | +| 必填字段缺失 | 2 | `[start] missing required key 'start_date'` | +| 未知 section / key | 2 | `unknown config sections: ['extra']; allowed: [...]` | +| 日期格式错 | 2 | `[start].start_date: not a valid calendar date: '20241399' (...)` 或 `must be 8 digits, got '2024-01-02'` | +| `initial_cash = nan` / `inf` | 2 | `[capital].initial_cash=NaN must be a finite number ...` | +| `initial_cash = float` | 2 | `[capital].initial_cash must be int/str/Decimal; float is forbidden ...` | +| 空交易窗口 | 2 | `no trading days in [...] for source 'memory'; ...` | +| 策略模块无法导入 | 2 | `could not import strategy module 'examples.foo': ...` | +| 策略类非 BaseStrategy 子类 | 2 | `MyStrategy is not a BaseStrategy subclass` | +| 策略无 `class_name` 且模块无 BaseStrategy | 2 | `no BaseStrategy subclass found in ...` | +| 输出目录不可创建 / 不可写 | 3 | `cannot create output directory ...: ...` | +| 输出目录已含旧结果文件 | 3 | `output directory ... already contains prior-run files; pass force=True ...` | +| 引擎异常(非 RunFailed) | 4 | `backtest run failed: ...` | +| 成功 | 0 | stdout 一行:`hqbacktest: wrote results to results/run-1` | + +> 退出码 ≠ 0 时,唯一一行错误输出到 stderr,便于 CI / shell 脚本处理。 + +## 5. 复现性 + +两次相同输入 + 相同数据 + 相同 `data_root` 的运行: + +- `events.jsonl` 字节相同 +- `equity_curve.csv` / `orders.csv` / `fills.csv` / `positions.csv` / `costs.csv` 字节相同 +- `summary.json` 字段值相同(去除内存地址等运行时常量后) +- 唯一的非确定性字段是 `run_metadata.json` 的 `timestamp_utc`(记录运行时刻,非结果数据) + +CI 用法: + +```bash +hqbacktest run --config configs/baseline.toml --output results/baseline +hqbacktest run --config configs/baseline.toml --output results/baseline-2 --force +diff -r results/baseline results/baseline-2 # 仅 run_metadata.json 的 timestamp_utc 不一致 +``` + +## 6. CLI 测试 + +CLI 测试覆盖端到端、配置验证、可复现性与错误信息,详见 `tests/cli/`。 diff --git a/docs/design/mvp-contract.md b/docs/design/mvp-contract.md index 519b5c5..73cf43c 100644 --- a/docs/design/mvp-contract.md +++ b/docs/design/mvp-contract.md @@ -138,7 +138,7 @@ - `data portal` 通过 `data_root` 与 `source` 解析数据集根目录。v0.1 的固定布局为 `{root}/{source}/calendar.csv`,以及 `stock_list/{YYYYMMDD}.csv`、`stock_daily/{YYYYMMDD}.csv`、`stock_factor/{YYYYMMDD}.csv`;任何缺失、不可读或格式不符的文件必须报错,不得联网回补。 - hqdata CSV 快照是叶子数据边界;更新数据只能在回测运行前通过 hqdata CLI 完成。 -### 3.3 数据可见性与缺行语义(任务 14 固化) +### 3.3 数据可见性与缺行语义 | 维度 | v0.1 默认决定 | | --- | --- | @@ -150,7 +150,7 @@ | `Bar.volume` 单位 | **手**(1 手 = 100 股;与 Tushare `hqdata` 适配器口径一致);调用方需要股数时应乘以 `LOT_SIZE`。 | | 双门户一致性 | `InMemoryDataPortal` 与 `HqDataCsvPortal` 行为完全一致(parity 测试覆盖)。 | -### 3.4 撮合与账本口径(任务 16 固化) +### 3.4 撮合与账本口径 | 维度 | v0.1 默认决定 | | --- | --- | @@ -166,7 +166,7 @@ | `target_quantity_for_value(0)` | 返回 `0`(flatten),与 docstring 一致。 | | CLI `initial_cash` | 拒绝 `float`(TOML 字面 `100000.0` 报错),与引擎层 `BacktestConfig` 严格度对齐。 | -### 3.5 净值与绩效指标口径(任务 17 固化) +### 3.5 净值与绩效指标口径 | 维度 | v0.1 默认决定 | | --- | --- | @@ -178,7 +178,7 @@ | Decimal 量化 | 所有 `float` 桥接的 Decimal 输出统一 quantize 到 `Decimal('0.000000000001')`,保证 `summary.json` 干净。 | | `positions.sellable_quantity` | **结转后**(D 行快照为 D+1 起始时可卖数);engine 在 `_snapshot_equity` 前调用 `settle_t1`,D 行的 `sellable_quantity` 即已包含当日成交的滚动。 | -### 3.6 策略隔离与审计完整性(任务 18 固化) +### 3.6 策略隔离与审计完整性 | 维度 | v0.1 默认决定 | | --- | --- | @@ -186,9 +186,9 @@ | `DataView.portal` 私有 | 字段名 `_portal`(下划线私有约定),策略**无法通过公开 API**(`view.portal`)访问——该属性已重命名为 `_portal`,访问旧名抛 `AttributeError`。该约定是 Python 的"约定私有"语义,**不**是语言级强制力:策略仍可经 `view._portal`(或 `context._data_view._portal`)触及 raw portal——这是 Python 语言限制而非本项目缺陷;契约依靠约定 + 审计测试守住。所有合规数据访问走 `view.history` / `view.current_price` / `view.universe`。 | | Universe 生效 | `set_universe(...)` 后,对未声明的符号下单立即拒绝(`RejectReason.OUT_OF_UNIVERSE`,含 ORDER_CREATED + ORDER_REJECTED 事件,Order 不经过 broker、停留在 `_out_of_universe_orders` 并在 result 构造时折入 `orders_table`);**未设 universe 时不限制**。 | | 历史股票池 | `Context.historical_universe()` 返回 `visible_through` 当日的 portal 股票池(默认排除 `.BJ`),受可见性约束;不暴露 raw portal。 | -| 返回值防御性 | `pending_orders()` / `universe()` / `historical_universe()` 均返回 list 副本;Bar / Factor 跨查询复用(任务 15)。 | +| 返回值防御性 | `pending_orders()` / `universe()` / `historical_universe()` 均返回 list 副本;Bar / Factor 跨查询复用。 | -### 3.7 因子诊断接入与分红偏差显性化(任务 19 固化) +### 3.7 因子诊断接入与分红偏差显性化 | 维度 | v0.1 默认决定 | | --- | --- | @@ -200,7 +200,7 @@ | CLI 警告 | `run_from_config` 末尾若 `result.factor_diagnostics` 非空,打印一行 `warning: N corporate-action factor jumps detected during holding periods; NAV excludes dividends (adjustment_policy=none), see summary.json`。 | | 文档承诺 | README 显著位置明示:`adjustment_policy=none` 下跨除权日的净值**系统性低估**(少分红现金),长区间结果不可用于收益评估,并链接因子诊断输出(`summary.json` / `events.jsonl`)。 | -### 3.8 CLI 易用性与契约承诺一致性(任务 20 固化) +### 3.8 CLI 易用性与契约承诺一致性 | 维度 | v0.1 默认决定 | | --- | --- | diff --git a/docs/factor-diagnostics.md b/docs/factor-diagnostics.md new file mode 100644 index 0000000..db2f825 --- /dev/null +++ b/docs/factor-diagnostics.md @@ -0,0 +1,90 @@ +# 因子诊断与分红偏差显性化 + +> 适用版本:v0.1。契约层级见 [`docs/design/mvp-contract.md`](design/mvp-contract.md) §3.7。 + +## 1. 为什么需要这份文档 + +`adjustment_policy="none"` 下,跨除权日(除息 / 除权日)的回测净值**系统性低估**(少分红现金)。这是数据 + 复权策略的选择决定的,不是 bug: + +- 成交、现金账本、v0.1 净值**全部使用未复权价格**。 +- 因子复权序列只用于数据质量诊断,不进入账本、不改写 `cash` / `position` / `equity`。 +- 因子总回报口径(`factor_total_return`)必须等独立的「公司行为会计」设计与验证完成后才允许加入配置。 + +v0.1 的合约做法:**让偏差可见,不让偏差消失**。这份文档说明诊断如何接入、怎样读输出。 + +## 2. 接入条件 + +| 维度 | 默认决定 | +| --- | --- | +| 启用条件 | `adjustment_policy="none"`(v0.1 唯一接受值) | +| 触发对象 | **当前持仓**(`position.quantity > 0`)的标的;清仓后停止告警(持有期结束) | +| 跳变阈值 | **0.1%**(holdings-period 阈值,`jump_band=(0.999, 1.001)`) | +| 一般诊断阈值 | `(0.5, 2.0)` 用于一般因子质量诊断(不带 holdings 约束) | +| 数据来源 | `portal.get_factor(symbol, today, today)` | +| 缺失处理 | 快照缺失 / 因子 `None` 静默跳过;不影响 run | + +## 3. 数据写入位置 + +引擎在 holdings 期间遇到因子跳变时同时写入三处: + +| 位置 | 内容 | +| --- | --- | +| 事件日志 | `EngineEvent(phase=DATA_WARNING, detail={symbol, before, after, ratio})` | +| `BacktestResult.factor_diagnostics` | 完整 `FactorDiagnostic` 列表:symbol / ex_date / before / after / ratio / action | +| `summary.json` 的 `factor_diagnostics` 字段 | 上列的 JSON 形态 | + +代码位置:`src/hqbacktest/engine/factor_diagnostics.py`、`src/hqbacktest/result.py`。 + +## 4. 账本零影响 + +诊断是**只读观测**: + +- `engine._running_cash` / `Portfolio.position[symbol]` / `EquityPoint.total_equity` 完全不受影响。 +- baseline(未启用诊断)与启用诊断版本的 `equity_curve.csv` / `orders.csv` / `fills.csv` / `positions.csv` / `costs.csv` / `summary.json` 字节相同(去掉 `summary.factor_diagnostics` 字段后)。 +- 测试锁定:`tests/engine/test_factor_diagnostics.py::test_diagnostics_do_not_change_ledger`。 + +## 5. CLI 警告 + +`hqbacktest run`(即 `cli/runner.run_from_config`)末尾检查 `result.factor_diagnostics`: + +- 非空 → 打印一行 stderr / stdout 提示: + ```text + warning: N corporate-action factor jumps detected during holding periods; NAV excludes dividends (adjustment_policy=none), see summary.json + ``` +- 该提示**不影响退出码**——回测成功仍是 exit 0;诊断的存在不等于运行失败。 +- `[--quiet]`(未实现 v0.1 暂不支持)或 `summary.json` 自身的 `factor_diagnostics` 字段是更持久的查询入口。 + +## 6. 长区间结果的解读 + +跨除权日长区间(如年度 / 多年)的回测净值: + +- **不可**直接用于收益评估,因为它系统性低估现金分红。 +- **必须**先评估因子跳变(看 `summary.factor_diagnostics` 与 `events.jsonl` 中的 `DATA_WARNING` 行)。 +- 若发现大量 hint 跳变集中于某几个交易日附近,且这些日期符合除权除息节奏,应视为「该结果已被分红偏差影响」的信号。 +- 重新评估需要等价的精确公司行为会计能力,**v0.1 不提供**;v0.1 的响应是「看见」而非「修正」。 + +## 7. API + +```python +from hqbacktest.engine.factor_diagnostics import analyze_factor_series + +diag: list[FactorDiagnostic] = analyze_factor_series( + symbol="600000.SH", + factors=[Decimal("1.0"), Decimal("1.0"), Decimal("0.85")], # 跨日序列 + threshold=(0.999, 1.001), # 0.1% +) +for d in diag: + print(d.ex_date, d.before, d.after, d.ratio) +``` + +`threshold` 缺省为 `(0.5, 2.0)`(一般诊断);holdings-period 引擎内部传 `(0.999, 1.001)`。 + +## 8. 手算回归 + +| 场景 | 测试位置 | +| --- | --- | +| 复刻 `600000.SH` 2026-07-16 除权案例 | `tests/engine/test_factor_diagnostics.py::test_600000_2026_07_16_ex_dividend` | +| 诊断不修改账本(byte-identical ledger) | `tests/engine/test_factor_diagnostics.py::test_diagnostics_do_not_change_ledger` | +| 缺因子 / 无效因子静默跳过 | `tests/engine/test_factor_diagnostics.py::test_missing_factor_no_warning` | +| 清仓后停止告警 | `tests/engine/test_factor_diagnostics.py::test_no_warning_after_position_closed` | +| CLI 末尾打印 `warning: N corporate-action ...` | `tests/cli/test_run_factor_warning.py` | diff --git a/docs/isolation.md b/docs/isolation.md new file mode 100644 index 0000000..1b0c4a2 --- /dev/null +++ b/docs/isolation.md @@ -0,0 +1,102 @@ +# 策略隔离与审计完整性 + +> 适用版本:v0.1。契约层级见 [`docs/design/mvp-contract.md`](design/mvp-contract.md) §3.6。 + +策略与引擎的边界是 v0.1 的核心安全承诺。下面这些约束强制策略只通过 `Context` / `DataView` 与引擎交互,所有绕过都必须被拒。 + +## 1. `Order` 不可变 + +```python +@dataclass(frozen=True) +class Order: + order_id: str + symbol: str + side: Side + quantity: int # BUY 已按整手向下取整 + avg_fill_price: Decimal | None = None + fill_ids: tuple[str, ...] = () # tuple 而非 list + status: OrderStatus = OrderStatus.ACCEPTED +``` + +- `@dataclass(frozen=True)`:策略拿到 `pending_orders()` 返回的 `Order` 后无法修改任何字段。 +- `transition` / `record_fill` 使用 `object.__setattr__` 写入冻结字段——这层写入**只**由 engine / broker 调用。 +- `fill_ids` 是 `tuple[str, ...]`,不是 `list`——防止策略 append 伪造 fill。 +- 策略异常 / 构造期外赋 `fill_ids = ['fake']` 会被 `frozen=True` 抛 `FrozenInstanceError` 立即可见。 + +代码位置:`src/hqbacktest/domain/order.py`。 + +## 2. `DataView.portal` 私有 + +`DataView` 持有的原始 portal 在字段层面是 `_portal`(下划线),不是 `portal`: + +```python +class DataView: + def __init__(self, portal: MarketDataPortal, visible_through: str, ...): + self._portal = portal # 私有约定 + # self.portal 不存在;访问抛 AttributeError +``` + +- 策略无法经 `view.portal.get_bars(sym, future_date)` 读到 `visible_through` 之后的数据。 +- 所有数据访问走 `view.history` / `view.current_price` / `view.universe`。 +- 这一约定在契约层登记时**明确是 Python 的「约定私有」**,不是语言级强制力——策略仍可经 `view._portal`(或 `context._data_view._portal`)触及原 portal,这是 Python 语言的限制,不是项目缺陷。契约依靠**约定 + 审计测试**守住: + +```python +# tests/engine/test_isolation.py +def test_strategy_cannot_access_raw_portal_by_public_name(): + view = DataView(portal=..., visible_through="20240101") + with pytest.raises(AttributeError): + view.portal +``` + +代码位置:`src/hqbacktest/data/view.py`、测试 `tests/engine/test_isolation.py`。 + +## 3. Universe 生效 + +```python +context.set_universe(["600000.SH", "000001.SZ"]) +context.order("999999.SH", 100) # → OUT_OF_UNIVERSE +``` + +- `set_universe([...])` 之后,对未声明的 symbol 下单**立即拒绝**。 +- 拒绝原因:`RejectReason.OUT_OF_UNIVERSE`,事件流: + - `ORDER_CREATED`(id 已分配) + - `ORDER_REJECTED`(reason=`OUT_OF_UNIVERSE`) +- Order **不**经过 broker / portfolio,停留在 `engine._out_of_universe_orders`,在 `BacktestResult` 构造时折入 `orders_table`,便于审计。 +- **未设 universe 时不限制**——v0.1 默认行为。 + +代码位置:`src/hqbacktest/engine/engine.py::_submit_intent`、`src/hqbacktest/engine/universe_filter.py`。 + +## 4. 历史股票池 + +`Context.historical_universe()`: + +- 返回 `visible_through` 当日的 portal 股票池(默认排除 `.BJ`)。 +- 受可见性约束:跨日访问需要先等引擎推进到合适的回测日。 +- 不暴露原 `MarketDataPortal`。 +- 与 `DataView.universe()` 行为一致(`Context.historical_universe` 转发该接口),契约 §3.3。 + +```python +def historical_universe(self) -> list[str]: + return self._data_view.universe() # 转发,不持有 portal +``` + +## 5. 返回值防御性 + +`pending_orders()` / `universe()` / `historical_universe()` **均返回 list 副本**(`list(self._internal)`),防止策略原地修改内部状态。 + +`Bar` / `Factor` 跨查询复用:同一引用在多个 `data.history()` 调用间共享——这是为减少 70 万 Bar 量级下的内存压力而做的、已经审计过的优化(见 [`docs/performance.md`](performance.md))。策略不应就地修改 `Bar` 字段。 + +## 6. `Context` 只读 + +策略仅通过 `Context` 的查询方法读取现金、持仓、订单等;对 `Portfolio` 字段直接赋值(`context._portfolio.cash = ...`)必须抛错。这层保护依赖「私有字段名 + 审计测试」,与 §2 同源。 + +## 7. 审计测试清单 + +| 测试 | 文件 | +| --- | --- | +| `Order` 不可变(frozen + tuple fill_ids) | `tests/domain/test_order.py` | +| `DataView.portal` 是 `AttributeError` | `tests/engine/test_isolation.py::test_dataview_portal_is_private` | +| Universe 生效:未声明 symbol 拒绝 | `tests/engine/test_isolation.py::test_universe_filter_rejects_unknown_symbol` | +| `Context.historical_universe` 不暴露 portal | `tests/engine/test_isolation.py::test_historical_universe_does_not_expose_portal` | +| `pending_orders` / `universe` 返回 list 副本 | `tests/engine/test_isolation.py::test_return_values_are_defensive_copies` | +| 示例策略只触碰公共 API | `tests/examples/test_isolation_examples.py` | diff --git a/docs/issues/hqdata-csv-source.md b/docs/issues/hqdata-csv-source.md new file mode 100644 index 0000000..1fb64b7 --- /dev/null +++ b/docs/issues/hqdata-csv-source.md @@ -0,0 +1,142 @@ +# feat(sources): 新增 CsvSource —— 让 hqbacktest 通过 hqdata API 读取 CSV snapshot + +## 动机 + +hqdata CLI 已经把日线 / 因子 / 股票池落盘到 `{data_root}/{source}/.../{YYYYMMDD}.csv`。目前 `hqbacktest` 自己再用 `pd.read_csv` 重新解析一遍,存在职责重叠。改造方向: + +- hqdata 新增 `CsvSource`,通过 `hqdata.init_source("csv", root=..., source_name=...)` 启用,从快照目录读取并以 `pandas.DataFrame` 形式返回。 +- hqbacktest 在回测期调用 `hqdata.api.get_*` 系列接口;CSV 列映射、文件存在性检查、错误分类全部由 hqdata 负责。 +- hqbacktest 仍保留自己的双层缓存与 `DataFrame → Bar/Factor` 转换(属于回测侧语义,不在本 issue 范围)。 + +本 issue 只覆盖 hqdata 这一侧。hqbacktest 侧由另一 issue 跟进(见文末关联)。 + +## 范围 + +### 在本 issue 内 + +- 新增 `hqdata.errors.SnapshotFileMissingError`(含 `kind` / `date` / `path` / `source_name` 字段)。 +- 新增 `hqdata.sources.csv_source.CsvSource(BaseSource)`,实现 `get_calendar` / `get_stock_list` / `get_stock_daily_bar` / `get_stock_factor` / `get_stock_snapshot`。 +- `hqdata.api.init_source` 扩展 `"csv"` 分支:从 kwargs 取 `root`,构造 `CsvSource`。 +- 单元测试 `tests/sources/test_csv_source.py` + `tests/api/test_init_source.py::test_init_source_csv`。 + +### 不在本 issue 内 + +- 不改动 tushare / ricequant / akshare 任何实现。 +- 不改动 `hqdata.cli` 与 `compare_cli`(下载 / 比较仍走原逻辑)。 +- 不引入 hqbacktest 依赖。 +- 不实现 `get_stock_snapshot`(CsvSource 没有实时数据,保留 `NotImplementedError`)。 +- 不暴露写 CSV 的反向接口。 + +## 设计要点 + +### 数据布局(与 hqdata CLI 落盘一致) + +``` +/ +├── calendar.csv # date, is_open +├── stock_list/{YYYYMMDD}.csv # symbol, date, name, exchange, board, curr_type, list_date, delist_date +├── stock_daily/{YYYYMMDD}.csv # symbol, date, pre_close, open, high, low, close, volume, turnover, change, pct_change +└── stock_factor/{YYYYMMDD}.csv # symbol, date, factor +``` + +### `CsvSource` API + +```python +class CsvSource(BaseSource): + def __init__(self, root: str | Path, source_name: str = "csv"): + ... +``` + +构造期做最少校验:`root` 存在且是目录;否则 `ValueError`。**不**要求 `calendar.csv` 当时就存在(部分回测可能只跑子集)。 + +### 各接口语义 + +| 接口 | 行为 | 整日文件缺失 | +| --- | --- | --- | +| `get_calendar(start, end, is_open)` | 读 `calendar.csv`,过滤 `[start, end]` 与 `is_open` | 文件不存在 → `SnapshotFileMissingError("calendar", "", path)`;返回空 DataFrame | +| `get_stock_list(trade_date, ...)` | 读 `stock_list/{trade_date}.csv`,按 symbol/exchange/board 过滤 | 文件不存在 → `SnapshotFileMissingError("stock_list", trade_date, path)` | +| `get_stock_daily_bar(symbol, start, end, trading_days)` | 用 `get_calendar` 解出实际交易日,逐日读 `stock_daily/{date}.csv`,按 symbol 过滤行,concat 为一张 DataFrame | 单日文件缺失 → `SnapshotFileMissingError("stock_daily", date, path)` | +| `get_stock_factor(trade_date, symbol)` | 读 `stock_factor/{trade_date}.csv`,按 symbol 过滤 | 文件不存在 → `SnapshotFileMissingError("stock_factor", trade_date, path)` | +| `get_stock_snapshot(symbol)` | 不实现 | — | + +**个股缺失**:仅是该 symbol 在该日无行 → 静默不返回(与 tushare/ricequant 行为一致)。 + +**列名一致性**:返回 DataFrame 的列名与 `BaseSource._empty_stock_*` 列名完全对齐,便于 hqbacktest 转 Bar。 + +### `init_source` 签名 + +现状: + +```python +def init_source(source_type: Literal["ricequant", "tushare", "akshare"], **kwargs) -> None +``` + +扩展后: + +```python +def init_source( + source_type: Literal["ricequant", "tushare", "akshare", "csv"], **kwargs +) -> None: + ... + elif source_type == "csv": + if "root" not in kwargs: + raise ValueError("init_source('csv', ...) requires root=") + from hqdata.sources.csv_source import CsvSource + _source = CsvSource(**kwargs) +``` + +`tushare` / `ricequant` / `akshare` 分支一字不动。 + +### 数值类型 + +CSV 是文本;`factor` / `close` / `volume` / `amount` 等列读出后用 `Decimal(str(...))` 转,**禁止** `Decimal(float(...))`(与 hqbacktest 已有约定一致)。`is_open` 视为 `int` 0/1;`date` 保持 `str`(YYYYMMDD)。 + +## 实现清单 + +- [ ] `hqdata/errors.py`(若已有则同文件):新增 `SnapshotFileMissingError`,字段 `kind / date / path / source_name` +- [ ] `hqdata/sources/csv_source.py`:新增 `CsvSource`,按上表实现 5 个接口 +- [ ] `hqdata/sources/__init__.py`:导出 `CsvSource` +- [ ] `hqdata/api.py::init_source`:扩展 `Literal` 与 csv 分支 +- [ ] `hqdata/tests/sources/test_csv_source.py`: + - `calendar.csv` 读取 + `is_open` 过滤 + 空区间返回 + - `stock_list/{d}.csv` 正常 + 缺文件抛 `SnapshotFileMissingError` + - `stock_daily/{d}.csv` 跨区间 union + symbol 过滤 + 个股缺行静默 + 单日缺文件抛异常 + - `stock_factor/{d}.csv` 正常 + - 列名与 `BaseSource._empty_stock_*` 一致 +- [ ] `hqdata/tests/api/test_init_source.py`:加 `test_init_source_csv` 用 `tmp_path` 伪造 `{root}` 后调用 `hqdata.api.*` 验证接口可用 + +## 验收标准 + +1. `pytest hqdata/tests/ -v` 全绿。 +2. 新增一行: + + ```python + init_source("csv", root="/path/to/snapshot") + hqdata.get_stock_list("20240102") # 返回正确 DataFrame + hqdata.get_stock_daily_bar("600000.SH", "20240102", "20240110") # 跨日 concat 正确 + hqdata.get_stock_factor("20240102") # 返回 factor DataFrame + ``` + + 全部按 §「各接口语义」表行为。 +3. 缺文件时抛 `SnapshotFileMissingError`(含明确 `kind / date / path / source_name`),不抛裸 `FileNotFoundError`,不静默返回空。 +4. `tushare` / `ricequant` / `akshare` 三条路径行为完全不变(既有测试不退化)。 +5. `hqdata.api.init_source("csv")` 缺 `root=` 报清晰错误,不静默使用空字符串。 + +## 风险与注意 + +- **列名漂移**:`CsvSource` 构造后第一次调用任何接口前,先做一次列名校验(用 `_empty_stock_*` 的列集合 ⊆ 实际读出 csv 列集合),不一致直接抛 `InvalidDataError` 形异常。 +- **大文件**:单日 `stock_daily` 实测约 5000 行 × ~12 列,`pd.read_csv` 单次无压力;不在本 issue 引入缓存(缓存属于 hqbacktest 侧,见另一 issue)。 +- **路径分隔符**:用 `pathlib.Path`,不手拼字符串。 +- **类型转换**:`pd.read_csv` 默认数值列读成 `float64`;CsvSource 内对 `factor` / 价格 / 数量列做显式 `Decimal(str(s))` 转换。 + +## 不在 scope 内(提示但不实现) + +- hqbacktest 改造(`HqDataCsvPortal` → 调用 hqdata API;`DataFrame → Bar` 转换;双层缓存)—— 见 TODO.md §3 阶段 B。 +- hqdata CsvSource 的内部缓存(理由:缓存属于调用方语义,hqbacktest 自己有更严的双层缓存)。 +- hqdata CsvSource 的写接口(CSV 落盘仍走原 `hqdata.cli`)。 + +## 关联 + +- 实施计划根文档:`hqbacktest/TODO.md` §3「实施阶段」阶段 A +- 上下文:hqbacktest issue(待开)—— 「feat(portal): 改为通过 hqdata API 读取 CSV」 +- 设计依据:`hqbacktest/docs/design/mvp-contract.md` §3.1「数据边界」(改造后该节措辞需更新,由 hqbacktest 侧 issue 一并处理) diff --git a/docs/matching.md b/docs/matching.md new file mode 100644 index 0000000..6ce920b --- /dev/null +++ b/docs/matching.md @@ -0,0 +1,88 @@ +# 撮合与账本口径 + +> 适用版本:v0.1。契约层级见 [`docs/design/mvp-contract.md`](design/mvp-contract.md) §3.4;本文给出可操作的边界与代码位置。 + +## 1. 同批撮合顺序 + +单个 `OPEN_MATCH(D)` batch 内**所有 SELL 先撮合、再撮合 BUY**(A 股「卖出资金当日可用」语义): + +```text +OPEN_MATCH(D) + ├── SELL 队列(按策略提交顺序;缺钱 / 缺券直接拒,不滚动) + │ └── 卖出回款累加到 running_cash + └── BUY 队列(按策略提交顺序;按整手向下取整;现金不足直接拒) + └── 成本从 running_cash 扣除 +``` + +资金检查走 **滚动现金**(running_cash):SELL 净回款先增、BUY 成本后扣;同批 SELL 失败的 BUY 不会回滚。这保证「卖出资金当日可用」语义。 + +代码位置:`src/hqbacktest/engine/broker.py`(`match()`、`SimulatedBroker.match`)。 + +## 2. 数量与整手 + +| 维度 | v0.1 决定 | 备注 | +| --- | --- | --- | +| BUY 整手取整 | 按 100 股向下取整 | `LOT_SIZE=100`;零股静默截到整手违反契约 | +| SELL 数量 | 允许任意正整数股(含零股) | 仅 BUY 整手;SELL 严禁静默截到整手 | +| 清仓零股 | `order_target(symbol, 0)` 必须能清掉含零股的持仓 | 走 `intents.target_quantity_for_value(0) → 0` | +| `order(sym, -N)` | 对任意正整数 N 提交原数量 | 不整手 | +| T+1 可卖不足 | **整单拒绝**(`RejectReason.INSUFFICIENT_SHARES`) | 不支持部分截断 | + +代码位置:`src/hqbacktest/engine/intents.py`、`src/hqbacktest/engine/rules.py`。 + +## 3. 费用量化 + +| 字段 | 四舍五入方式 | 精度 | +| --- | --- | --- | +| 现金 / 费用 / 净值 | `ROUND_HALF_EVEN` | `quantize_cash = Decimal('0.01')` 元 | +| 价格 / 成交价 / 成本价 | `ROUND_HALF_EVEN` | `quantize_price = Decimal('0.0001')` 元 | + +与券商「四舍五入到分」存在 **1 分级** 差异(半数情形不同),属契约口径,不引入误差补偿。 + +代码位置:`src/hqbacktest/util/money.py`。 + +## 4. 费用方向 + +| 费用 | BUY | SELL | +| --- | --- | --- | +| 佣金 | ✓(`commission_rate`) | ✓ | +| 印花税 | **0**(`Fill.__post_init__` 拒绝非零) | ✓(`stamp_tax_rate`) | +| 过户费 | 可选(默认 0) | 可选 | + +`Fill.BUY` 携带非零 `stamp_tax` 是契约违反,构造期即抛错。这条约束保证 `costs.csv` 与账本一致。 + +代码位置:`src/hqbacktest/domain/fill.py`、`src/hqbacktest/engine/cost.py`。 + +## 5. realized_pnl 口径 + +`realized_pnl` **不含任何费用**,仅 `(sell_price - avg_cost) × quantity`: + +- 费用(commission / stamp_tax / other_fee)只走现金账,不混入 `realized_pnl`。 +- 同日同价「先买后卖」与「先卖后买」时,`realized_pnl` 在费用外相同,**不含费用的现金额**依提交顺序不同(SELL 先成交会先释放资金再扣 BUY 成本)。 +- 持有期跨越多个交易日时,`avg_cost` 是该日日终的滚动加权平均成本,由 broker 在每次成交后写入 `Position.avg_cost`。 + +代码位置:`src/hqbacktest/engine/portfolio.py`、`src/hqbacktest/domain/position.py`。 + +## 6. CLI 与引擎一致 + +`hqbacktest run` 的 `[capital].initial_cash` 字段: + +| 类型 | 是否接受 | 错误码 | +| --- | --- | --- | +| `int` / `str` / `Decimal` | ✓ | — | +| `float`(含 TOML 字面 `100000.0`) | ✗ | exit 2 | +| `nan` / `+inf` / `-inf` | ✗ | exit 2 | + +这与 `BacktestConfig` 字段的严格度对齐,阻止浮点金额进入账本。 + +详见 [`docs/cli.md`](cli.md) §「错误信息」。 + +## 7. 手算回归 + +| 场景 | 测试位置 | +| --- | --- | +| 同批 SELL 先于 BUY + 滚动现金 | `tests/engine/test_matching.py` | +| BUY 整手向下、SELL 不整手、`order_target(0)` 清零股 | `tests/engine/test_intents.py` | +| `Fill.BUY` 拒绝非零 `stamp_tax` | `tests/domain/test_fill.py` | +| `realized_pnl` 不含费用 | `tests/engine/test_portfolio_realized_pnl.py` | +| CLI `initial_cash` 拒绝 float / nan / inf | `tests/cli/test_config.py` | diff --git a/docs/metrics.md b/docs/metrics.md new file mode 100644 index 0000000..419751f --- /dev/null +++ b/docs/metrics.md @@ -0,0 +1,90 @@ +# 净值与绩效指标口径 + +> 适用版本:v0.1。契约层级见 [`docs/design/mvp-contract.md`](design/mvp-contract.md) §3.5;输出文件 schema 见 [`docs/output.md`](output.md)。 + +## 1. 数据来源 + +`PerformanceMetrics` 不重新从 `total_equity` 推导日收益,而是**直接读 `EquityPoint.daily_return`**——这是引擎在日终写入的字段。这避免了「零种子」推导在首日真实 P&L 上的丢失。 + +```text +BacktestEngine._snapshot_equity(D) + └── 写入 EquityPoint { total_equity, daily_return, drawdown } + │ + ▼ +PerformanceMetrics.compute_metrics(equity_curve, initial_cash, MetricsConfig) + └── 直接消费 EquityPoint.daily_return 与 EquityPoint.drawdown +``` + +代码位置:`src/hqbacktest/engine/metrics.py`(顶部注释含完整公式)、`src/hqbacktest/engine/equity.py`。 + +## 2. 首日 P&L 进入曲线 + +`equity_curve[0]` 首日不再硬编码 `daily_return=0` / `drawdown=0`: + +| 字段 | 首日(D = start_date) | 后续日 | +| --- | --- | --- | +| `daily_return` | `total_equity[0] / initial_cash - 1` | `(total_equity[t] - total_equity[t-1]) / total_equity[t-1]` | +| `drawdown` | `(initial_cash - total_equity[0]) / initial_cash`(首日下跌时为正) | `(running_peak - total_equity[t]) / running_peak` | + +**running peak 序列**:`max(initial_cash, 历史 total_equity)`。首日跌幅进入回撤峰值序列,后续日的回撤以「比 initial_cash 与历史最高 equity 的最大值还低多少」衡量。 + +```text +running_peak[t] = max(initial_cash, total_equity[0], total_equity[1], ..., total_equity[t]) +drawdown[t] = (running_peak[t] - total_equity[t]) / running_peak[t] # ∈ [0, 1) +``` + +满足恒等式:`∏(1 + daily_return) == 1 + total_return`(Decimal 精度内)。 + +## 3. 波动率与夏普:样本不足返回 `None` + +| 指标 | 序列长度 < 2 | 真正 0 波动率 | 正常 | +| --- | --- | --- | --- | +| `daily_volatility` | `None` + note | `Decimal('0')` | `Decimal(str(stdev(returns, ddof=1)))` | +| `annualized_volatility` | `None` + note | `Decimal('0')` | `daily_volatility * sqrt(annual_trading_days)` | +| `sharpe_ratio` | `None` + note | `None` + note(零分母) | `(annualized_return - risk_free_rate) / annualized_volatility` | + +**禁止**把样本不足错报为 `Decimal('0')`。`None` 会在 `summary.json` 的 `metrics.notes` 字段留痕。 + +`annual_trading_days` 默认 252,`risk_free_rate` 默认 0,二者均走 `MetricsConfig` 写入 `summary.json`,可追溯。 + +## 4. 幂运算桥接 + +```text +(1 + total_return) ** (n / annual_trading_days) +``` + +的实现走 `Decimal(str(float(growth) ** float(exponent)))`,**禁止 `Decimal(float(...))`**。这避免了二进制浮点被直接继承到 Decimal 字段后污染 `summary.json`。 + +代码位置:`src/hqbacktest/engine/metrics.py` 中的 `_annualized_return_power_bridge`。 + +## 5. Decimal 量化 + +所有 `float` 桥接的 Decimal 输出统一 quantize 到 `Decimal('0.000000000001')`(1e-12 元),保证 `summary.json` 字段形态干净。真正从账本推导的字段(`total_return`、`realized_pnl` 等)保留 `quantize_cash`(0.01 元)粒度。 + +## 6. `positions.sellable_quantity` 结转后 + +`positions[date].sellable_quantity` 显示的是 **D 行快照的 D+1 起始时可卖数**——即 `settle_t1` 已结算、T+1 释放后的数量,而不是「D 当日还剩多少」。 + +```text +BAR_CLOSE(D): + broker.match() # 撮合,含 BUY(计入当日买入,T+1 才可卖) + portfolio.settle_t1(D) # 把 D 当日买入转结为 D+1 可卖 + snapshot(D + 1 起始) # positions[D].sellable_quantity = 已结转 +``` + +代码位置:`src/hqbacktest/engine/portfolio.py::settle_t1`。 + +## 7. 边界与错误 + +- 持仓标的在某日无有效收盘价 → 引擎运行失败并记录 `DATA_ERROR`(契约 §4:不使用前收、插值或静默按零估值)。 +- `risk_free_rate` / `annual_trading_days` / 样本不足的说明均落到 `summary.json.metrics.notes`。 + +## 8. 手算回归 + +| 场景 | 测试位置 | +| --- | --- | +| `daily_return` / `drawdown` 首日锚定 `initial_cash` | `tests/engine/test_metrics.py::test_first_day_daily_return_and_drawdown` | +| 恒等式 `∏(1+daily_return) == 1+total_return` | `tests/engine/test_metrics.py::test_identity_product_equals_one_plus_total_return` | +| 2 日 -9% / +5.5% → `daily_volatility` ≈ 0.10253 | `tests/engine/test_metrics.py::test_two_day_volatility_known_value` | +| 样本不足返回 `None`(非 0) | `tests/engine/test_metrics.py::test_insufficient_samples_returns_none` | +| `positions.sellable_quantity` 结转后口径 | `tests/engine/test_metrics.py::test_sellable_quantity_post_settle` | diff --git a/docs/output.md b/docs/output.md new file mode 100644 index 0000000..3df5cad --- /dev/null +++ b/docs/output.md @@ -0,0 +1,177 @@ +# BacktestResult 输出 + +> 适用版本:v0.1。本文档说明 `BacktestResult.save(dir)` 写出的文件 schema 与 `PerformanceMetrics` 字段。指标算法口径见 [`docs/metrics.md`](metrics.md),CLI 输出目录与命名见 [`docs/cli.md`](cli.md) §3。 + +## 1. 文件总览 + +```text +/ +├── config.toml # 你传入的原始 TOML(精确字节) +├── run_metadata.json # hqbacktest / Python / 平台 / 时间戳 / git commit +├── events.jsonl # 引擎事件日志(每行一条 JSON) +├── equity_curve.csv +├── orders.csv +├── fills.csv +├── positions.csv +├── costs.csv +└── summary.json +``` + +每个文件都可独立读出,互不重叠。 + +## 2. 表格 schema(CSV) + +### 2.1 `equity_curve.csv` + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `date` | `str` (YYYYMMDD) | 回测日 | +| `cash` | `Decimal` | 当日现金(含冻结) | +| `market_value` | `Decimal` | 持仓市值(按 D 有效 close 估值) | +| `total_equity` | `Decimal` | `cash + market_value` | +| `daily_return` | `Decimal` | 首日 `total_equity[0] / initial_cash - 1`;后续日 `(total_equity[t] - total_equity[t-1]) / total_equity[t-1]` | +| `drawdown` | `Decimal` | `(running_peak - total_equity[t]) / running_peak`,running_peak 序列以 `initial_cash` 为起点 | + +首日 P&L 进入曲线的细节见 [`docs/metrics.md`](metrics.md) §2。 + +### 2.2 `orders.csv` + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `order_id` | `str` | 引擎分配,UUID | +| `date` | `str` | 订单创建日 | +| `phase` | `str` | 创建所在阶段(`BEFORE_TRADING_START` / `BAR_CLOSE`) | +| `symbol` | `str` | `600000.SH` 等 | +| `side` | `str` | `BUY` / `SELL` | +| `quantity` | `int` | 已 BUY 整手向下取整;SELL 原值 | +| `type` | `str` | `MARKET`(v0.1 唯一) | +| `status` | `str` | `ACCEPTED` / `PENDING` / `FILLED` / `REJECTED` / `CANCELLED` | +| `avg_fill_price` | `Decimal` 或空 | 全部成交后的加权均价 | +| `filled_quantity` | `int` | 累计成交股数 | +| `fill_ids` | `str` | 逗号分隔的 `fill_id` 列表 | +| `reject_reason` | `str` 或空 | 拒绝原因枚举值 | + +`OUT_OF_UNIVERSE` 拒绝也会出现在这里(§「策略隔离」)。 + +### 2.3 `fills.csv` + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `fill_id` | `str` | 引擎分配 | +| `date` | `str` | 成交日 | +| `order_id` | `str` | 关联订单 | +| `symbol` | `str` | | +| `side` | `str` | `BUY` / `SELL` | +| `price` | `Decimal` | 成交价(市价单按当日 open) | +| `quantity` | `int` | 成交股数 | +| `amount` | `Decimal` | `price * quantity` | +| `commission` | `Decimal` | 佣金(含 min_commission 保底) | +| `stamp_tax` | `Decimal` | 仅 SELL;BUY 必须为 0 | +| `transfer_fee` | `Decimal` | 默认 0 | +| `net_amount` | `Decimal` | BUY 正成本 = amount + commission + transfer_fee;SELL 净回款 = amount - commission - stamp_tax - transfer_fee | + +### 2.4 `positions.csv` + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `date` | `str` | 快照日 | +| `symbol` | `str` | | +| `quantity` | `int` | 当日收盘后的余额 | +| `sellable_quantity` | `int` | **D+1 起始时**的可卖数 = 当日结转后数 | +| `avg_cost` | `Decimal` | 滚动加权平均成本 | +| `close_price` | `Decimal` | D 日收盘价(无有效 close → DATA_ERROR) | +| `market_value` | `Decimal` | `quantity * close_price` | + +`sellable_quantity` 结转后口径见 [`docs/metrics.md`](metrics.md) §6。 + +### 2.5 `costs.csv` + +| 列 | 类型 | 说明 | +| --- | --- | --- | +| `fill_id` | `str` | 关联成交 | +| `commission` | `Decimal` | | +| `stamp_tax` | `Decimal` | | +| `transfer_fee` | `Decimal` | | +| `total_cost` | `Decimal` | `commission + stamp_tax + transfer_fee` | + +`summary.json` 内的 costs 总和应与 `costs.csv` `total_cost` 之和一致。 + +## 3. `summary.json` + +```jsonc +{ + "config_snapshot": { ... }, // 解析后的 BacktestConfig + "trading_days": ["20240102", ...], // [start_date, end_date] 实际命中的交易日 + "adjustment_policy": "none", + "data_version": { + "source": "tushare", + "data_root": "~/.hqdata", + "calendar_first": "20100101", + "calendar_last": "20260815" + }, + "metrics": { ... }, // PerformanceMetrics 序列化 + "factor_diagnostics": [ ... ], // 见 docs/factor-diagnostics.md + "notes": [ ... ] // 边界说明(样本不足 / None 字段等) +} +``` + +`config_snapshot` 字段固定(不包含 `rule_set` 等运行时对象),保证跨运行字节稳定。 + +## 4. PerformanceMetrics 字段 + +| 字段 | 类型 | 公式 / 来源 | +| --- | --- | --- | +| `total_return` | `Decimal` | `(final_equity / initial_cash) - 1` | +| `daily_volatility` | `Decimal \| None` | 样本标准差(ddof=1);样本不足返回 `None` | +| `annualized_volatility` | `Decimal \| None` | `daily_volatility * sqrt(annual_trading_days)` | +| `annualized_return` | `Decimal \| None` | `(1 + total_return) ** (N / annual_trading_days) - 1`,N < 2 → `None` | +| `sharpe_ratio` | `Decimal \| None` | `(annualized_return - risk_free_rate) / annualized_volatility`,零波动 → `None` | +| `max_drawdown` | `Decimal` | `max(peak - current) / peak`(来自 `equity_curve.drawdown`) | +| `turnover` | `Decimal` | `(sum(BUY 成交额) + sum(SELL 成交额)) / 2 / initial_cash` | +| `trade_count` | `int` | `len(fills)` | +| `win_rate` | `Decimal \| None` | `SELL 且 fill.price > 当时平均成本` / `SELL 总数`,无 SELL → `None` | +| `risk_free_rate` | `Decimal` | 从 `MetricsConfig`,默认 0 | +| `annual_trading_days` | `int` | 从 `MetricsConfig`,默认 252 | + +`notes` 数组包含边界说明,例如: + +```text +[ + "daily_volatility: insufficient samples (< 2 daily returns) — returned None", + "sharpe_ratio: zero volatility — returned None", + "win_rate: no SELL fills — returned None" +] +``` + +## 5. 事件日志(`events.jsonl`) + +每行一条 JSON 事件,常见形态: + +```json +{"timestamp": "2026-08-25T03:11:42Z", "phase": "BEFORE_TRADING_START", "date": "20240102", + "type": "ORDER_CREATED", "order_id": "...", "symbol": "600000.SH", "side": "BUY", "quantity": 100} +``` + +`type` 取值(不穷举):`SESSION_START`, `ORDER_CREATED`, `ORDER_REJECTED`, `ORDER_FILLED`, `BACKTEST_ENDED`, `DATA_WARNING`, `DATA_ERROR`, `ORDER_CANCELLED`, `BAR_CLOSE`, `AFTER_TRADING_END`。 + +完整字段约定见契约 [`docs/design/mvp-contract.md`](design/mvp-contract.md) 规则 10。 + +## 6. 字节稳定性 + +- 输入 + 数据 + `data_root` + `python_version`(主要版本)+ `hqbacktest_version` 一致时,下列文件字节相同: + - `events.jsonl` + - 五个 CSV + - `summary.json`(去掉 `rule_set`) +- 唯一允许不一致:`run_metadata.json.timestamp_utc`。 +- 浮点 Decimal 量化统一到 `Decimal('0.000000000001')`,防止二进制浮点跨平台漂移。 + +## 7. 工具函数 + +```python +from hqbacktest import BacktestResult + +result.save("results/run-1") # 写出全部 +restored = BacktestResult.load("results/run-1") # 从目录重建 BacktestResult +``` + +`load` 解析全部 CSV + JSON,重新构造 `equity_curve` / `orders_table` / `fills_table` / `positions_table` / `costs_table` / `PerformanceMetrics`。`run_metadata.json` 不参与业务重建(仅审计)。 diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..2c8f7cf --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,81 @@ +# 性能与内存 + +> 适用版本:v0.1。本文档说明 `HqDataCsvPortal` 在单次回测中的缓存策略、内存量级与实测基准。 + +## 1. 双层缓存 + +`HqDataCsvPortal` 在单次回测中按「按日文件缓存 + 按 symbol 累积序列」两层缓存: + +```text +get_bars / get_factor + │ + ▼ +_per_day_cache: dict[date, dict[symbol, Bar|Factor]] # 单次 run 内每天每文件解析一次 + │ + ▼ +_per_symbol_series: dict[symbol, list[Bar]] # 累积序列,二分查找 +``` + +### 1.1 按日文件缓存 + +- 每个 `stock_daily/{D}.csv` / `stock_factor/{D}.csv` 在一次运行中最多解析一次。 +- 解析结果以 `{date: {symbol: Bar}}` / `{date: {symbol: Factor}}` 形式缓存。 +- 跨 symbol 的同一天 CSV 只读取一次(按需 lazy-load)。 + +### 1.2 按 symbol 累积序列 + +- 每个 symbol 在内存中维护一个按日期升序的累积序列。 +- `get_bars` / `get_factor` 在该序列上做 `bisect` 切片,单次调用 **O(log N)**。 +- `DataView.history` 走同一累积缓存,单次 `get_bars` 切片即可。 + +### 1.3 避开旧实现的热点 + +旧实现的两个热点在 v0.1 已规避: + +- **逐日 `get_bars(day, day)` 往返** → 由 `current_price(symbol)` 单次 `get_calendar`(确定 20 日回看起点)+ 一次 `get_bars` 共同覆盖。 +- **Bar 重复构造** → `Bar` / `Factor` 对象在重叠窗口间复用,仅返回列表的防御性拷贝。 + +代码位置:`src/hqbacktest/data/csv_portal.py`、`src/hqbacktest/data/view.py`。 + +## 2. 内存量级 + +| 组成 | 单实例大小 | 估算 | +| --- | --- | --- | +| `Bar`(dataclass) | ~200 B | 量级来自属性数 + Decimal 引用 | +| `Factor`(Decimal) | ~80 B | 单字段,Decimal 字符串存储 | +| 全市场累积(5000 symbols × 139 days) | — | 70 万 Bar ≈ 140 MB | + +`_symbol_bars` 累积**只**对真实访问过的 symbol 增长——策略触及的 universe 通常远小于全市场,因此普遍远低于 140 MB。 + +## 3. 真实数据基准 + +数据集:`~/.hqdata/tushare`,区间 20260105–20260731,139 个交易日,每个 daily 文件约 5000 行。 + +| 场景 | 总耗时(含首次数据加载) | 目标 | +| --- | --- | :---: | +| 5 stocks × 139 days MA 策略 | ~7.6 s | < 10 s ✅ | +| 300 stocks × 139 days MA 策略 | ~9.4 s | < 120 s ✅ | + +环境:标准 CI(4 vCPU / 8 GiB),冷启动加载所有 daily + factor 文件。结果含 **首次数据加载**,不区分冷热。 + +代码位置:基准运行入口在 `tests/data/test_performance.py::test_real_data_benchmark`(需 `~/.hqdata/tushare` 可读否则 skip)。 + +## 4. 性能冒烟测试 + +`tests/data/test_task15_performance.py`: + +- 50 symbols × 250 days 全量 `history(bar_count=20)` 在 15 秒阈值内完成。 +- 跑 50 组合(不同 universe / 不同窗口长度),确认累积缓存 + bisect 没有退化为线性扫描。 + +## 5. 调优建议 + +- 尽量在 `initialize` 中通过 `set_universe` 限定 symbols——`_symbol_bars` 只对触及的 symbol 累积。 +- `history` 单次取够窗口长度(如 20 / 60 / 120),避免多次 5 日短期窗口来回 dispatch。 +- 避免在策略内保存 `Bar` 列表到 self.*——直接依赖 `data.history()` 的返回值,让累积缓存复用。 +- 大区间回测(>10000 日)+ 全 universe(5000 symbols)跑生产数据时考虑 `data_root` 在 SSD 上;CSV 解析是单线程 IO 瓶颈。 + +## 6. 不属于性能范围 + +- **网络数据获取**:`hqbacktest` 不调用任何数据源 SDK、不联网;CSV 必须是 `hqdata` CLI 预落盘的。 +- **上市公司重计算**:因子序列的全市场预计算由 `hqdata` 完成,不在回测运行时发生。 +- **多进程 / NUMA 优化**:v0.1 单线程;现测基准显示单线程 IO 已足够,不引入并行复杂度。 diff --git a/docs/strategy-api.md b/docs/strategy-api.md new file mode 100644 index 0000000..60cac57 --- /dev/null +++ b/docs/strategy-api.md @@ -0,0 +1,134 @@ +# 策略 API + +> 适用版本:v0.1。契约层级见 [`docs/design/mvp-contract.md`](design/mvp-contract.md) §4「日交易日事件顺序」、规则 1「无未来函数」、规则 11「Context 只读不可改写」。 + +## 1. 生命周期回调 + +| 回调 | 能看到的数据 | 市价单最早成交时间 | 适合的工作 | +| --- | --- | --- | --- | +| `initialize(context)` | 无逐日行情(引擎强制读取即报错) | 不可下单(引擎强制) | 设置固定参数与初始 universe(`context.set_universe([...])`) | +| `before_trading_start(context, data)` | 前一交易日及以前 | 当日开盘 | 根据已知历史生成开盘订单 | +| `on_bar(context, data)` | 当日收盘后包含当天日线 | 下一交易日开盘 | 计算收盘信号并提交次日订单 | +| `after_trading_end(context)` | 当日完成后的账户快照 | 不可下单 | 记录、检查、自定义日志 | + +- `on_bar` / `before_trading_start` 必须由策略**显式实现**或**显式继承默认空实现**——引擎不会自动注入任何默认值。 +- `initialize` 中调用 `data.history()` / `data.current_price()` 必须抛错:盘前无任何逐日行情可读。 +- `initialize` 中调用 `context.order(...)` 必须抛错:避免在未观察到任何数据时下单。 +- 阶段顺序由 [`docs/design/mvp-contract.md`](design/mvp-contract.md) §4 锁定,**引擎不可重排或合并阶段**。 + +## 2. 时序图 + +```text + D-1 收盘 D 开盘 D 收盘 + │ │ │ + └──── BAR_CLOSE(D-1) ───┘ │ + │ │ + ▼ │ + BEFORE_TRADING_START(D) ◀── 策略看到 D-1 ──┘ + │ + ▼ + 提交订单(当日开盘撮合候选) + │ + ▼ + OPEN_MATCH(D) ─── 开盘价全额成交 + │ + ▼ + BAR_CLOSE(D) ─── 策略看到 D 收盘 + │ + ▼ + 提交订单(最早 D+1 开盘撮合) + │ + ▼ + AFTER_TRADING_END(D) ─── 日终估值与回调 +``` + +## 3. `Context` API + +### 3.1 只读查询 + +| 方法 | 返回 | 说明 | +| --- | --- | --- | +| `cash` | `Decimal` | 当前现金(不含当日冻结待扣) | +| `positions` | `dict[symbol, Position]` | 持仓快照(D+1 起始可卖数) | +| `total_equity` | `Decimal` | 现金 + 持仓市值 | +| `universe` | `list[str]` | `set_universe(...)` 声明的股票池;未设时返回空列表 | +| `historical_universe` | `list[str]` | `visible_through` 当日的 portal 股票池(默认排除 `.BJ`),受可见性约束;不暴露原 portal | +| `pending_orders()` | `list[Order]` | 所有未终止订单的副本(含 `PENDING` / `ACCEPTED`) | +| `current_price(symbol)` | `Decimal \| None` | 截至 `visible_through` 的最近有效收盘价;首日哨兵返回 `None`,无价返回 `None` | + +### 3.2 下单意图 + +| 方法 | 签名 | 行为 | +| --- | --- | --- | +| `order(symbol, quantity)` | `quantity` 整数;正买负卖 | 按数量下单;BUY 整手向下、SELL 原值 | +| `order_value(symbol, value)` | `value` 接受 int / str 金额;float 拒绝 | 按金额下单,自动换算股数(整手约束) | +| `order_target(symbol, quantity)` | `quantity` 整数 | 调整到目标持仓;`0` 必须清仓 | +| `order_target_value(symbol, value)` | 同上 | 调整到目标金额 | +| `order_target_percent(symbol, pct)` | `pct` Decimal 占比 | 调整到目标百分比 | +| `cancel_order(order_id)` | 订单 ID | 撤销 `PENDING` / `ACCEPTED` 订单;已成交失败 | + +代码位置:`src/hqbacktest/engine/context.py`。 + +### 3.3 不可改写 + +- 任何对 `context._portfolio` / `context._broker` 等私有字段的访问必须抛错。 +- `Context` 是接口,`__setattr__` 对外暴露属性受校验。 +- 见 [`docs/isolation.md`](isolation.md) §6。 + +## 4. `DataView` 可见性矩阵 + +`DataView` 在每次策略调用前由引擎设置 `visible_through`: + +| 回调 | `visible_through` | `history()` 范围 | `current_price()` | 越界行为 | +| --- | --- | --- | --- | --- | +| `initialize` | 无(无逐日行情可读) | 调用即报错 | 调用即报错 | 抛 `DataViewError` | +| `before_trading_start(D)` | `D - 1` | `[..., D-1]` | 截至 D-1 最近有效 close | 越界抛错 | +| `on_bar(D)` | `D` | `[..., D]`,含 D 日线 | 截至 D 最近有效 close | 越界抛错 | +| `after_trading_end(D)` | `D` | `[..., D]` | 截至 D 最近有效 close | 越界抛错 | +| 首个交易日盘前 | `"00000000"` 哨兵 | `[]` | `None`(不抛异常) | — | + +**任何未来数据访问必须抛错**,不得返回空值、最后已知值或插值结果。代码位置:`src/hqbacktest/data/view.py::history / current_price / universe`。 + +## 5. 示例:最小策略 + +```python +from decimal import Decimal +from hqbacktest import BaseStrategy + + +class MovingAverageStrategy(BaseStrategy): + def initialize(self, context): + context.set_universe(["600000.SH"]) + + def before_trading_start(self, context, data): + closes = data.history("600000.SH", field="close", bar_count=5) + if len(closes) < 5: + return + avg = sum(closes) / len(closes) + + def on_bar(self, context, data): + closes = data.history("600000.SH", field="close", bar_count=5) + if len(closes) < 5: + return + avg = sum(closes) / len(closes) + if closes[-1] > avg: + context.order_target_percent("600000.SH", Decimal("0.95")) + else: + context.order_target("600000.SH", 0) + + def after_trading_end(self, context): + pass +``` + +完整端到端:`examples/buy_and_hold.py`、`examples/moving_average.py`。 + +## 6. 生命周期测试 + +| 测试 | 文件 | +| --- | --- | +| 时序 + 数据可见性矩阵 | `tests/engine/test_lifecycle.py` | +| `initialize` 中调用 `data.history` 抛错 | `tests/engine/test_lifecycle.py::test_initialize_blocks_history` | +| `initialize` 中调用 `context.order` 抛错 | `tests/engine/test_lifecycle.py::test_initialize_blocks_order` | +| `on_bar` 在 `BAR_CLOSE` 后看到 D 日线 | `tests/engine/test_lifecycle.py::test_on_bar_sees_day_data` | +| `after_trading_end` 不可下单 | `tests/engine/test_lifecycle.py::test_after_trading_end_blocks_order` | +| 首日哨兵 `visible_through="00000000"` 不抛异常 | `tests/data/test_sentinel_first_day.py` |