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` |