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