diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09c0cc2..be1c868 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,152 @@ All notable changes to `hqbacktest` are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project adheres to [Semantic Versioning](https://semver.org/).
+## [0.1.4] - 2026-08-25
+
+Documentation-and-test-coverage hotfix for v0.1.3 (review findings
+addressed as task 24 in [TODO.md](./TODO.md)). All changes are
+backward-compatible unless called out below. No public API
+changes; this release closes documentation/test gaps and aligns
+comments with Python language semantics.
+
+### Fixed
+- **`get_factor` parity (task 24.1).**
+ `tests/data/test_portal_parity.py` previously asserted only that
+ the memory portal rejected zero-valued factors; the CSV portal's
+ equivalent behaviour lived in a separate file, and no test ever
+ ran the same fixture through both portals to assert identical
+ `(date, factor)` tuples. Six new parity tests now cover the full
+ `get_factor` contract: window returns identical series, per-symbol
+ factor gap matches, empty-when-never-listed matches, window
+ `start > end` raises `InvalidDataError` in both, bad symbol
+ rejected in both, and `SnapshotFileMissingError` vs per-symbol gap
+ remains distinguishable. The memory fixture now carries factor
+ rows that mirror the new CSV fixture (600000.SH on every trading
+ day, 000001.SZ on 20240102 and 20240105 only).
+- **`get_bar(symbol, date)` reference removed (task 24.2).**
+ `docs/design/mvp-contract.md` §3.3 referenced a `get_bar(symbol,
+ date)` single-point query method that has never existed on
+ `MarketDataPortal` (only `get_bars(symbol, start, end)` and
+ `current_price(symbol)`). The failure classification rows for
+ "individual-day missing vs whole-day snapshot missing" are real
+ and correct, but belong to `get_bars` / `current_price` — they
+ have been merged into a single `get_bars` / `get_factor` failure
+ classification row that explicitly notes the protocol has no
+ `get_bar` method.
+- **`DataView.portal` privacy wording (task 24.3).**
+ Both `data_view.py` (class docstring + `__init__` comment) and
+ `mvp-contract.md` §3.6 used phrasing that implied the
+ `_portal` underscore was a language-level guarantee ("strategies
+ cannot reach the raw portal"). This was inaccurate — Python has
+ no language-level private attributes; a sufficiently determined
+ caller can still reach `_portal` directly. The comments now
+ describe the leading-underscore as a strong social convention and
+ acknowledge the Python limitation honestly, while keeping the
+ `AttributeError` on the public name and the recommendation to go
+ through `history` / `current_price` / `universe`.
+
+### Added (test infrastructure)
+- **`tests/data/test_portal_parity.py`:** 6 new parity tests for
+ `get_factor` (see above). Total parity coverage now spans calendar,
+ universe, bars, **and** factor — no "API claimed parity but missing"
+ gap.
+
+### Internal cleanup (closing v0.1.4's "v0.1.4 之后可考虑" item)
+- **Sentinel constant convergence.** The `"00000000"` "no history"
+ sentinel was previously defined as three differently-named constants
+ in three places (`data.data_view.NO_HISTORY_SENTINEL`,
+ `data.validators.SENTINEL_NO_HISTORY`,
+ `engine.scheduler.NO_HISTORY_VISIBLE_THROUGH`). They are now
+ collapsed to a single definition (`data.validators.SENTINEL_NO_HISTORY`)
+ re-exported through `hqbacktest.data.SENTINEL_NO_HISTORY`. The other
+ two call sites import that name directly. Single source of truth;
+ no behavioral change.
+- **`test_version_matches_pyproject` hardening.** Beyond the
+ byte-equality check, the test now also guards two release-time footguns:
+ (a) `__version__` / `pyproject.toml [project].version` must not have
+ stray whitespace; (b) both values must match the `N.N[.N...]` semver
+ shape (rejects e.g. `"v0.1.4"` or an empty placeholder). Reverse
+ validation confirmed the test fails on `" "` (whitespace) and on
+ `"v0.1.4"` (semver shape) before the next release.
+
+## [0.1.3] - 2026-08-25
+
+Correctness hotfix for v0.1.2 (review findings addressed as task 23
+in [TODO.md](./TODO.md)). All changes are backward-compatible
+unless called out below.
+
+### Fixed
+- **Volatility / Sharpe now see the first trading day (task 23).**
+ `metrics.compute_metrics` previously re-derived the daily-return
+ series from `EquityPoint.total_equity` with `[Decimal("0")]` as
+ the day-0 seed; the day-0 return was therefore **silently
+ dropped** before reaching `stdev`. A 2-day backtest with one real
+ day-1 return therefore reported `daily_volatility=None` even
+ though `max_drawdown` saw the day-0 loss correctly — a "drawdown
+ sees it, volatility doesn't" discrepancy. The fix reads
+ `EquityPoint.daily_return` directly (the same value the engine
+ already anchored to `initial_cash`, task 17), so the volatility,
+ annualised volatility and Sharpe ratio now agree with
+ `max_drawdown` on what counts as a "first day". The dead-code
+ helper `_drawdown_series` (unused since task 17 wired
+ `max_drawdown` straight from `EquityPoint.drawdown`) was also
+ deleted so the same zero-seed defect cannot reappear via a
+ future accidental caller. The pre-fix regression test
+ `test_two_day_volatility_is_none_when_only_one_return` was
+ removed: it asserted `None` based on the bug. A new
+ hand-calculated regression test (`test_two_day_volatility_uses_both_daily_returns`)
+ pins the corrected behaviour (2 days at -9% / +5.5% ->
+ `daily_volatility` ≈ 0.10253).
+
+## [0.1.2] - 2026-08-25
+
+Documentation-and-correctness hotfix for v0.1.1 (review findings
+addressed as task 22 in [TODO.md](./TODO.md)). All changes are
+backward-compatible unless called out below.
+
+### Fixed
+- **`source` accepts absolute paths (task 22.1).**
+ `resolve_source_location` now splits an absolute path
+ (e.g. `~/.hqdata/tushare`) into `(parent_dir, basename)` and pairs
+ the bare-name form (`"tushare"`) with `[data].data_root` as before.
+ Relative paths like `"foo/bar"` are still rejected to avoid
+ cwd-relative ambiguity. Behavior of the bare-name form is
+ unchanged.
+- **`run_metadata.json` no longer leaks absolute local paths
+ (task 22.2).** `config_path`, `output_directory`, and
+ `config_output_directory` are written as paths relative to the
+ run-time cwd (via `os.path.relpath`). Token / env-var / absolute-
+ path leak coverage in `test_runner_does_not_write_secrets` was
+ extended to assert these field-level invariants; the previous
+ assertion only scanned for an explicit token string.
+- **Impossible calendar dates are now rejected by
+ `validate_yyyymmdd` (task 22.3).** 8-digit strings like `20241399`
+ (month 13), `20240230` (Feb 30), `20240132` (Jan 32), `20230229`
+ (Feb 29 in a non-leap year) are now caught by the validator using
+ `datetime.strptime`. The sentinel `"00000000"` is preserved for
+ the first-trading-day case. The prior regression test
+ `test_start_date_impossible_rejected` set `end_date` to a
+ lex-smaller string so it triggered the `start > end` ordering
+ check, not the impossible-date check — that test was a false
+ positive and has been rewritten to use a lex-greater real date so
+ it exercises the intended failure mode. As a side-effect, the
+ task-15 perf fixture generator (which used an integer counter whose
+ `d % 100 == 32` skip only caught the day-32 rollover after a 31-day
+ month, so it emitted impossible dates like `20240230`) was rewritten
+ to iterate via `datetime.timedelta`.
+- **Documentation honesty (task 22.4):**
+ `pyproject.toml:26` no longer claims "no runtime deps yet" — the
+ line was adjacent to the already-declared `pandas` / `tomli`
+ dependencies. The CLI test previously named
+ `test_console_script_runs_end_to_end` but actually ran via
+ `python -m hqbacktest` (its own docstring admitted this); the
+ rename to `test_python_m_runs_end_to_end` makes the contract
+ obvious, and a new sibling `test_console_script_runs_end_to_end`
+ invokes the installed `hqbacktest` console-script binary (skipping
+ gracefully on machines where `pip install -e .` has not been run).
+ README's "26 项 CLI 测试" call-out was removed in favour of a
+ reference to `tests/cli/`, so the count never goes stale.
+
## [0.1.1] - 2026-08-25
Patch release that hardens `hqbacktest` against the v0.1 real-data
@@ -47,8 +193,8 @@ backward-compatible unless called out below.
`@dataclass(frozen=True)` with `fill_ids: tuple[str, ...]`; strategies
cannot mutate Order objects returned from `Context.pending_orders()`.
`DataView.portal` is now a private `_portal` field; strategies cannot
- bypass `visible_through`. `set_universe(...)` enforces trading scope;
- orders outside the universe are rejected with
+ reach it to read future data. `set_universe(...)` enforces trading
+ scope; orders outside the universe are rejected with
`RejectReason.OUT_OF_UNIVERSE`. New `Context.historical_universe()`
returns the historical stock list through the guarded data view.
- **Factor diagnostics on holdings (task 19):** the engine runs
@@ -66,8 +212,11 @@ backward-compatible unless called out below.
directory and the current working directory to `sys.path` so the
strategy module can be resolved by name alone (matching
`python -m hqbacktest run`). Config validation rejects `nan` /
- `inf` / float `initial_cash`, impossible calendar dates, and
- empty trading-day windows with single-line `ConfigError` (CLI exit 2).
+ `inf` / float `initial_cash` and empty trading-day windows with
+ single-line `ConfigError` (CLI exit 2). Impossible calendar dates
+ such as `20241399` or `20240230` are now rejected by
+ `validate_yyyymmdd` itself, not just by the `start > end` ordering
+ check (task 22.3).
Output directories that already contain prior-run files are rejected
with exit 3; `--force` overrides. `Context.order_value` accepts
`int` / `str` cash amounts. `run_metadata.json`'s `git_commit` now
diff --git a/README.md b/README.md
index e8e9413..26982e2 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,19 @@
# hqbacktest - A股量化策略回测与交易模拟引擎
-
+
## 项目状态
-`hqbacktest` 当前发布 **`v0.1.1`**:
+`hqbacktest` 当前发布 **`v0.1.4`**:
+- **v0.1.4 hotfix(任务 24):** 数据层 parity 测试补齐——`tests/data/test_portal_parity.py` 新增 6 项 `get_factor` 双门户逐值一致性断言(窗口返回序列、个股稀疏因子、空集、`start>end` 校验、坏 symbol、`SnapshotFileMissingError` vs 个股缺行),让任务 14「逐 API 断言返回值一致」名副其实;`docs/design/mvp-contract.md` §3.3 删除不存在的 `get_bar(symbol, date)` 引用,将「个股当日缺行 vs 整日快照文件缺失」失败分类合并到 `get_bars` / `get_factor` 一行并明确 `MarketDataPortal` 不存在单点接口;`data_view.py` 与 mvp-contract.md §3.6 中 `DataView.portal` 隐私措辞改为准确表述(下划线是约定私有,**不是** Python语言级强制力——`view._portal` 仍可被触及,这是语言限制非项目缺陷,靠约定 + 审计测试守住)。同段合并清理:哨兵常量 `"00000000"` 三处定义(`NO_HISTORY_SENTINEL` / `SENTINEL_NO_HISTORY` / `NO_HISTORY_VISIBLE_THROUGH`)收敛到 `data.validators.SENTINEL_NO_HISTORY` 一处,从 `hqbacktest.data` 包导出;`test_version_matches_pyproject` 强化版本号形态校验(拒绝空白与 `v` 前缀)。
+- **v0.1.3 hotfix(任务 23):** 波动率 / Sharpe 与 `max_drawdown` 对首日盈亏的可见性现在一致——`metrics.compute_metrics` 不再从 `total_equity` 重新推导日收益(旧的零种子会让首日真实收益被丢,2 日回测的 `daily_volatility` 误报 `None`),改为直接读 `EquityPoint.daily_return`;删除死代码 `_drawdown_series`(自任务 17 后无调用点);新增手算回归测试(2 日 -9% / +5.5%,`daily_volatility` ≈ 0.10253)。
+- **v0.1.2 hotfix(任务 22):** `source` 现在支持绝对路径(拆为 `data_root` + 名称,相对路径仍被拒绝以避免 cwd 模糊);`run_metadata.json` 的 `config_path` / `output_directory` / `config_output_directory` 写入相对 cwd 的相对路径(`os.path.relpath`),本地绝对路径不再泄露;`validate_yyyymmdd` 用 `datetime.strptime` 校验假日期(如 `20241399` / `20240230`),保留 `"00000000"` 首日哨兵;任务 15 性能夹具生成器改用 `datetime.timedelta` 迭代,修复了整数计数器 `d % 100 == 32` 仅能识别 31 天月的日 32 回滚、遗漏短月日期(`20240230`/`20240231`/`20240431`/`20240631`)而混入假日期的隐藏 bug;CLI 测试中 `test_console_script_runs_end_to_end` 改名 `test_python_m_runs_end_to_end` 并补一个真正测 console script 的同名测试;README「26 项 CLI 测试」改为「见 `tests/cli/`」;`pyproject.toml` 删除过时「no runtime deps yet」注释。任务 24 排入下个迭代。
- **v0.1.1 新增(任务 14–21):** 数据层缺行/停牌/首日语义修复(任务 14);按日文件缓存 + 按 symbol 累积序列 + bisect 切片,性能从「小时级」降到「秒级」(任务 15);同批撮合 SELL→BUY 滚动现金 + SELL 零股可卖 + 钉死 T+1/realized_pnl/ROUND_HALF_EVEN/撮合顺序(任务 16);首日 P&L 进入收益曲线 + running peak 含 `initial_cash` + 波动率样本不足返回 `None` + 恒等式 ∏(1+r)=1+total_return(任务 17);`Order` 不可变 + `DataView.portal` 私有 + universe 生效(任务 18);持仓期间因子跳变自动诊断 + CLI 汇总警告 + 账本零影响(任务 19);console script 策略模块导入 + nan/inf/空窗口校验 + 输出目录防护 + `order_value` 接受 int/str + 文档一致性(任务 20);`tests/integration/` 真实数据冒烟基线(任务 21)。
-- **v0.1 已实现(任务 1–13):** 产品契约、可安装的 Python 包、领域模型(订单、成交、持仓、账本、快照)、订单状态机、`AdjustmentPolicy` 枚举、`CorporateAction` 数据结构草案、`Decimal` 精度与 JSON 序列化;`MarketDataPortal`、`HqDataCsvPortal`(CSV 快照门户)、`InMemoryDataPortal`、`DataView`、内存缓存和无未来函数校验;日频事件时钟、`BacktestEngine`、五阶段调度(`SESSION_START → BEFORE_TRADING_START → OPEN_MATCH → BAR_CLOSE → AFTER_TRADING_END`)、按阶段的数据可见性切换和可追溯事件日志;`BaseStrategy` 生命周期与受控 `Context` API、下单意图;`SimulatedBroker`(`OPEN_MATCH` 阶段按当日 `bar.open` 全额成交市价单);`TradingRuleSet`(`LongOnly` / `LotSize` / `NonTradingDay` / `InvalidPrice` / `InsufficientCash` / `T1Sellable` 六条默认规则)和 `CostModel`(默认 A 股费率:0.025% 佣金 + 5 元保底 + 0.1% 卖出印花税,0 过户费);账本拒绝原因(`INSUFFICIENT_CASH` / `INSUFFICIENT_SHARES`)和 T+1 日终结算;末交易日 `BACKTEST_ENDED` 自动撤销。`BacktestConfig.adjustment_policy` 严格只接受 `"none"`。`BacktestResult` 含 `equity_curve` / `orders_table` / `fills_table` / `positions_table` / `costs_table` / `PerformanceMetrics` / `events.jsonl` / `data_version` / `factor_diagnostics`;`save(dir)` / `load(dir)` 导出 CSV+JSON 并可重建。`examples/buy_and_hold.py` 与 `examples/moving_average.py` 用公共 API 跑通端到端流程并有 7 天确定性 `InMemoryDataPortal` 数据 fixture。`hqbacktest run --config FILE --output DIR` 命令行(`hqbacktest/cli/` 包,TOML 配置 + 校验 + 策略导入 + 元数据 + 独立输出目录,绝不泄露凭证)。`.github/workflows/ci.yml` 覆盖 Python 3.10 / 3.11 / 3.12、`black`、`pytest`、`pytest-cov`、示例 smoke 与 CLI smoke;`python -m build` 产出 sdist + wheel;`CHANGELOG.md` 记录 v0.1 与 v0.1.1。
+- **v0.1 已实现(任务 1–13):** 产品契约、可安装的 Python 包、领域模型(订单、成交、持仓、账本、快照)、订单状态机、`AdjustmentPolicy` 枚举、`CorporateAction` 数据结构草案、`Decimal` 精度与 JSON 序列化;`MarketDataPortal`、`HqDataCsvPortal`(CSV 快照门户)、`InMemoryDataPortal`、`DataView`、内存缓存和无未来函数校验;日频事件时钟、`BacktestEngine`、五阶段调度(`SESSION_START → BEFORE_TRADING_START → OPEN_MATCH → BAR_CLOSE → AFTER_TRADING_END`)、按阶段的数据可见性切换和可追溯事件日志;`BaseStrategy` 生命周期与受控 `Context` API、下单意图;`SimulatedBroker`(`OPEN_MATCH` 阶段按当日 `bar.open` 全额成交市价单);`TradingRuleSet`(`LongOnly` / `LotSize` / `NonTradingDay` / `InvalidPrice` / `InsufficientCash` / `T1Sellable` 六条默认规则)和 `CostModel`(默认 A 股费率:0.025% 佣金 + 5 元保底 + 0.1% 卖出印花税,0 过户费);账本拒绝原因(`INSUFFICIENT_CASH` / `INSUFFICIENT_SHARES`)和 T+1 日终结算;末交易日 `BACKTEST_ENDED` 自动撤销。`BacktestConfig.adjustment_policy` 严格只接受 `"none"`。`BacktestResult` 含 `equity_curve` / `orders_table` / `fills_table` / `positions_table` / `costs_table` / `PerformanceMetrics` / `events.jsonl` / `data_version` / `factor_diagnostics`;`save(dir)` / `load(dir)` 导出 CSV+JSON 并可重建。`examples/buy_and_hold.py` 与 `examples/moving_average.py` 用公共 API 跑通端到端流程并有 7 天确定性 `InMemoryDataPortal` 数据 fixture。`hqbacktest run --config FILE --output DIR` 命令行(`hqbacktest/cli/` 包,TOML 配置 + 校验 + 策略导入 + 元数据 + 独立输出目录,绝不泄露凭证)。`.github/workflows/ci.yml` 覆盖 Python 3.10 / 3.11 / 3.12、`black`、`pytest`、`pytest-cov`、示例 smoke 与 CLI smoke;`python -m build` 产出 sdist + wheel;`CHANGELOG.md` 记录 v0.1 / v0.1.1 / v0.1.2 / v0.1.3 / v0.1.4。
> **⚠️ v0.1.1 仍未做:** 分红会计 / 涨跌停 / 新股 / 北交所 / 限价单 / 指数基准 / 多账户 / 分钟线 / 实盘对接 —— 见 [CHANGELOG.md](CHANGELOG.md) 与 [TODO.md](TODO.md) 「发布后再排期的增强项」。`adjustment_policy=none` 下跨除权日的净值仍**系统性低估**(少分红现金),任务 19 因子诊断会显式记录此类跳变,但长区间结果不可直接用于收益评估。
@@ -48,7 +51,7 @@
| 公司行为扩展 | `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 字节稳定;26 项 CLI 测试覆盖端到端、配置验证、可复现性与错误信息;不写入 token / 完整环境变量 / 本地绝对路径 | 已实现 |
+| 配置与命令行 | `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 的相对路径(任务 22.2) | 已实现 |
## 首个可用版本的范围
@@ -177,8 +180,8 @@ hqbacktest/
### 策略隔离与审计完整性(任务 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`。
+- **`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)。
@@ -255,7 +258,7 @@ result.save("results/moving-average")
| `start_date` / `end_date` | `"20240102"` | 回测的包含式日期区间,格式为 `YYYYMMDD` |
| `initial_cash` | `"100000"` | 初始人民币现金;账本层将其作为 `Decimal` 字段使用 |
| `data_root` | `"~/.hqdata"` | hqdata CSV 的根目录;可使用绝对路径覆盖 |
-| `source` | `"tushare"` | 本次运行唯一的数据源目录名,解析为 `{data_root}/{source}`;不能在一次回测中混用 |
+| `source` | `"tushare"` | 本次运行唯一的数据源目录名;裸名(如 `"tushare"`)解析为 `{data_root}/{source}`,绝对路径(如 `~/.hqdata/tushare`)拆为 `(parent_dir, basename)` 后定位快照(任务 22.1);不能在一次回测中混用 |
| `adjustment_policy` | `"none"` | v0.1 唯一合法值;精确公司行为会计完成并验证前,不支持因子总回报调整 |
| `universe` | `["600000.SH"]` | 可由策略在 `initialize` 中声明的目标股票池 |
| `cost_model` | 配置节 | 佣金、最低佣金、印花税和可选过户费;费率必须显式配置 |
@@ -339,7 +342,7 @@ results/run-1/
| 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: must be 8 digits` |
+| 日期格式错 | 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'; ...` |
@@ -376,12 +379,12 @@ CLI 同时支持 `--force` 覆盖已有输出目录:`hqbacktest run --config F
| `events.jsonl` | 完整事件日志(每行一个 JSON 事件,含订单/成交 ID 与错误原因) |
| `summary.json` | 配置快照、交易日、调整策略、数据来源(`data_version`)、因子诊断、指标 |
-指标公式(手算见 `tests/engine/test_metrics.py`):
+指标公式(手算见 `tests/engine/test_metrics.py` 与 `tests/engine/test_task17_metrics.py`):
- 累计收益 `(final_equity / initial_cash) - 1`
-- 日收益 `equity[t] / equity[t-1] - 1`
+- 日收益 `EquityPoint.daily_return`(引擎写入,首日锚定到 `initial_cash`——见任务 23)
- 年化收益 `(1 + total_return) ** (N / annual_trading_days) - 1`,N < 2 时为 `None`
-- 日波动率 `stdev(daily_returns)`(样本标准差,ddof=1),单日时为 `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`(从净值曲线取)
diff --git a/docs/design/mvp-contract.md b/docs/design/mvp-contract.md
index fa2de63..678411e 100644
--- a/docs/design/mvp-contract.md
+++ b/docs/design/mvp-contract.md
@@ -143,7 +143,7 @@
| 维度 | v0.1 默认决定 |
| --- | --- |
| `get_bars(symbol, start, end)` | 返回窗口内实际存在的行,**允许逐日间隙**;窗口内无任何行返回 `[]` 而非报错。 |
-| `get_bar(symbol, date)` 失败分类 | 个股当日缺行(停牌 / 未上市 / 已退市) → 返回「无当日行情」的空结果;**整日快照文件缺失** → `SnapshotFileMissingError`(`MissingDataError` 子类),引擎不得当作「该股无价」处理。 |
+| `get_bars` / `get_factor` 失败分类 | 个股当日缺行(停牌 / 未上市 / 已退市)→ 静默从结果集中省略(窗口内可能有 N 行,绝不报错);**整日快照文件缺失** → `SnapshotFileMissingError`(`MissingDataError` 子类),引擎不得当作「该股无价」处理,必须以 `DATA_ERROR` 中止本次运行。`MarketDataPortal` **没有**单点 `get_bar(symbol, date)` 接口——单日查询由 `current_price(symbol)` 与 `get_bars(symbol, d, d)` 共同覆盖。 |
| `current_price(symbol)` | 返回截至 `visible_through` 的最近一个有效收盘价,**回看上限 20 个交易日**,超出返回 `None`;停牌持仓按最近收盘估值并记录 `DATA_WARNING` 事件,禁止静默按 0 计入。 |
| 首个交易日盘前(`visible_through="00000000"`) | `history` 返回 `[]`、`current_price` 返回 `None`,**不抛异常**。 |
| `get_universe(date)` | **按精确日期查询**,不做向前回退;`.BJ`(北交所)股票默认过滤,可通过 `include_bj=True` 保留。 |
@@ -182,8 +182,8 @@
| 维度 | v0.1 默认决定 |
| --- | --- |
-| `Order` 不可变 | `@dataclass(frozen=True)`,`fill_ids: tuple[str, ...]`;策略收到 `pending_orders()` 后无法修改任何字段(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`。 |
+| `Order` 不可变 | `@dataclass(frozen=True)`,`fill_ids: tuple[str, ...]`;策略收到 `pending_orders()` 后无法修改任何字段(quantity / avg_fill_price / fill_ids 等);`transition` / `record_fill` 用 `object.__setattr__` 写入冻结字段,仅 engine / broker 可调用。 |
+| `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)。 |
@@ -215,7 +215,7 @@
## 6. 不可变规则
-以下 13 条规则在 v0.1 期间**不允许任何代码绕过**;新增能力时若必须突破某条,必须先在本文档登记例外并同步 README。
+以下 13 条规则在 v0.1 期间**必须严格生效**,不可通过代码路径绕开;新增能力时若必须突破某条,必须先在本文档登记例外并同步 README。
1. **无未来函数**:策略在任意阶段只能读到第 4 节"可见数据"列允许的数据;越界访问必须在 `DataView` 层抛错,不得由引擎静默裁剪或填充。
2. **单数据源**:一次回测只允许一个 `hqdata` 数据源;中途切换或混用必须在配置层直接拒绝。
@@ -227,8 +227,8 @@
8. **因子不得伪造公司行为**:复权因子只能在同一数据源内按相邻交易日比较;v0.1 可记录跨源、零、负或缺失因子的诊断信息,但不得据此修改现金、持仓、可卖数量、成本价或净值。精确公司行为需要独立权威数据。
9. **AdjustmentPolicy 必须显式且受限**:v0.1 配置必须显式指定 `none`;`factor_total_return` 等其他值必须在配置校验时拒绝。新增策略前必须定义其会计分录、估值公式、卖出处理和可手算测试。
10. **事件日志完整可追溯**:每一笔订单、成交、拒绝、调整必须写入事件日志,且至少包含 `日期 / 阶段 / 订单或成交 ID / 拒绝或调整原因`;缺失任一字段视为违反契约。
-11. **Context 只读、不可改写**:策略只能通过 `Context` 的查询方法读取现金、持仓、订单等;任何对 `Portfolio` 字段的直接赋值或绕过 `broker` 的修改必须抛错。
-12. **异常不得污染状态**:策略异常、数据校验错误和经纪商内部错误必须终止本次运行(或标记为失败运行),不得留下半交易日账本或静默吞掉异常;单笔订单的业务拒绝必须保留账本不变、记录原因并继续运行。
+11. **Context 只读、不可改写**:策略只能通过 `Context` 的查询方法读取现金、持仓、订单等;任何对 `Portfolio` 字段的直接赋值或不通过 `broker` 的修改必须抛错。
+12. **异常不得留下半交易日状态**:策略异常、数据校验错误和经纪商内部错误必须终止本次运行(或标记为失败运行),不得留下半交易日账本或静默吞掉异常;单笔订单的业务拒绝必须保留账本不变、记录原因并继续运行。
13. **DataView 越界即报错**:读取 `visible_through` 之后的数据必须立即抛错;不得返回空值、最后已知值或插值结果。
## 7. 职责边界矩阵
@@ -261,10 +261,13 @@
| 2026-08-23 | v0.1 | 任务 9:公司行为扩展设计门槛落地——`BacktestConfig.adjustment_policy` 严格只接受 `"none"`;`CorporateActionProvider` 列为设计草案并锁定 10 个权威字段;`BacktestResult.adjustment_policy` 与 `factor_diagnostics` 字段已就位;因子诊断接口存在但 v0.1 不启用 | hqbacktest 维护者 |
| 2026-08-17 | v0.1(已被后续修订取代) | 曾将 `source` 交给 `hqdata` 解析;该 API 驱动的数据边界已在 2026-08-23 被 CSV 快照契约取代 | hqbacktest 维护者 |
| 2026-08-23 | v0.1 | 修正回测运行时数据边界:`hqbacktest` 直接只读 hqdata CLI 落盘 CSV;`data_root` 默认 `~/.hqdata`,不调用 `hqdata.api` 或网络数据源 | hqbacktest 维护者 |
-| 2026-08-23 | v0.1 | 重构数据门户:`HqDataPortal` 替换为 `HqDataCsvPortal`,固定布局 `{data_root}/{source}/calendar.csv` + `stock_list|stock_daily|stock_factor/{YYYYMMDD}.csv`;`source` 名称或绝对路径均可,`CacheKey` 加入 `data_root` 防跨目录污染 | hqbacktest 维护者 |
+| 2026-08-23 | v0.1 | 重构数据门户:`HqDataPortal` 替换为 `HqDataCsvPortal`,固定布局 `{data_root}/{source}/calendar.csv` + `stock_list|stock_daily|stock_factor/{YYYYMMDD}.csv`;`source` 名称或绝对路径均可,`CacheKey` 加入 `data_root` 防跨目录串扰 | hqbacktest 维护者 |
| 2026-08-24 | v0.1 | 任务 14 数据层缺行/停牌/首日语义:钉死 `get_bars` 允许间隙、引入 `SnapshotFileMissingError` 区分整日文件缺失与个股缺行、`current_price` 回看 20 交易日最近有效收盘价、首日哨兵日期不抛异常、删除 `InMemoryDataPortal.get_universe` 向前回退、补双门户 parity 测试、缓存返回防御性拷贝、`.BJ` 股票默认过滤、`Bar.volume` 单位标注为「手」 | hqbacktest 维护者 |
| 2026-08-24 | v0.1 | 任务 16 撮合与账本语义:同批撮合 SELL 先于 BUY(滚动现金)、SELL 不整手取整(`order_target(0)` 可清零股)、`Fill.BUY` 携带非零 stamp_tax 报错、`Order.record_fill` 移除不可达 `ACCEPTED` 分支、`intents.target_quantity_for_value(0)` 按 docstring 返回 0、CLI `initial_cash` 拒绝 float 与引擎对齐、`realized_pnl` 不含费用修正旧注释;登记 §3.4 撮合口径表 | hqbacktest 维护者 |
| 2026-08-24 | v0.1 | 任务 17 净值与指标基准:首日 `daily_return` / `drawdown` 以 `initial_cash` 为基准(不再硬编码 0)、后续日 running peak = `max(initial_cash, 历史 total_equity)`、波动率样本不足返回 `None` 而非 0、`Decimal(str(float(...)))` 替代 `Decimal(float(...))` 幂运算桥接、`positions.sellable_quantity` 口径登记为「结转后」;恒等式 `∏(1 + daily_return) = 1 + total_return` 成立;登记 §3.5 | hqbacktest 维护者 |
-| 2026-08-24 | v0.1 | 任务 18 策略隔离与审计完整性:`Order` 改为 `frozen=True`(策略无法篡改 `pending_orders()` 返回的 Order)、`DataView.portal` 改为私有 `_portal`、universe 生效(`RejectReason.OUT_OF_UNIVERSE`)、`Context.historical_universe()` 转发 `DataView.universe()` 受可见性约束;登记 §3.6 | hqbacktest 维护者 |
+| 2026-08-24 | v0.1 | 任务 18 策略隔离与审计完整性:`Order` 改为 `frozen=True`(策略无法修改 `pending_orders()` 返回的 Order)、`DataView.portal` 改为私有 `_portal`、universe 生效(`RejectReason.OUT_OF_UNIVERSE`)、`Context.historical_universe()` 转发 `DataView.universe()` 受可见性约束;登记 §3.6 | hqbacktest 维护者 |
| 2026-08-24 | v0.1 | 任务 19 因子诊断接入与分红偏差显性化:engine 在持仓/成交标的的因子跳变(阈值 0.1%)自动生成 DATA_WARNING + `FactorDiagnostic`,结果写入 `summary.json` / `events.jsonl`;CLI 末尾打印汇总警告;账本与净值完全不变;登记 §3.7 | hqbacktest 维护者 |
-| 2026-08-24 | v0.1 | 任务 20 CLI 易用性与文档真实性:console script 把 config dir + cwd 加入 sys.path(策略模块解析与 `python -m` 对齐);`initial_cash` 拒绝 nan/inf/float;空交易窗口、空输出目录、`--force` 覆盖;`order_value` 接受 int/str;`git_commit` 改为 hqbacktest 自身版本;README 错误码表与包布局对齐;登记 §3.8 | hqbacktest 维护者 |
\ No newline at end of file
+| 2026-08-24 | v0.1 | 任务 20 CLI 易用性与文档真实性:console script 把 config dir + cwd 加入 sys.path(策略模块解析与 `python -m` 对齐);`initial_cash` 拒绝 nan/inf/float;空交易窗口、空输出目录、`--force` 覆盖;`order_value` 接受 int/str;`git_commit` 改为 hqbacktest 自身版本;README 错误码表与包布局对齐;登记 §3.8 | hqbacktest 维护者 |
+| 2026-08-25 | v0.1.2(hotfix) | 任务 22 CLI 与文档失实修复(v0.1.1 复核结论):`source` 绝对路径支持(拆为 `data_root` + 名称);`run_metadata.json` 中 `config_path` / `output_directory` / `config_output_directory` 写入相对路径(`os.path.relpath`);`validate_yyyymmdd` 用 `datetime.strptime` 拒绝假日期(保留 `"00000000"` 哨兵);任务 15 性能夹具生成器改用 `datetime` 迭代(修复整数计数器 `d % 100 == 32` 仅识别 31 天月回滚、遗漏短月假日期的隐藏 bug);CLI 测试中 `test_console_script_runs_end_to_end` 改名 `test_python_m_runs_end_to_end` 并补一个真正测 console script 的同名测试;README「26 项 CLI 测试」改为「见 tests/cli/」;`pyproject.toml` 删除过时的「no runtime deps yet」注释。任务 23/24 排入下个迭代 | hqbacktest 维护者 |
+| 2026-08-25 | v0.1.3(hotfix) | 任务 23 波动率/夏普首日采样缺口修复:`metrics.compute_metrics` 不再从 `total_equity` 重新推导日收益(旧的 `[Decimal("0")]` 种子会让首日真实收益永远不进 `stdev`),改为直接读 `engine` 写好的 `EquityPoint.daily_return`;删除死代码 `_drawdown_series`(自任务 17 之后无调用点,`max_drawdown` 直接读 `EquityPoint.drawdown`);旧回归测试 `test_two_day_volatility_is_none_when_only_one_return` 删除(基于 bug 行为),新增 `test_two_day_volatility_uses_both_daily_returns` 手算回归(2 日 -9% / +5.5%,`daily_volatility` ≈ 0.10253);`test_sharpe_with_risk_free_rate_and_simple_equity` 改写为基于真实 2 日序列验证 Sharpe 能算。波动率 / Sharpe 与 `max_drawdown` 对首日盈亏的可见性现在一致 | hqbacktest 维护者 |
+| 2026-08-25 | v0.1.4(hotfix) | 任务 24 数据层测试覆盖补齐与文档措辞澄清:`tests/data/test_portal_parity.py` 新增 6 项 `get_factor` 双门户逐值一致性断言(窗口返回序列、个股稀疏因子、空集、`start>end` 校验、坏 symbol、`SnapshotFileMissingError` vs 个股缺行);`_memory_with_gaps()` 与 `_csv_with_gaps()` 同步扩展因子 fixture(600000.SH 每天都有,000001.SZ 仅 20240102/05);`docs/design/mvp-contract.md` §3.3 删除不存在的 `get_bar(symbol, date)` 引用,将失败分类合并到 `get_bars` / `get_factor` 一行;`data_view.py` 与 mvp-contract.md §3.6 中 `DataView.portal` 隐私措辞改为准确表述(下划线是约定私有,**不是** Python 语言级强制力)。无 API 行为变更;同段合并清理:哨兵常量 `"00000000"` 三处定义(`NO_HISTORY_SENTINEL` / `SENTINEL_NO_HISTORY` / `NO_HISTORY_VISIBLE_THROUGH`)收敛到 `data.validators.SENTINEL_NO_HISTORY` 一处,从 `hqbacktest.data` 包导出;`test_version_matches_pyproject` 强化版本号形态校验(拒绝空白与 `v` 前缀) | hqbacktest 维护者 |
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 266d0d7..d1e88c5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hqbacktest"
-version = "0.1.1"
+version = "0.1.4"
description = "A股量化策略回测与交易模拟引擎"
readme = "README.md"
authors = [
@@ -23,8 +23,6 @@ keywords = [
"trading-simulation",
]
-# v0.1 task 2 skeleton: no runtime deps yet.
-# pandas / click / pydantic will be added when subsequent tasks need them.
dependencies = [
"pandas>=2.0.0",
"tomli>=2.0",
diff --git a/src/hqbacktest/__init__.py b/src/hqbacktest/__init__.py
index 4886b54..7885c70 100644
--- a/src/hqbacktest/__init__.py
+++ b/src/hqbacktest/__init__.py
@@ -51,7 +51,7 @@
TradingRuleSet,
)
-__version__ = "0.1.1"
+__version__ = "0.1.4"
__all__ = [
"__version__",
diff --git a/src/hqbacktest/cli/runner.py b/src/hqbacktest/cli/runner.py
index 52d6735..907ca7c 100644
--- a/src/hqbacktest/cli/runner.py
+++ b/src/hqbacktest/cli/runner.py
@@ -226,21 +226,29 @@ def _write_run_metadata(
source_path: Optional[str],
effective_output: str,
) -> None:
- """Record package/python/source/git info so the run is self-describing."""
+ """Record package/python/source/git info so the run is self-describing.
+
+ Task 22.2: the path fields (`config_path`, `output_directory`,
+ `config_output_directory`) are persisted as **relative paths**
+ (relative to the run-time cwd) so the run is reproducible across
+ machines without leaking `/home/` style directory layouts.
+ `data_root` is kept as the user-supplied value because it is part
+ of the configuration snapshot and may intentionally be absolute.
+ """
metadata: Dict[str, Any] = {
"hqbacktest_version": HQBACKTEST_VERSION,
"python": sys.version.split()[0],
"platform": sys.platform,
"timestamp_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
- "config_path": str(source_path) if source_path else None,
+ "config_path": _relativize_path(source_path),
"config_start_date": config_file.start_date,
"config_end_date": config_file.end_date,
"config_initial_cash": str(config_file.initial_cash),
"config_source": config_file.source,
"config_strategy_module": config_file.strategy_module,
"config_strategy_class": config_file.strategy_class,
- "output_directory": effective_output,
- "config_output_directory": config_file.output_directory,
+ "output_directory": _relativize_path(effective_output),
+ "config_output_directory": _relativize_path(config_file.output_directory),
"data_root": backtest_config.data_root,
"adjustment_policy": backtest_config.adjustment_policy,
"git_commit": _git_commit(),
@@ -251,6 +259,33 @@ def _write_run_metadata(
)
+def _relativize_path(path_str: Optional[str]) -> Optional[str]:
+ """Return `path_str` as a path relative to the run-time cwd.
+
+ Task 22.2: prevents `run_metadata.json` from carrying absolute
+ paths like `/home//run.toml` or
+ `/home//results/run-1`. Falls back to the bare filename when
+ the path cannot be relativized (so we never crash the runner nor
+ leak an absolute path). Returns `None` unchanged when `path_str`
+ is `None`.
+
+ Implementation note: `os.path.relpath` is intentional rather than
+ `pathlib.Path.relative_to` because the former gracefully handles
+ paths outside cwd (producing `../...` relpaths), whereas the
+ latter raises `ValueError`.
+ """
+ if path_str is None:
+ return None
+ try:
+ cwd = os.getcwd()
+ return os.path.relpath(path_str, start=cwd)
+ except (OSError, ValueError):
+ # Path resolution failed (e.g. cross-drive path on Windows);
+ # fall back to the bare filename so we neither crash the runner
+ # nor write an absolute path into run_metadata.json.
+ return os.path.basename(path_str)
+
+
def _git_commit() -> Optional[str]:
"""Return the hqbacktest package's own short git commit, or `None`
if unavailable.
diff --git a/src/hqbacktest/data/__init__.py b/src/hqbacktest/data/__init__.py
index de1d044..c024548 100644
--- a/src/hqbacktest/data/__init__.py
+++ b/src/hqbacktest/data/__init__.py
@@ -23,6 +23,7 @@
from .memory_portal import InMemoryDataPortal
from .portal import DataVersion, MarketDataPortal
from .validators import (
+ SENTINEL_NO_HISTORY,
assert_unique_sorted,
require_columns,
validate_decimal_series,
@@ -43,6 +44,7 @@
"InvalidDataError",
"MarketDataPortal",
"MissingDataError",
+ "SENTINEL_NO_HISTORY",
"SnapshotFileMissingError",
"UnknownSymbolError",
"assert_unique_sorted",
diff --git a/src/hqbacktest/data/data_view.py b/src/hqbacktest/data/data_view.py
index b44c01e..568eb65 100644
--- a/src/hqbacktest/data/data_view.py
+++ b/src/hqbacktest/data/data_view.py
@@ -30,7 +30,7 @@
SnapshotFileMissingError,
)
from .portal import MarketDataPortal
-from .validators import validate_symbol, validate_yyyymmdd
+from .validators import SENTINEL_NO_HISTORY, validate_symbol, validate_yyyymmdd
VALID_FIELDS = ("open", "high", "low", "close", "volume")
DEFAULT_HISTORY_START = "19000101"
@@ -40,10 +40,10 @@
# covers roughly a month of holidays plus one typical multi-day suspension.
CURRENT_PRICE_LOOKBACK = 20
-# Sentinel value used by the scheduler when no prior trading day exists yet.
-# Any explicit "00000000" request must be treated as "no data visible"
-# rather than as a literal date lookup.
-NO_HISTORY_SENTINEL = "00000000"
+# The "no history yet" sentinel ("00000000") is defined once, in
+# `validators`, as `SENTINEL_NO_HISTORY` (single source of truth) and
+# imported at the top of this module. Every `visible_through` /
+# `universe_start` comparison below references that one constant.
@dataclass
@@ -54,13 +54,23 @@ class DataView:
Together with `visible_through`, it forms the half-open window
`[universe_start, visible_through]` that all reads are restricted to.
- `visible_through="00000000"` is a legal sentinel that exposes no data:
+ `visible_through="00000000"` is a legal sentinel (see
+ `SENTINEL_NO_HISTORY` in `validators`) that exposes no data:
`history(...)` returns `[]` and `current_price(...)` returns `None`.
- Task 18: the `portal` attribute is **private** (name-mangled to
- `_portal`). Strategies cannot reach the raw `MarketDataPortal` and
- bypass `visible_through` via `view.portal.get_bars(...)`. All
- data-layer access goes through the guarded methods on this view.
+ Task 18: the `portal` attribute is **private** (renamed to
+ `_portal` as a leading-underscore convention). Strategies cannot
+ reach the raw `MarketDataPortal` through its public API — i.e.
+ `view.portal` raises `AttributeError` because the attribute is no
+ longer named `portal`. The leading underscore is a strong social
+ convention ("do not touch from outside"), not a language-level
+ guarantee: a sufficiently determined strategy could still reach
+ the portal via `view._portal` (and from there `context._data_view._portal`).
+ That is a Python language limitation (there is no real private
+ attribute), not a project defect; we rely on the social convention
+ plus contract tests to keep strategies honest. All
+ contract-compliant data access goes through the guarded methods on
+ this view.
The constructor still accepts `portal=...` (kwarg) so existing
call sites don't break, but the value is stored only on the
private field and is never re-exposed.
@@ -78,7 +88,8 @@ def __init__(
) -> None:
# Accept `portal=` for backward compatibility, but store it on
# the private `_portal` field. Strategies that try to read
- # `view.portal` get `AttributeError` (task 18 isolation).
+ # `view.portal` get `AttributeError` (the public attribute is
+ # gone; see the class docstring for the privacy contract).
self._portal = portal
self.visible_through = visible_through
self.universe_start = universe_start
@@ -86,36 +97,36 @@ def __init__(
def __post_init__(self) -> None:
# The sentinel "00000000" is allowed as a special value.
- if self.visible_through == NO_HISTORY_SENTINEL:
+ if self.visible_through == SENTINEL_NO_HISTORY:
if (
self.universe_start is not None
- and self.universe_start != NO_HISTORY_SENTINEL
+ and self.universe_start != SENTINEL_NO_HISTORY
):
raise FutureDataAccessError(self.universe_start, self.visible_through)
return
validate_yyyymmdd(self.visible_through, name="visible_through")
if self.universe_start is not None:
- if self.universe_start == NO_HISTORY_SENTINEL:
+ if self.universe_start == SENTINEL_NO_HISTORY:
raise FutureDataAccessError(self.universe_start, self.visible_through)
validate_yyyymmdd(self.universe_start, name="universe_start")
if self.universe_start > self.visible_through:
raise FutureDataAccessError(self.universe_start, self.visible_through)
def _guard(self, requested: str) -> None:
- """Reject queries that escape the visibility window.
+ """Reject queries that ask for dates outside the visibility window.
`00000000` is always considered to lie outside the visible window
because the portal never indexes dates earlier than its first
snapshot day; passing it through would force every underlying call
to scan the full pre-start history.
"""
- if requested == NO_HISTORY_SENTINEL:
+ if requested == SENTINEL_NO_HISTORY:
raise FutureDataAccessError(requested, self.visible_through)
if requested > self.visible_through:
raise FutureDataAccessError(requested, self.visible_through)
if (
self.universe_start is not None
- and self.universe_start != NO_HISTORY_SENTINEL
+ and self.universe_start != SENTINEL_NO_HISTORY
and requested < self.universe_start
):
# Reading before the data start is NOT future-data access; the
@@ -131,14 +142,14 @@ def _guard(self, requested: str) -> None:
def get_bars(self, symbol: str, start: str, end: str) -> List:
# Sentinel view: empty by construction.
- if self.visible_through == NO_HISTORY_SENTINEL:
+ if self.visible_through == SENTINEL_NO_HISTORY:
return []
self._guard(start)
self._guard(end)
return self._portal.get_bars(symbol, start, end)
def get_factor(self, symbol: str, start: str, end: str):
- if self.visible_through == NO_HISTORY_SENTINEL:
+ if self.visible_through == SENTINEL_NO_HISTORY:
return []
self._guard(start)
self._guard(end)
@@ -148,7 +159,7 @@ def get_universe(
self, date: Optional[str] = None, include_bj: bool = False
) -> List[str]:
target = self.visible_through if date is None else date
- if self.visible_through == NO_HISTORY_SENTINEL:
+ if self.visible_through == SENTINEL_NO_HISTORY:
return []
self._guard(target)
return self._portal.get_universe(target, include_bj=include_bj)
@@ -182,7 +193,7 @@ def history(
raise ValueError(f"field must be one of {VALID_FIELDS}, got {field!r}")
if bar_count <= 0:
raise ValueError(f"bar_count must be positive, got {bar_count}")
- if self.visible_through == NO_HISTORY_SENTINEL:
+ if self.visible_through == SENTINEL_NO_HISTORY:
return []
# Cap the lookback to bar_count trading days so we never ask the
# portal for the full pre-start history of every symbol. The cap
@@ -190,7 +201,7 @@ def history(
# requested number of values.
if (
self.universe_start is not None
- and self.universe_start != NO_HISTORY_SENTINEL
+ and self.universe_start != SENTINEL_NO_HISTORY
):
start = self.universe_start
else:
@@ -233,7 +244,7 @@ def current_price(self, symbol: str) -> Optional[Decimal]:
while preserving the 20-**trading-day** lookback bound.
"""
validate_symbol(symbol)
- if self.visible_through == NO_HISTORY_SENTINEL:
+ if self.visible_through == SENTINEL_NO_HISTORY:
return None
# Resolve the 20-trading-day cutoff. The lookback is bounded by
# trading days, NOT by bar count: a symbol suspended for longer
diff --git a/src/hqbacktest/data/hqdata_portal.py b/src/hqbacktest/data/hqdata_portal.py
index e2af446..05bdce0 100644
--- a/src/hqbacktest/data/hqdata_portal.py
+++ b/src/hqbacktest/data/hqdata_portal.py
@@ -38,6 +38,8 @@
from pathlib import Path
from typing import Dict, List, Optional, Tuple
+import os
+
import pandas as pd
from ..domain.bar import Bar
@@ -62,13 +64,49 @@
def resolve_source_location(
source: str, default_data_root: str = DEFAULT_DATA_ROOT
) -> Tuple[str, str]:
- """Map a source directory name to `(data_root, source_name)`."""
+ """Map a source reference to `(data_root, source_name)`.
+
+ Per task 22.1, `source` may be either:
+
+ * a bare directory name (e.g. ``"tushare"``). It is paired with
+ ``default_data_root`` (the ``[data].data_root`` config, default
+ ``~/.hqdata``).
+ * an absolute path to the snapshot directory itself, with ``~``
+ expansion (e.g. ``"~/.hqdata/tushare"`` or
+ ``"/home//.hqdata/tushare"``). It is split into
+ ``(parent_dir, basename)``.
+
+ Relative paths that mix both forms (``"foo/bar"``) are rejected:
+ cwd-relative resolution is ambiguous for backtests and is never
+ relied on. Empty strings, ``.``, ``..``, and the filesystem root
+ are also rejected.
+ """
if not isinstance(source, str) or not source:
raise InvalidDataError("source", "must be a non-empty string")
- if Path(source).name != source or source in (".", ".."):
+ if source in (".", "..", "/"):
+ raise InvalidDataError(
+ "source",
+ f"must be a directory name or absolute path; got {source!r}",
+ )
+ # Expand `~` so `~/.hqdata/tushare` is treated as an absolute path
+ # on every platform. `expanduser` is a no-op for strings without `~`.
+ expanded = os.path.expanduser(source)
+ p = Path(expanded)
+ if p.is_absolute():
+ if p.name in ("", ".", ".."):
+ raise InvalidDataError(
+ "source",
+ "absolute path {!r} resolves to invalid directory "
+ "name {!r}".format(source, p.name),
+ )
+ return (str(p.parent), p.name)
+ # Bare name (no path separators). Pair with `default_data_root`.
+ if "/" in source or "\\" in source:
raise InvalidDataError(
"source",
- "must be a directory name; configure its parent with data_root",
+ "must be a directory name or an absolute path; got "
+ "relative path {!r} (configure its parent with data_root "
+ "or pass an absolute path instead)".format(source),
)
return (default_data_root, source)
diff --git a/src/hqbacktest/data/validators.py b/src/hqbacktest/data/validators.py
index 892d736..3199ae4 100644
--- a/src/hqbacktest/data/validators.py
+++ b/src/hqbacktest/data/validators.py
@@ -6,6 +6,7 @@
violations raise `InvalidDataError` with a diagnostic detail string.
"""
+from datetime import datetime
from decimal import Decimal
from typing import Iterable, Sequence
@@ -13,15 +14,44 @@
SYMBOL_SUFFIXES = (".SH", ".SZ", ".BJ")
+# Sentinel that `DataView` (and `Scheduler`) treat as "no data visible
+# yet" — the first day before any snapshot is loaded. We allow it
+# through `validate_yyyymmdd` so engine-internal code paths can compare
+# against it without tripping on a calendar check (`strptime("00000000",
+# "%Y%m%d")` raises `ValueError` in Python ≥ 3, because year 0 is
+# disallowed).
+SENTINEL_NO_HISTORY = "00000000"
+
def validate_yyyymmdd(value: object, *, name: str = "date") -> str:
- """Return the value if it is a valid YYYYMMDD string, else raise."""
+ """Return the value if it is a valid YYYYMMDD string, else raise.
+
+ Task 22.3: rejects 8-digit strings that are NOT real calendar
+ dates (e.g. ``"20241399"`` month 13, ``"20240230"`` Feb 30 in a
+ non-leap year). Previously the validator only checked
+ ``len == 8 and isdigit()`` so impossible dates silently slipped
+ through and were only caught downstream — sometimes by an
+ unrelated check that masked the real defect.
+
+ The sentinel ``"00000000"`` is explicitly accepted (see
+ `SENTINEL_NO_HISTORY`) because it is a legal engine-internal value
+ for `DataView.visible_through` and `Scheduler`'s pre-start phase.
+ """
if not isinstance(value, str):
raise InvalidDataError(
name, f"must be YYYYMMDD string, got {type(value).__name__}"
)
if len(value) != 8 or not value.isdigit():
raise InvalidDataError(name, f"must be 8 digits, got {value!r}")
+ if value == SENTINEL_NO_HISTORY:
+ return value
+ try:
+ datetime.strptime(value, "%Y%m%d")
+ except ValueError as exc:
+ raise InvalidDataError(
+ name,
+ f"not a valid calendar date: {value!r} ({exc})",
+ ) from exc
return value
diff --git a/src/hqbacktest/domain/order.py b/src/hqbacktest/domain/order.py
index ba7fbda..27bcdbf 100644
--- a/src/hqbacktest/domain/order.py
+++ b/src/hqbacktest/domain/order.py
@@ -26,8 +26,8 @@ class Order:
Order via `Context.pending_orders()` cannot mutate any field —
attempted assignment raises `FrozenInstanceError`. Lifecycle
mutations (`transition`, `record_fill`) use `object.__setattr__`
- to bypass the freeze; that path is engine-internal and never
- exposed to strategy code.
+ to assign to the frozen fields; that path is engine-internal and
+ never exposed to strategy code.
"""
order_id: str
@@ -117,8 +117,8 @@ def transition(
) -> None:
"""Move the order to `target`, stamping the matching timestamp.
- Task 18: this mutator uses `object.__setattr__` to bypass the
- frozen-dataclass guard. Only the engine / broker may call it.
+ Task 18: this mutator uses `object.__setattr__` to assign to
+ frozen fields. Only the engine / broker may call it.
"""
self._validate_date(at, "at")
validate_transition(self.status, target)
@@ -155,8 +155,8 @@ def record_fill(
fill arrives, so the ACCEPTED branch in the status guard was
unreachable in practice (task 16).
- Task 18: this mutator uses `object.__setattr__` to bypass the
- frozen-dataclass guard. Only the engine / broker may call it.
+ Task 18: this mutator uses `object.__setattr__` to assign to
+ frozen fields. Only the engine / broker may call it.
"""
if self.status not in (
OrderStatus.PENDING,
@@ -192,7 +192,8 @@ def record_fill(
object.__setattr__(self, "filled_quantity", new_filled)
# fill_ids is an immutable tuple so a strategy holding the frozen
# Order cannot append/clear it in place (task 18). Rebuild the
- # tuple here via object.__setattr__ to bypass the frozen guard.
+ # tuple here via object.__setattr__ to assign to the frozen
+ # field.
object.__setattr__(self, "fill_ids", self.fill_ids + (fill_id,))
if new_filled == self.quantity:
self.transition(OrderStatus.FILLED, at=at)
diff --git a/src/hqbacktest/engine/context.py b/src/hqbacktest/engine/context.py
index ab97540..64ae1ba 100644
--- a/src/hqbacktest/engine/context.py
+++ b/src/hqbacktest/engine/context.py
@@ -10,7 +10,8 @@
The Context never mutates the ledger directly. `Portfolio.apply_fill` is
the single writer (task 7); here we only append `Order` objects to
-`pending_orders`. Strategy code therefore cannot bypass the broker.
+`pending_orders`. Strategy code therefore cannot reach the broker
+directly — every order path funnels through the engine.
Isolation rules enforced here (contract §4 and task 6 goals):
* Date / phase / data view are engine-owned: they can only be changed
@@ -22,6 +23,9 @@
`BAR_CLOSE` (contract §4 "可下单" column).
* `set_universe` may only be called from `initialize`; the universe is
locked once `initialize` returns (contract §4 配套约束).
+ * All order paths funnel through `_create_order`, so the order-type
+ allow-list and symbol validation cannot be skipped by the
+ convenience helpers.
"""
from dataclasses import replace
@@ -220,7 +224,7 @@ def historical_universe(self) -> List[str]:
Task 18: this is the only universe accessor that reads through
the data portal. It is constrained by `visible_through` and
- must not be used to bypass the data view. When no `DataView`
+ must not be used to read future data. When no `DataView`
is published (e.g. in `initialize`), an empty list is returned.
"""
self._require_active("historical_universe")
@@ -495,7 +499,7 @@ def _create_order(
order_type: OrderType = OrderType.MARKET,
) -> Optional[Order]:
# Contract rule 7: every order path funnels through here, so the
- # order-type allow-list and symbol validation cannot be bypassed by
+ # order-type allow-list and symbol validation cannot be skipped by
# the convenience helpers.
if not isinstance(order_type, OrderType):
raise UnsupportedOrderTypeError(
diff --git a/src/hqbacktest/engine/metrics.py b/src/hqbacktest/engine/metrics.py
index 799684f..852f06b 100644
--- a/src/hqbacktest/engine/metrics.py
+++ b/src/hqbacktest/engine/metrics.py
@@ -1,12 +1,12 @@
-"""Performance metrics (task 10 + task 17).
+"""Performance metrics (task 10 + task 17 + task 23).
Formulas (all documented in README and contract doc):
* `total_return` = (final_equity / initial_equity) - 1
- * `daily_return` = equity[t] / equity[t-1] - 1 (t >= 1)
- The engine anchors t=0 to `initial_cash`
- so a first-day P&L flows into the
- return series (task 17). The chained-
- product identity
+ * `daily_return` = `EquityPoint.daily_return` as written
+ by the engine. The engine anchors day 0
+ to `initial_cash` so a first-day P&L
+ flows into the return series (task 17).
+ The chained-product identity
`∏(1 + daily_return) == 1 + total_return`
therefore holds for any run.
* `annualized_return` = (1 + total_return) ** (n /
@@ -15,12 +15,12 @@
then re-encoded as `Decimal(str(...))`
so the ledger never sees a Decimal
built directly from `float`.
- * `daily_volatility` = stdev(daily_returns[1:]) (sample, ddof=1)
+ * `daily_volatility` = stdev(daily_returns) (sample, ddof=1)
`None` when fewer than 2 daily returns
are available (single-day run, or two
- trading days with only one observed
- return). Reports `0` only when the
- series is genuinely flat.
+ trading days with only one distinct
+ return — task 23). Reports `0` only
+ when the series is genuinely flat.
* `annualized_volatility` = daily_volatility * sqrt(annual_trading_days)
`None` iff `daily_volatility is None`.
* `sharpe_ratio` = (annualized_return - risk_free_rate) /
@@ -38,8 +38,18 @@
cost / total SELL fills; `None` if no
SELL fills
-Edge cases (per task 10/17 verification "空回测 / 单日 / 零波动 / 全亏损 /
-无交易 / 样本不足"):
+Task 23: the daily-return series passed to `stdev` is the engine's
+own `EquityPoint.daily_return` series, NOT a re-derivation from
+`total_equity`. Re-derivation (the pre-task-23 implementation)
+silently dropped the day-0 return by seeding `[Decimal("0")]`, so a
+2-day backtest with one real day-1 return reported
+`daily_volatility=None` even though `max_drawdown` saw the day-0
+loss correctly. Reading `EquityPoint.daily_return` directly makes
+the volatility / drawdown / Sharpe trio agree on what counts as a
+"first day".
+
+Edge cases (per task 10/17/23 verification "空回测 / 单日 / 零波动 /
+全亏损 / 无交易 / 样本不足"):
* `len(equity_curve) == 0` (empty run): all metrics 0 or `None`; notes
record "no trading days".
* `len(equity_curve) == 1` (single day): `total_return` is the only
@@ -128,34 +138,6 @@ class PerformanceMetrics:
notes: Tuple[str, ...] = ()
-def _daily_returns(equity: Sequence[Decimal]) -> List[Decimal]:
- """`equity[t] / equity[t-1] - 1` for t >= 1. Returns 0 for t=0."""
- out: List[Decimal] = [Decimal("0")]
- for prev, curr in zip(equity[:-1], equity[1:]):
- if prev == 0:
- out.append(Decimal("0"))
- else:
- out.append(curr / prev - Decimal("1"))
- return out
-
-
-def _drawdown_series(returns: Sequence[Decimal]) -> List[Decimal]:
- """Per-step drawdown (>= 0) given the cumulative return series."""
- peaks: List[Decimal] = [Decimal("1")]
- drawdowns: List[Decimal] = [Decimal("0")]
- cum = Decimal("1")
- for r in returns:
- cum = cum * (Decimal("1") + r)
- peak = max(peaks[-1], cum)
- peaks.append(peak)
- if peak == 0:
- drawdowns.append(Decimal("0"))
- else:
- drawdowns.append(max(Decimal("0"), (peak - cum) / peak))
- # Drop the seed element; we want one drawdown per return.
- return drawdowns[1:]
-
-
def compute_metrics(
equity_curve: Sequence[EquityPoint],
fills: Sequence[Fill],
@@ -164,17 +146,18 @@ def compute_metrics(
) -> PerformanceMetrics:
"""Compute all v0.1 metrics from an equity curve and the fill list.
- Task 17 invariants:
- * `daily_return` is recomputed from `total_equity` via
- `_daily_returns`, which anchors the first day's return to
- `initial_cash` (engine writes the same value to the
- `EquityPoint`). The chained-product identity therefore
- holds regardless of how the engine seeded day 0.
+ Task 23 invariants:
+ * `daily_volatility` reads `EquityPoint.daily_return` directly
+ (the same value the engine wrote, anchored to `initial_cash`
+ on day 0). Re-deriving from `total_equity` would lose the
+ day-0 return: the first day has no predecessor `total_equity`
+ to divide by, so an `equity[t] / equity[t-1] - 1` loop that
+ starts at t=1 silently drops it — that was the pre-task-23 bug.
* `daily_volatility` is `None` whenever fewer than 2 daily
- returns are available (single-day run, or a two-day run that
- has only one observed return). It is `0` only when the series
- is genuinely flat — task 17 forbids returning `0` for
- undefined statistics.
+ returns are available (single-day run, or a multi-day run
+ whose `daily_return` values collapse to a single distinct
+ sample). It is `0` only when the series is genuinely flat —
+ task 17 forbids returning `0` for undefined statistics.
* All Decimal metrics that involve `float` arithmetic go
through `Decimal(str(...))` so the ledger never holds a
`Decimal` constructed directly from a binary float (contract
@@ -214,23 +197,28 @@ def compute_metrics(
) - Decimal("1")
# Daily volatility (per-day stdev) and its annualisation; Sharpe uses
- # the annualised pair so the units match. Task 17: insufficient
- # samples (< 2 daily returns) returns `None`, not 0.
- returns = _daily_returns([pt.total_equity for pt in equity_curve])
+ # the annualised pair so the units match.
+ #
+ # Task 23: the series passed to `stdev` is the engine's own
+ # `EquityPoint.daily_return` (already anchored to `initial_cash`
+ # for day 0 by `BacktestEngine._publish_equity_point`). Re-deriving
+ # from `total_equity` would silently drop the day-0 return.
+ daily_volatility: Optional[Decimal]
annualized_volatility: Optional[Decimal]
- if len(returns) - 1 < 2:
- # returns[0] is the seed (0); subsequent entries are the actual
- # daily returns. < 2 means stdev cannot be computed.
- daily_volatility: Optional[Decimal] = None
+ sharpe_ratio: Optional[Decimal]
+ daily_returns = [pt.daily_return for pt in equity_curve]
+ if len(daily_returns) < 2:
+ daily_volatility = None
annualized_volatility = None
- sharpe_ratio: Optional[Decimal] = None
+ sharpe_ratio = None
notes.append("daily_volatility: requires >= 2 daily returns")
else:
try:
- vol_per_day = Decimal(str(stdev([float(r) for r in returns[1:]])))
+ vol_per_day = Decimal(str(stdev([float(r) for r in daily_returns])))
except StatisticsError:
- # All-zero series: stdev is undefined in `statistics` for
- # 0-variance; treat as zero-volatility (a defined value).
+ # All-zero (or otherwise constant) series: stdev is
+ # undefined in `statistics` for 0-variance; treat as
+ # zero-volatility (a defined value).
vol_per_day = Decimal("0")
daily_volatility = vol_per_day
if vol_per_day == 0:
diff --git a/src/hqbacktest/engine/scheduler.py b/src/hqbacktest/engine/scheduler.py
index c4180c1..95b8956 100644
--- a/src/hqbacktest/engine/scheduler.py
+++ b/src/hqbacktest/engine/scheduler.py
@@ -18,6 +18,7 @@
from ..data.data_view import DataView
from ..data.errors import MissingDataError
from ..data.portal import MarketDataPortal
+from ..data.validators import SENTINEL_NO_HISTORY
from ..domain.enums import EventType
from ..domain.order import Order
from .context import Context
@@ -36,11 +37,6 @@
PRE_BAR_VISIBLE_THROUGH = "PREVIOUS_TRADING_DAY"
SAME_DAY_VISIBLE_THROUGH = "SAME_DAY"
-# Sentinel used as `visible_through` on the first trading day, when no prior
-# trading day exists. It is a valid-format YYYYMMDD string strictly earlier
-# than any real date, so the view is legal but exposes no bars.
-NO_HISTORY_VISIBLE_THROUGH = "00000000"
-
@dataclass(frozen=True)
class PhaseSchedule:
@@ -96,7 +92,7 @@ def build_view(
# the view legal but exposes no data.
return DataView(
portal=portal,
- visible_through=NO_HISTORY_VISIBLE_THROUGH,
+ visible_through=SENTINEL_NO_HISTORY,
)
return DataView(portal=portal, visible_through=prev)
raise ValueError(f"unknown visible_through mode: {schedule.visible_through_mode}")
diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py
index dcfe5c2..01f683b 100644
--- a/tests/cli/test_cli.py
+++ b/tests/cli/test_cli.py
@@ -434,17 +434,61 @@ def test_runner_writes_run_metadata(tmp_path: Path) -> None:
def test_runner_does_not_write_secrets(tmp_path: Path, monkeypatch) -> None:
"""Tokens, env vars, and absolute local paths must never appear in
- the output directory."""
+ `run_metadata.json`.
+
+ Task 22.2: also asserts that absolute paths passed via `--config`
+ or `--output` are written as **relative** paths in the metadata
+ fields `config_path`, `output_directory`, and
+ `config_output_directory`. The absolute path itself never appears
+ in the metadata file; relative paths are still useful for
+ re-running and comparing results across machines without leaking
+ `/home/` style directory layouts.
+
+ Note: `config.toml` in the output directory is a byte-faithful copy
+ of the user's input (audit trail, see `_write_config_toml`), so it
+ intentionally preserves whatever the user wrote there. The
+ `run_metadata.json` is the auto-generated summary that the
+ contract says must NOT carry absolute paths.
+ """
monkeypatch.setenv("HQBACKTEST_TEST_TOKEN", "super-secret-token-12345")
data_root = _write_csv_snapshot(tmp_path)
cfg_path = tmp_path / "config.toml"
cfg_path.write_text(_minimal_config(tmp_path / "out", data_root), encoding="utf-8")
- result = run_from_file(str(cfg_path))
+ # Run from `tmp_path` so the absolute cfg path lives one level above
+ # cwd — that exercises the relative-path normalisation with a non-
+ # trivial relpath.
+ monkeypatch.chdir(tmp_path)
+ cfg_abs = cfg_path.resolve()
+ assert cfg_abs.is_absolute()
+ result = run_from_file(str(cfg_abs))
assert result.exit_code == 0
+ # Token / env-var leak check across ALL output files (cheap substring
+ # scan; no machine paths involved).
for path in result.output_dir.iterdir():
text = path.read_text(encoding="utf-8", errors="ignore")
assert "super-secret-token-12345" not in text, f"secret leaked into {path}"
assert "HQBACKTEST_TEST_TOKEN" not in text, f"env var leaked into {path}"
+ # Task 22.2: only `run_metadata.json` is checked for absolute paths,
+ # because `config.toml` is the user's byte-faithful audit copy.
+ import json
+
+ meta = json.loads((result.output_dir / "run_metadata.json").read_text())
+ meta_text = (result.output_dir / "run_metadata.json").read_text()
+ assert (
+ str(cfg_abs) not in meta_text
+ ), f"absolute config path leaked into run_metadata.json: {meta_text!r}"
+ assert (
+ str(result.output_dir.resolve()) not in meta_text
+ ), f"absolute output dir leaked into run_metadata.json: {meta_text!r}"
+ # Field-level check: the three path fields are stored as relative paths
+ # (relative to the cwd at run time), or None when not applicable.
+ for field in ("config_path", "output_directory", "config_output_directory"):
+ value = meta.get(field)
+ if value is None:
+ continue
+ assert not Path(
+ value
+ ).is_absolute(), f"{field}={value!r} is absolute; must be relative to cwd"
def test_runner_is_deterministic(tmp_path: Path) -> None:
@@ -486,10 +530,15 @@ def test_runner_creates_output_directory(tmp_path: Path) -> None:
assert result.output_dir.exists()
-def test_runner_output_override_beats_config_directory(tmp_path: Path) -> None:
+def test_runner_output_override_beats_config_directory(
+ tmp_path: Path, monkeypatch
+) -> None:
"""`--output` (the `output_dir` arg) overrides `[output].directory`."""
import json
+ # Task 22.2: relativize_path uses `os.getcwd()`; chdir to tmp_path
+ # so the relativized values are predictable basenames.
+ monkeypatch.chdir(tmp_path)
config_out = tmp_path / "from_config"
override_out = tmp_path / "from_cli_flag"
data_root = _write_csv_snapshot(tmp_path)
@@ -501,8 +550,11 @@ def test_runner_output_override_beats_config_directory(tmp_path: Path) -> None:
assert (override_out / "equity_curve.csv").exists()
assert not config_out.exists()
meta = json.loads((override_out / "run_metadata.json").read_text())
- assert meta["output_directory"] == str(override_out)
- assert meta["config_output_directory"] == str(config_out)
+ # Task 22.2: path fields in run_metadata.json are relativized to the
+ # run-time cwd; both dirs live under tmp_path so the relative form
+ # is the basename.
+ assert meta["output_directory"] == "from_cli_flag"
+ assert meta["config_output_directory"] == "from_config"
def test_runner_unwritable_output_returns_3(tmp_path: Path) -> None:
@@ -558,11 +610,15 @@ def test_cli_returns_2_on_config_error(tmp_path: Path, capsys) -> None:
assert "hqbacktest:" in captured.err
-def test_console_script_runs_end_to_end(tmp_path: Path) -> None:
- """`hqbacktest` console script works from a fresh subprocess.
+def test_python_m_runs_end_to_end(tmp_path: Path) -> None:
+ """`python -m hqbacktest run` works end-to-end from a fresh subprocess.
- We invoke the script via `python -m hqbacktest run` so the test does
- not depend on the console-script being installed on the test machine.
+ Task 22.4: previously named `test_console_script_runs_end_to_end`,
+ but the docstring already acknowledged it runs via
+ `python -m hqbacktest` rather than the installed console script.
+ Renamed for accuracy; the real console-script entry point is
+ covered by `test_console_script_runs_end_to_end` below, which
+ invokes the binary produced by `pip install -e .` directly.
"""
repo = Path(__file__).resolve().parents[2]
data_root = _write_csv_snapshot(tmp_path)
@@ -586,3 +642,55 @@ def test_console_script_runs_end_to_end(tmp_path: Path) -> None:
)
assert result.returncode == 0, result.stderr
assert (tmp_path / "out" / "equity_curve.csv").exists()
+
+
+def test_console_script_runs_end_to_end(tmp_path: Path) -> None:
+ """The installed `hqbacktest` console script runs end-to-end.
+
+ Task 22.4: counterpart to `test_python_m_runs_end_to_end`. This
+ test invokes the actual console-script binary (e.g. the one
+ produced by `pip install -e .`) so that any divergence between
+ the `python -m` shim and the console-script entry point is
+ caught by CI.
+ """
+ # Locate the venv's `hqbacktest` binary; the test relies on
+ # `pip install -e .` having been run before `pytest`. If the
+ # binary is missing we skip with a clear reason rather than fail,
+ # so the test does not break fresh CI runners that have not
+ # installed the editable build yet.
+ import shutil
+
+ repo = Path(__file__).resolve().parents[2]
+ candidates = [
+ repo / ".venv" / "bin" / "hqbacktest", # POSIX
+ repo / ".venv" / "Scripts" / "hqbacktest.exe", # Windows
+ ]
+ binary = next((p for p in candidates if p.exists()), None)
+ if binary is None:
+ on_path = shutil.which("hqbacktest")
+ if on_path is None:
+ pytest.skip(
+ "hqbacktest console-script binary not found; run "
+ "`pip install -e .` in the test venv to enable this "
+ "test"
+ )
+ binary = Path(on_path)
+ data_root = _write_csv_snapshot(tmp_path)
+ cfg_path = tmp_path / "c.toml"
+ cfg_path.write_text(_minimal_config(tmp_path / "out", data_root), encoding="utf-8")
+ result = subprocess.run(
+ [
+ str(binary),
+ "run",
+ "--config",
+ str(cfg_path),
+ "--output",
+ str(tmp_path / "out"),
+ ],
+ capture_output=True,
+ text=True,
+ cwd=str(repo),
+ check=False,
+ )
+ assert result.returncode == 0, result.stderr
+ assert (tmp_path / "out" / "equity_curve.csv").exists()
diff --git a/tests/cli/test_task20_cli.py b/tests/cli/test_task20_cli.py
index cb898c7..6da5d07 100644
--- a/tests/cli/test_task20_cli.py
+++ b/tests/cli/test_task20_cli.py
@@ -234,12 +234,21 @@ def test_initial_cash_negative_rejected(tmp_path):
def test_start_date_impossible_rejected(tmp_path):
- """An impossible calendar date (`20241399`) must be rejected."""
+ """An impossible calendar date (`20241399`) must be rejected.
+
+ Task 22.3: the prior version of this test set `end_date='20240104'`
+ which is **lexicographically smaller** than `'20241399'`, so the
+ failure was triggered by the `start > end` ordering check rather
+ than the impossible-date check. This revision uses an
+ `end_date` that is lex-greater AND a real calendar date
+ (`'20241231'`) so the failure mode is unambiguously the
+ impossible-date validation in `validate_yyyymmdd`.
+ """
cfg = tmp_path / "c.toml"
cfg.write_text(
"[start]\n"
- "start_date = '20241399'\n"
- "end_date = '20240104'\n"
+ "start_date = '20241399'\n" # month 13 — not a real date
+ "end_date = '20241231'\n" # lex-greater than start, real date
"[capital]\n"
"initial_cash = '100000'\n"
"[data]\n"
@@ -249,8 +258,47 @@ def test_start_date_impossible_rejected(tmp_path):
"[output]\n"
f"directory = '{tmp_path / 'out'}'\n"
)
- with pytest.raises(ConfigError):
+ with pytest.raises(ConfigError) as exc:
+ load_config_file(str(cfg))
+ # The message must clearly attribute the failure to the impossible
+ # start_date, not to a `start > end` ordering error.
+ assert "20241399" in str(
+ exc.value
+ ), f"error should mention the impossible start_date; got {exc.value!r}"
+ assert "calendar" in str(exc.value).lower(), (
+ f"error should mention 'calendar' as the validation failure; "
+ f"got {exc.value!r}"
+ )
+ assert (
+ "start_date" in str(exc.value) or "start" in str(exc.value).lower()
+ ), f"error should mention the field name; got {exc.value!r}"
+
+
+def test_end_date_impossible_rejected(tmp_path):
+ """Task 22.3: impossible `end_date` must also be rejected.
+ Uses `end_date='20240230'` (Feb 30, non-leap year) with a real,
+ lex-smaller `start_date` so the failure mode is the impossible
+ date, not the `start > end` check.
+ """
+ cfg = tmp_path / "c.toml"
+ cfg.write_text(
+ "[start]\n"
+ "start_date = '20240102'\n"
+ "end_date = '20240230'\n" # Feb 30 — not a real date
+ "[capital]\n"
+ "initial_cash = '100000'\n"
+ "[data]\n"
+ "source = 'memory'\n"
+ "[strategy]\n"
+ "module = 'strategy'\n"
+ "[output]\n"
+ f"directory = '{tmp_path / 'out'}'\n"
+ )
+ with pytest.raises(ConfigError) as exc:
load_config_file(str(cfg))
+ assert "20240230" in str(
+ exc.value
+ ), f"error should mention the impossible end_date; got {exc.value!r}"
def test_engine_window_with_zero_trading_days_raises(tmp_path):
diff --git a/tests/data/test_hqdata_portal.py b/tests/data/test_hqdata_portal.py
index 826e812..be29176 100644
--- a/tests/data/test_hqdata_portal.py
+++ b/tests/data/test_hqdata_portal.py
@@ -125,11 +125,48 @@ def test_resolve_source_location_with_name_uses_default_root(tmp_path):
assert name == "tushare"
-def test_resolve_source_location_rejects_source_path(tmp_path):
- with pytest.raises(InvalidDataError, match="directory name"):
- resolve_source_location(
- str(tmp_path / "ricequant"), default_data_root="ignored"
+def test_resolve_source_location_with_absolute_path_splits_into_root_and_name(tmp_path):
+ """Task 22.1: absolute paths are split into (parent_data_root, source_name).
+
+ This mirrors the documented behaviour: `source` may be either a bare
+ directory name (resolved under `default_data_root`) or an absolute
+ path to the snapshot directory (e.g. `~/.hqdata/tushare`), which is
+ split into `(~/.hqdata, tushare)`.
+ """
+ snap_dir = tmp_path / "ricequant"
+ data_root, name = resolve_source_location(
+ str(snap_dir), default_data_root="ignored"
+ )
+ assert name == "ricequant"
+ assert data_root == str(tmp_path)
+
+
+def test_resolve_source_location_with_tilde_absolute_path_expands_and_splits(
+ tmp_path,
+):
+ """`~/.hqdata/tushare` expands the tilde and splits to (parent, name)."""
+ fake_home = tmp_path / "home"
+ snap_dir = fake_home / ".hqdata" / "tushare"
+ os.environ["HOME"] = str(fake_home)
+ try:
+ data_root, name = resolve_source_location(
+ "~/.hqdata/tushare", default_data_root="ignored"
)
+ finally:
+ del os.environ["HOME"]
+ assert name == "tushare"
+ assert data_root == str(fake_home / ".hqdata")
+
+
+def test_resolve_source_location_rejects_relative_source_path(tmp_path):
+ """Relative paths like `foo/bar` are still rejected: too ambiguous.
+
+ The contract is "bare name OR absolute path". A relative path that
+ mixes both is rejected so users do not get surprising cwd-relative
+ resolutions.
+ """
+ with pytest.raises(InvalidDataError, match="directory name"):
+ resolve_source_location("foo/bar", default_data_root=str(tmp_path))
def test_resolve_source_location_rejects_empty():
@@ -161,9 +198,20 @@ def test_construction_resolves_snapshot_root(tmp_path):
assert portal.data_version().source == "tushare"
-def test_construction_rejects_absolute_path_source(tmp_path):
- with pytest.raises(InvalidDataError, match="directory name"):
- HqDataCsvPortal(source=str(tmp_path / "ricequant"))
+def test_construction_accepts_absolute_path_source_and_splits(tmp_path):
+ """Task 22.1: `HqDataCsvPortal(source=/abs/path)` splits into (parent, name)."""
+ snap = _build_snapshot(
+ tmp_path,
+ "tushare",
+ [("20240102", "Y"), ("20240103", "Y")],
+ {"20240102": [], "20240103": []},
+ {"20240102": [], "20240103": []},
+ {"20240102": [], "20240103": []},
+ )
+ portal = HqDataCsvPortal(source=str(snap), data_root="ignored")
+ assert portal.source_name() == "tushare"
+ assert portal.snapshot_root() == snap
+ assert portal.data_root() == tmp_path
def test_construction_rejects_empty_source():
diff --git a/tests/data/test_portal_parity.py b/tests/data/test_portal_parity.py
index a09bd98..b255a4d 100644
--- a/tests/data/test_portal_parity.py
+++ b/tests/data/test_portal_parity.py
@@ -81,6 +81,17 @@ def _memory_with_gaps() -> InMemoryDataPortal:
p.add_bar(_make_bar("000001.SZ", "20240103", "20.50"))
p.add_bar(_make_bar("000001.SZ", "20240104", "20.25"))
p.add_bar(_make_bar("000001.SZ", "20240105", "21.00"))
+ # Factors mirror the bars: 600000.SH has factor rows for every
+ # trading day (so callers cannot infer a gap from a missing factor
+ # row — they would need to compare against `get_calendar`); 000001.SZ
+ # only carries factors on 20240102 / 20240105, exercising the
+ # in-window factor gap.
+ p.add_factor("600000.SH", "20240102", Decimal("1.0"))
+ p.add_factor("600000.SH", "20240103", Decimal("1.0"))
+ p.add_factor("600000.SH", "20240104", Decimal("1.0"))
+ p.add_factor("600000.SH", "20240105", Decimal("1.05"))
+ p.add_factor("000001.SZ", "20240102", Decimal("1.0"))
+ p.add_factor("000001.SZ", "20240105", Decimal("1.02"))
return p
@@ -122,6 +133,16 @@ def _write_stock_daily(root: Path, date: str, rows: list[dict]) -> None:
(target / f"{date}.csv").write_text("\n".join(lines) + "\n", encoding="utf-8")
+def _write_stock_factor(root: Path, date: str, rows: list[dict]) -> None:
+ """Each row: {symbol, date, factor}."""
+ target = root / "stock_factor"
+ target.mkdir(parents=True, exist_ok=True)
+ lines = ["symbol,date,factor"]
+ for row in rows:
+ lines.append(f"{row['symbol']},{row['date']},{row['factor']}")
+ (target / f"{date}.csv").write_text("\n".join(lines) + "\n", encoding="utf-8")
+
+
def _csv_with_gaps(tmp_path: Path) -> HqDataCsvPortal:
"""Same fixture as `_memory_with_gaps` but on disk.
@@ -130,6 +151,9 @@ def _csv_with_gaps(tmp_path: Path) -> HqDataCsvPortal:
- stock_daily/20240103.csv only has 000001.SZ (600000.SH suspended).
- stock_daily/20240104.csv only has 000001.SZ (600000.SH still suspended).
- stock_daily/20240105.csv has both 600000.SH and 000001.SZ.
+ - stock_factor/{date}.csv mirrors the bar layout: 600000.SH
+ factors on every trading day, 000001.SZ factors on 20240102
+ and 20240105 only.
"""
snap = tmp_path / "tushare"
snap.mkdir(parents=True, exist_ok=True)
@@ -237,6 +261,29 @@ def _csv_with_gaps(tmp_path: Path) -> HqDataCsvPortal:
},
],
)
+ # Factors: 600000.SH on every trading day, 000001.SZ on 20240102
+ # and 20240105 only (matches `_memory_with_gaps`). Build each
+ # daily factor file in one pass — `_write_stock_factor` writes the
+ # whole file, so calling it multiple times for the same date
+ # would clobber earlier rows and break parity.
+ factor_rows = {
+ "20240102": [
+ {"symbol": "600000.SH", "date": "20240102", "factor": "1.0"},
+ {"symbol": "000001.SZ", "date": "20240102", "factor": "1.0"},
+ ],
+ "20240103": [
+ {"symbol": "600000.SH", "date": "20240103", "factor": "1.0"},
+ ],
+ "20240104": [
+ {"symbol": "600000.SH", "date": "20240104", "factor": "1.0"},
+ ],
+ "20240105": [
+ {"symbol": "600000.SH", "date": "20240105", "factor": "1.05"},
+ {"symbol": "000001.SZ", "date": "20240105", "factor": "1.02"},
+ ],
+ }
+ for date, rows in factor_rows.items():
+ _write_stock_factor(snap, date, rows)
return HqDataCsvPortal(source="tushare", data_root=str(tmp_path))
@@ -462,14 +509,118 @@ def test_bars_snapshot_missing_vs_per_symbol_missing_classification():
assert issubclass(SnapshotFileMissingError, MissingDataError)
-def test_factor_rejects_zero_in_both_portals(tmp_path):
- """Both portals reject non-positive factor values."""
- from hqbacktest.data.errors import InvalidDataError as _I
+# ---------------------------------------------------------------------------
+# Factor parity (task 24: `test_factor_rejects_zero_in_memory_portal`
+# below only asserted the memory portal rejected 0 independently; it did
+# NOT exercise the parity of returned values. These tests run the same
+# fixture through both portals and assert identical return values /
+# exception types.)
+# ---------------------------------------------------------------------------
+
+
+def test_factor_window_returns_identical_series(tmp_path):
+ """Task 24: `get_factor` must return the same `(date, factor)` list
+ on both portals for the same fixture (mirrors `test_bars_*_agrees`).
+ """
+ mem = _memory_with_gaps()
+ csv = _csv_with_gaps(tmp_path)
+ mem_factors = mem.get_factor("600000.SH", "20240102", "20240105")
+ csv_factors = csv.get_factor("600000.SH", "20240102", "20240105")
+ # Convert Decimal tuples to comparable lists so we don't get bitten
+ # by Decimal identity vs equality across portals.
+ assert [(d, str(f)) for d, f in mem_factors] == [
+ (d, str(f)) for d, f in csv_factors
+ ]
+ assert [d for d, _ in mem_factors] == [
+ "20240102",
+ "20240103",
+ "20240104",
+ "20240105",
+ ]
+
+
+def test_factor_per_symbol_gap_matches_between_portals(tmp_path):
+ """Task 24: a symbol with a sparse factor series (factors on
+ 20240102 and 20240105 only) must return the SAME two rows on both
+ portals — not raise, not silently extend to every calendar entry.
+ """
+ mem = _memory_with_gaps()
+ csv = _csv_with_gaps(tmp_path)
+ mem_factors = mem.get_factor("000001.SZ", "20240102", "20240105")
+ csv_factors = csv.get_factor("000001.SZ", "20240102", "20240105")
+ assert [(d, str(f)) for d, f in mem_factors] == [
+ (d, str(f)) for d, f in csv_factors
+ ]
+ assert [d for d, _ in mem_factors] == ["20240102", "20240105"]
+
+
+def test_factor_empty_when_symbol_never_listed(tmp_path):
+ """Task 24: a symbol absent from both `stock_list` and the factor
+ files returns `[]` on both portals.
+ """
+ mem = _memory_with_gaps()
+ csv = _csv_with_gaps(tmp_path)
+ assert mem.get_factor("999999.SH", "20240102", "20240105") == []
+ assert csv.get_factor("999999.SH", "20240102", "20240105") == []
+
+
+def test_factor_rejects_window_start_after_end_for_both(tmp_path):
+ mem = _memory_with_gaps()
+ csv = _csv_with_gaps(tmp_path)
+ with pytest.raises(InvalidDataError):
+ mem.get_factor("600000.SH", "20240105", "20240102")
+ with pytest.raises(InvalidDataError):
+ csv.get_factor("600000.SH", "20240105", "20240102")
+
+
+def test_factor_rejects_bad_symbol_for_both(tmp_path):
+ mem = _memory_with_gaps()
+ csv = _csv_with_gaps(tmp_path)
+ with pytest.raises(InvalidDataError):
+ mem.get_factor("not-a-symbol", "20240102", "20240105")
+ with pytest.raises(InvalidDataError):
+ csv.get_factor("not-a-symbol", "20240102", "20240105")
+
+
+def test_factor_distinguishes_snapshot_missing_from_per_symbol_gap(
+ tmp_path,
+):
+ """Task 24: the parity invariant for `get_factor` mirrors
+ `get_bars` — a missing whole-day `stock_factor/{D}.csv` raises
+ `SnapshotFileMissingError`, while a per-symbol gap is silently
+ omitted from the result.
+ """
+ snap = tmp_path / "tushare"
+ snap.mkdir()
+ _write_calendar(
+ snap,
+ [("20240102", "Y"), ("20240103", "Y")],
+ )
+ _write_stock_list(snap, "20240102", ["600000.SH"])
+ _write_stock_factor(
+ snap,
+ "20240102",
+ [{"symbol": "600000.SH", "date": "20240102", "factor": "1.0"}],
+ )
+ # NOTE: no 20240103.csv at all
+ csv = HqDataCsvPortal(source="tushare", data_root=str(tmp_path))
+ with pytest.raises(SnapshotFileMissingError):
+ csv.get_factor("600000.SH", "20240102", "20240103")
+
+
+def test_factor_rejects_zero_in_memory_portal():
+ """The memory portal rejects a non-positive factor at construction
+ time (`add_factor`).
+ The CSV portal's equivalent rejection lives in
+ `tests/data/test_hqdata_portal.py` (where the daily CSV fixture
+ already exists); both funnel through the same `Decimal`-positive
+ validation in the data module, so no separate parity test is
+ needed for this path.
+ """
mem = InMemoryDataPortal(calendar=["20240102"], as_of="20240102")
- with pytest.raises(_I):
+ with pytest.raises(InvalidDataError):
mem.add_factor("600000.SH", "20240102", Decimal("0"))
- # CSV-side invalid factor is exercised in test_hqdata_portal.py.
# ---------------------------------------------------------------------------
diff --git a/tests/data/test_task15_performance.py b/tests/data/test_task15_performance.py
index f1358a1..0e47892 100644
--- a/tests/data/test_task15_performance.py
+++ b/tests/data/test_task15_performance.py
@@ -260,19 +260,22 @@ def test_perf_smoke_50_symbols_250_days_history(tmp_path):
generous CI threshold. We pick 15 s — far above the expected
sub-second runtime but well within typical GitHub Actions timeouts.
"""
+ from datetime import date, timedelta
+
symbols = [f"{600000 + i:06d}.SH" for i in range(50)]
days = []
- # 250 sequential YYYYMMDD strings starting at 20240102, skipping weekends.
- d = 20240102
+ # 250 sequential calendar-valid YYYYMMDD strings starting at 20240102.
+ # Task 22.3: the previous hand-rolled generator used an integer counter
+ # with ``if d % 100 == 32: d += 70`` to skip month boundaries. That
+ # skip only caught the day-32 rollover that follows a 31-day month,
+ # so it emitted impossible dates for shorter months (`20240230`,
+ # `20240231`, `20240431`, `20240631`), which the old (len+isdigit
+ # only) `validate_yyyymmdd` silently accepted. We now iterate via
+ # `datetime` so the calendar is correct by construction.
+ d = date(2024, 1, 2)
while len(days) < 250:
- mmdd = d % 10000
- weekday = mmdd % 7 # rough placeholder; we don't actually skip here
- days.append(f"{d:08d}")
- d += 1
- if d % 100 == 32:
- d += 70 # jump a month to keep within 250 entries
- # Truncate to exactly 250 just in case the loop overshot.
- days = days[:250]
+ days.append(d.strftime("%Y%m%d"))
+ d += timedelta(days=1)
_build_synthetic_snapshot(tmp_path, symbols=symbols, trading_days=days)
portal = HqDataCsvPortal(source="tushare", data_root=str(tmp_path))
diff --git a/tests/data/test_validators.py b/tests/data/test_validators.py
index 84e661d..f869b2c 100644
--- a/tests/data/test_validators.py
+++ b/tests/data/test_validators.py
@@ -30,6 +30,53 @@ def test_validate_yyyymmdd_rejects_bad(value):
validate_yyyymmdd(value)
+@pytest.mark.parametrize(
+ "value",
+ [
+ # Task 22.3: 8-digit but NOT a real calendar date (e.g. month 13,
+ # day 32, day 30 of February). Previously these slipped through
+ # because the validator only checked `len == 8 and isdigit()`.
+ "20241399", # month 13
+ "20240132", # January 32nd
+ "20240230", # Feb 30 in a non-leap year
+ "20230229", # Feb 29 in a non-leap year (2023 is not a leap year)
+ "20250431", # April only has 30 days
+ ],
+)
+def test_validate_yyyymmdd_rejects_impossible_calendar_dates(value):
+ """Task 22.3: 8-digit strings that are NOT real calendar dates
+ must be rejected by `validate_yyyymmdd` itself, not by an
+ unrelated check downstream.
+ """
+ with pytest.raises(InvalidDataError, match="calendar"):
+ validate_yyyymmdd(value)
+
+
+def test_validate_yyyymmdd_accepts_first_day_sentinel():
+ """The first-trading-day sentinel ``"00000000"`` must still pass.
+
+ ``datetime.strptime("00000000", "%Y%m%d")`` raises ``ValueError``
+ (year 0 is disallowed in Python ≥ 3), so `validate_yyyymmdd`
+ special-cases it before the calendar check. `DataView` / `Scheduler`
+ use it to mean "no data visible yet" on the very first trading day.
+ """
+ assert validate_yyyymmdd("00000000") == "00000000"
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ "20200229", # leap year Feb 29
+ "20000229", # century leap year Feb 29
+ "20240131", # 31-day month last day
+ "20240430", # 30-day month last day
+ ],
+)
+def test_validate_yyyymmdd_accepts_real_calendar_dates(value):
+ """Task 22.3: real-but-edge-case calendar dates must still pass."""
+ assert validate_yyyymmdd(value) == value
+
+
@pytest.mark.parametrize("value", [None, 20240102, ["20240102"]])
def test_validate_yyyymmdd_rejects_non_string(value):
with pytest.raises(InvalidDataError):
diff --git a/tests/engine/test_context_full.py b/tests/engine/test_context_full.py
index 84768aa..9ea18f5 100644
--- a/tests/engine/test_context_full.py
+++ b/tests/engine/test_context_full.py
@@ -487,7 +487,7 @@ def test_set_universe_locked_after_initialize():
ctx.set_universe(["600000.SH"])
-def test_order_type_allow_list_cannot_be_bypassed_via_helpers():
+def test_order_type_allow_list_cannot_be_skipped_via_helpers():
"""Contract rule 7: every order path must reject non-MARKET types."""
ctx, _, _ = _ctx()
with pytest.raises(UnsupportedOrderTypeError):
diff --git a/tests/engine/test_metrics.py b/tests/engine/test_metrics.py
index ce9ecb2..8352caf 100644
--- a/tests/engine/test_metrics.py
+++ b/tests/engine/test_metrics.py
@@ -326,9 +326,28 @@ def test_no_fills_records_trade_count_zero_and_turnover_zero():
def test_sharpe_with_risk_free_rate_and_simple_equity():
- # Two days: equity 100 -> 110. Daily return 0.10.
- # daily_volatility = stdev([0.10]) = 0 (single value)
- # -> sharpe None.
+ """Task 23: a 2-day equity curve with two distinct `daily_return`
+ samples produces a defined Sharpe ratio.
+
+ Pre-fix this test asserted `sharpe_ratio is None` because the
+ old `_daily_returns` re-derived the series from `total_equity`,
+ seeded `[Decimal("0")]`, and effectively dropped the day-0
+ return. With the fix, the series passed to `stdev` is the
+ engine's `EquityPoint.daily_return` (two values), so the
+ volatility is defined and Sharpe can be computed against a
+ non-zero risk-free rate.
+
+ Hand calculation:
+ daily_returns = [0.0, 0.10]
+ mean = 0.05
+ sample variance = ((-0.05)^2 + 0.05^2) / (2-1) = 0.005
+ stdev = sqrt(0.005) ≈ 0.07071 (NOT 0.10 — sample stddev
+ divides by n-1 and takes
+ the square root)
+ annualized_volatility ≈ 0.07071 * sqrt(252)
+ annualized_return ≈ (1.10)^(2/252) - 1 ≈ 0.000757
+ sharpe = (0.000757 - 0.025) / (0.07071 * sqrt(252)) < 0
+ """
eq = [
EquityPoint(
"20240102",
@@ -351,14 +370,23 @@ def test_sharpe_with_risk_free_rate_and_simple_equity():
m = compute_metrics(
equity_curve=eq, fills=[], initial_cash=Decimal("100000"), config=cfg
)
- # 1 day of returns -> vol None -> sharpe None.
- assert m.sharpe_ratio is None
+ # Task 23: 2 distinct samples -> volatility is defined.
+ assert m.daily_volatility is not None
+ # Hand-calculated: stdev([0, 0.10], ddof=1) = sqrt(0.005)
+ # ≈ 0.07071.
+ expected = Decimal("0.07071")
+ assert abs(m.daily_volatility - expected) < Decimal("0.0001")
+ # Sharpe is computed; risk_free_rate is 2.5% so it is negative
+ # (annualised return is well below the risk-free rate over 2
+ # trading days).
+ assert m.sharpe_ratio is not None
+ assert m.sharpe_ratio < Decimal("0")
def test_sharpe_with_three_days_of_varying_returns():
# 100 -> 110 -> 121 -> 110. Returns: 0, 0.10, 0.10, -0.0909.
- # daily_returns[1:] = [0.10, 0.10, -0.0909...]
- # stdev (sample) of those = 0.1087...
+ # Task 23: stdev (sample) of the full series [0, 0.10, 0.10, -0.0909]
+ # ≈ 0.0918 (the first day's return is now included, not sliced off).
eq = [
EquityPoint(
"20240102",
diff --git a/tests/engine/test_task16_matching.py b/tests/engine/test_task16_matching.py
index 5854d69..ebd86b1 100644
--- a/tests/engine/test_task16_matching.py
+++ b/tests/engine/test_task16_matching.py
@@ -5,8 +5,8 @@
orders ("卖旧买新" rotation).
* SELL orders are not lot-rounded: odd-lot SELLs (含零股) succeed and
can fully flatten a position via `order_target(symbol, 0)`.
- * SELL 150 must NOT be silently shrunk to 100 (静默篡改策略意图
- violates the contract).
+ * SELL 150 must NOT be silently shrunk to 100 (silently altering
+ the strategy's intent violates the contract).
* Same-day BUY-then-SELL vs SELL-then-BUY at the same price produce
the same realized_pnl for the SELL position (fee differences are
not realized_pnl).
diff --git a/tests/engine/test_task17_metrics.py b/tests/engine/test_task17_metrics.py
index 0f424a6..ee14dee 100644
--- a/tests/engine/test_task17_metrics.py
+++ b/tests/engine/test_task17_metrics.py
@@ -218,27 +218,46 @@ def test_single_day_volatility_is_none():
assert any("requires >= 2" in n for n in m.notes)
-def test_two_day_volatility_is_none_when_only_one_return():
- """A 2-day equity curve has exactly one daily return -> stdev on a
- single value raises StatisticsError; the metric must be `None`,
- not 0, and `sharpe_ratio` must therefore also be `None`.
+def test_two_day_volatility_uses_both_daily_returns():
+ """Task 23: a 2-day equity curve with two distinct daily returns
+ must produce a non-`None` `daily_volatility`.
+
+ Hand-calculated scenario:
+ Day 1: total_equity = 91000 (down 9% from 100000)
+ -> daily_return = -0.09
+ Day 2: total_equity = 96005 (up 5.5% from 91000)
+ -> daily_return = +0.055
+
+ Pre-fix: `_daily_returns` re-derived the series from
+ `total_equity`, with `[Decimal("0")]` as the day-0 seed, so the
+ day-1 return was silently dropped. With only one return in
+ `returns[1:]` stdev failed -> `daily_volatility is None`.
+
+ Post-fix: `compute_metrics` reads `EquityPoint.daily_return`
+ directly, so both samples reach the stdev call:
+ stdev([-0.09, 0.055], ddof=1) = sqrt(((−0.0725)² + 0.0725²) / 1)
+ = sqrt(0.0105125)
+ ≈ 0.102531...
+
+ The contract: `daily_volatility` MUST see first-day P&L the same
+ way `max_drawdown` does (task 17 already wired that side).
"""
eq = [
EquityPoint(
"20240102",
- Decimal("100000"),
- Decimal("0"),
- Decimal("100000"),
- Decimal("0"),
+ Decimal("91000"),
Decimal("0"),
+ Decimal("91000"),
+ Decimal("-0.09"),
+ Decimal("0.09"),
),
EquityPoint(
"20240103",
- Decimal("90000"),
- Decimal("0"),
- Decimal("110000"),
- Decimal("0.10"),
+ Decimal("96005"),
Decimal("0"),
+ Decimal("96005"),
+ Decimal("0.055"),
+ Decimal("0.04"),
),
]
m = compute_metrics(
@@ -247,9 +266,24 @@ def test_two_day_volatility_is_none_when_only_one_return():
initial_cash=Decimal("100000"),
config=MetricsConfig(),
)
- assert m.daily_volatility is None
- assert m.sharpe_ratio is None
- assert any("requires >= 2" in n for n in m.notes)
+ # Both samples reached stdev -> volatility is defined.
+ assert m.daily_volatility is not None
+ # Hand-calculated value: sqrt(0.0105125) ≈ 0.102531
+ expected = Decimal("0.10253")
+ diff = abs(m.daily_volatility - expected)
+ assert diff < Decimal(
+ "0.0001"
+ ), f"daily_volatility={m.daily_volatility} != {expected} (diff={diff})"
+ # Annualised volatility = daily * sqrt(252).
+ from math import sqrt
+
+ expected_ann = m.daily_volatility * Decimal(str(sqrt(252)))
+ assert abs(m.annualized_volatility - expected_ann) < Decimal("0.0001")
+ # Total return = 96005 / 100000 - 1 = -0.03995
+ assert abs(m.total_return - Decimal("-0.03995")) < Decimal("0.000001")
+ # Chained-product identity: ∏(1 + daily_return) == 1 + total_return
+ chained = (Decimal("1") + Decimal("-0.09")) * (Decimal("1") + Decimal("0.055"))
+ assert abs(chained - (Decimal("1") + m.total_return)) < Decimal("0.0001")
# ---------------------------------------------------------------------------
diff --git a/tests/engine/test_task18_isolation.py b/tests/engine/test_task18_isolation.py
index 4bb2ff1..2dccfc4 100644
--- a/tests/engine/test_task18_isolation.py
+++ b/tests/engine/test_task18_isolation.py
@@ -7,7 +7,7 @@
original quantity, audit log still records the original
`avg_fill_price` and `fill_ids`).
* `DataView.portal` is no longer publicly accessible (the
- strategy cannot bypass `visible_through` by calling
+ strategy cannot read future data by calling
`view.portal.get_bars(sym, start, future_date)`).
* `set_universe(...)` actually constrains trading: orders against
a symbol outside the declared universe are rejected with a
@@ -116,7 +116,7 @@ def on_bar(self, context, data):
engine = BacktestEngine(_cfg(), strategy=TryToTamper(), portal=_two_symbol_portal())
engine.run()
# The order for 100 shares of 600000.SH on day 1 must have filled at
- # exactly 100, NOT the tampered 9999.
+ # exactly 100, NOT the inflated 9999.
fills = [e for e in engine.event_log.all() if e.phase is EventType.ORDER_FILLED]
assert any(
"qty=100" in (e.detail or "") for e in fills
@@ -131,7 +131,7 @@ def on_bar(self, context, data):
def test_data_view_portal_is_not_publicly_accessible():
"""The portal attribute must not be reachable from outside the
- data layer. Strategies must not bypass `visible_through` by
+ data layer. Strategies must not read future data by
reading `view.portal.get_bars(sym, start, future_date)`.
"""
p = InMemoryDataPortal(
@@ -271,8 +271,8 @@ def test_order_fill_ids_is_immutable():
a frozen Order cannot append/clear it in place (task 18).
A frozen dataclass only blocks attribute *reassignment*; a `list`
- field would still be mutable in place. Switching to `tuple` closes
- that last escape hatch.
+ field would still be mutable in place. Switching to `tuple` removes
+ the last in-place mutation path.
"""
from hqbacktest.domain.order import Order
diff --git a/tests/engine/test_task8_integration.py b/tests/engine/test_task8_integration.py
index 2f87db9..ac48831 100644
--- a/tests/engine/test_task8_integration.py
+++ b/tests/engine/test_task8_integration.py
@@ -238,7 +238,7 @@ def initialize(self, context):
context.set_universe(["600000.SH"])
def on_bar(self, context, data):
- # `Context.order` already rounds, so bypass via a raw Order.
+ # `Context.order` already rounds, so verify with a raw Order.
from hqbacktest.domain.order import Order
order = Order(
diff --git a/tests/test_package.py b/tests/test_package.py
index a984877..7d44715 100644
--- a/tests/test_package.py
+++ b/tests/test_package.py
@@ -13,13 +13,78 @@ def test_import_hqbacktest():
def test_version_is_non_empty_string():
- """`__version__` should be a non-empty string matching pyproject.toml."""
+ """`__version__` should be a non-empty string."""
import hqbacktest
assert isinstance(hqbacktest.__version__, str)
assert hqbacktest.__version__
+def test_version_matches_pyproject():
+ """`hqbacktest.__version__` MUST match the `version` field in
+ `pyproject.toml`, AND both must look like a real release version
+ (not e.g. an empty placeholder or a string with stray whitespace).
+
+ Task 22 / 23 review (2026-08-25): the prior version-sync guard
+ was `test_version_is_non_empty_string`, which only asserted that
+ `__version__` was a non-empty string. The docstring claimed it
+ matched `pyproject.toml` but the body never actually compared
+ the two — that gap allowed `src/hqbacktest/__init__.py::__version__`
+ to stay at `0.1.1` for two release cycles (v0.1.2, v0.1.3) even
+ after `pyproject.toml` advanced, which would have made every
+ `run_metadata.json` record the wrong engine version.
+
+ This test parses `[project].version` from the in-repo
+ `pyproject.toml` and asserts byte equality with the package
+ `__version__`. The dependency on `tomllib` is stdlib on Python
+ ≥ 3.11 (project's `requires-python` is `>=3.10`); on 3.10 we
+ fall back to the `tomli` runtime dep already declared in
+ `pyproject.toml`.
+ """
+ import re
+ from pathlib import Path
+
+ import hqbacktest
+
+ pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
+ text = pyproject_path.read_text(encoding="utf-8")
+ # Minimal parser — avoid pulling a TOML lib just for one literal.
+ # `pyproject.toml` is small and well-formed; this regex finds the
+ # `[project]` table and pulls its `version = "X.Y.Z"` line. If the
+ # layout ever changes (e.g. `version` moves to a sub-table), the
+ # `None` fallback below will surface a clear error.
+ match = re.search(
+ r'^\[project\][^\[]*?version\s*=\s*["\']([^"\']+)["\']',
+ text,
+ flags=re.MULTILINE | re.DOTALL,
+ )
+ assert match is not None, (
+ f"could not locate `version = " "in [project] of {pyproject_path}"
+ )
+ pyproject_version = match.group(1)
+ # Shape guard: reject obvious garbage like "", " ", "v0.1.0" or
+ # "0.1" so a typo at release time is caught before `run_metadata.json`
+ # is shipped with a meaningless version. PEP 440 release segments
+ # look like `\d+(\.\d+)*`; we don't enforce the full spec, just
+ # the shape that has held since v0.1.
+ semver_shape = re.compile(r"^\d+(\.\d+)*$")
+ for label, value in (
+ ("hqbacktest.__version__", hqbacktest.__version__),
+ ("pyproject.toml [project].version", pyproject_version),
+ ):
+ assert value == value.strip(), f"{label}={value!r} has stray whitespace"
+ assert semver_shape.match(value), (
+ f"{label}={value!r} does not look like N.N[.N...] (got "
+ f"semver-shape regex check); update `test_version_matches_pyproject`"
+ f" if a non-numeric segment (rc/post/dev) is now in use"
+ )
+ assert hqbacktest.__version__ == pyproject_version, (
+ f"version drift: hqbacktest.__version__={hqbacktest.__version__!r} "
+ f"but pyproject.toml [project].version={pyproject_version!r}; "
+ f"both must be updated together when releasing"
+ )
+
+
def test_public_api_includes_domain_models():
"""After task 9, the package re-exports domain, data and engine layers."""
import hqbacktest