From d21a518063cd9a85f92adb872360797ba3665d23 Mon Sep 17 00:00:00 2001 From: HowieMen Date: Tue, 25 Aug 2026 17:44:45 +0800 Subject: [PATCH] docs: drop process artefacts, describe the project on its own terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader-facing artefacts (README, contract) and in-code comments used to expose the project's development history — task numbers, hotfix versions, audit trail — alongside the actual content. A new reader does not care which TODO.md row a behaviour came from; they want to know what the project *is*, how to use it, and what its limits are. This commit strips the process labels so the project reads as a normal release: - Delete CHANGELOG.md and TODO.md (both dev-process artefacts). - Rename eight `test_task{N}_*.py` files to describe the area they cover (e.g. `test_strategy_isolation.py`, `test_metrics_baseline.py`). - Remove ~140 `Task X:` / `task X:` / `TODO.md` / `CHANGELOG.md` references from src/ and tests/. Keep the design intent, drop the development label. - Rewrite the README's "项目状态" section as a "当前能力(按模块)" + "已知限制" pair: the project described by what it does and what it does not, not by which task made it so. - Compress mvp-contract.md §9 "修订记录" from a per-task changelog into a single, deduplicated record of contract changes; remove the "对应 TODO.md 任务 1" header. No API or runtime behavior change. 668 unit tests + 4 real-data integration tests stay green; `black --check` clean; `python -m build` produces a v0.1.4 sdist + wheel. --- CHANGELOG.md | 323 ------------------ README.md | 71 ++-- docs/design/mvp-contract.md | 41 ++- examples/buy_and_hold.py | 2 +- examples/moving_average.py | 2 +- src/hqbacktest/cli/__init__.py | 2 +- src/hqbacktest/cli/__main__.py | 26 +- src/hqbacktest/cli/config.py | 15 +- src/hqbacktest/cli/runner.py | 55 ++- src/hqbacktest/data/cache.py | 8 +- src/hqbacktest/data/data_view.py | 53 +-- src/hqbacktest/data/hqdata_portal.py | 31 +- src/hqbacktest/data/memory_portal.py | 10 +- src/hqbacktest/data/validators.py | 10 +- src/hqbacktest/domain/adjustment.py | 8 +- src/hqbacktest/domain/enums.py | 6 +- src/hqbacktest/domain/fill.py | 6 +- src/hqbacktest/domain/order.py | 29 +- src/hqbacktest/engine/broker.py | 8 +- src/hqbacktest/engine/config.py | 18 +- src/hqbacktest/engine/context.py | 70 ++-- src/hqbacktest/engine/corporate_actions.py | 14 +- src/hqbacktest/engine/cost_model.py | 4 +- src/hqbacktest/engine/engine.py | 73 ++-- src/hqbacktest/engine/iterator.py | 8 +- src/hqbacktest/engine/metrics.py | 43 +-- src/hqbacktest/engine/result.py | 3 +- src/hqbacktest/engine/rule_set.py | 2 +- src/hqbacktest/engine/scheduler.py | 31 +- src/hqbacktest/engine/strategy.py | 18 +- tests/cli/test_cli.py | 48 ++- ...t_task20_cli.py => test_cli_validation.py} | 24 +- ...ance.py => test_data_layer_performance.py} | 33 +- ...antics.py => test_data_layer_semantics.py} | 10 +- tests/data/test_hqdata_portal.py | 29 +- tests/data/test_memory_portal.py | 6 +- tests/data/test_portal_parity.py | 43 +-- tests/data/test_validators.py | 14 +- ...8_integration.py => test_a_share_rules.py} | 2 +- tests/engine/test_adjustment.py | 10 +- tests/engine/test_broker.py | 14 +- tests/engine/test_config_and_context.py | 4 +- tests/engine/test_context_full.py | 11 +- tests/engine/test_engine.py | 23 +- ...gnostics.py => test_factor_diagnostics.py} | 2 +- tests/engine/test_intents.py | 8 +- tests/engine/test_iterator.py | 4 +- ...16_matching.py => test_matching_ledger.py} | 12 +- tests/engine/test_metrics.py | 24 +- ...17_metrics.py => test_metrics_baseline.py} | 22 +- tests/engine/test_result_export.py | 12 +- ...solation.py => test_strategy_isolation.py} | 4 +- tests/examples/strategies/__init__.py | 2 +- tests/examples/test_buy_and_hold.py | 2 +- tests/examples/test_moving_average.py | 2 +- tests/fixtures/sample_data.py | 2 +- tests/integration/__init__.py | 4 +- tests/integration/_harness.py | 2 +- .../test_real_data_buy_and_hold.py | 2 +- tests/integration/test_real_data_first_day.py | 10 +- .../test_real_data_ma_5_symbols.py | 4 +- tests/integration/test_real_data_suspended.py | 2 +- tests/test_package.py | 16 +- 63 files changed, 533 insertions(+), 864 deletions(-) delete mode 100644 CHANGELOG.md rename tests/cli/{test_task20_cli.py => test_cli_validation.py} (95%) rename tests/data/{test_task15_performance.py => test_data_layer_performance.py} (93%) rename tests/data/{test_task14_semantics.py => test_data_layer_semantics.py} (97%) rename tests/engine/{test_task8_integration.py => test_a_share_rules.py} (99%) rename tests/engine/{test_task19_factor_diagnostics.py => test_factor_diagnostics.py} (99%) rename tests/engine/{test_task16_matching.py => test_matching_ledger.py} (97%) rename tests/engine/{test_task17_metrics.py => test_metrics_baseline.py} (95%) rename tests/engine/{test_task18_isolation.py => test_strategy_isolation.py} (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index be1c868..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,323 +0,0 @@ -# Changelog - -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 -review (see [TODO.md](./TODO.md)「v0.1 评审结论」). All changes are -backward-compatible unless called out below. - -### Added -- **Data layer hardening (task 14):** `get_bars` / `get_factor` allow - per-day gaps in the window; `SnapshotFileMissingError` (subclass of - `MissingDataError`) distinguishes a missing whole-day snapshot from - a per-symbol gap. `current_price` walks back up to 20 trading days - for the most recent valid close. The first-trading-day sentinel - `visible_through="00000000"` no longer raises; `history` returns - `[]` and `current_price` returns `None`. `InMemoryDataPortal` - drops its forward-walk universe fallback to match the CSV portal's - per-date semantics. `.BJ` symbols are excluded by default - (`include_bj=True` opt-in). `Bar.volume` is documented as **手**. - Defensive copies returned from cached lists. -- **Data layer performance (task 15):** per-day file cache - (`{date: {symbol: Bar}}`) plus per-symbol cumulative sequences - (`_symbol_bars[symbol]`) with `bisect` slicing. The 5-symbol - moving-average strategy over the v0.1.1-calibrated 139-day window - finishes well under the 60-second budget. -- **Match & ledger semantics (task 16):** `SimulatedBroker.match` - matches all SELL orders first, then BUYs, with rolling cash so - same-day "卖旧买新" rotations are not falsely rejected for cash. - SELL orders are no longer lot-rounded; `order_target(symbol, 0)` - can flatten a position that contains odd-lot shares. BUY-only lot - rounding is preserved. 7 contract-level invariants are pinned - in `docs/design/mvp-contract.md` §3.4 (T+1 whole-order rejection, - `realized_pnl` excludes fees, `ROUND_HALF_EVEN`, etc.). -- **Equity curve & metrics baseline (task 17):** first-day P&L now - flows into `daily_return` and `drawdown` (anchored to - `initial_cash`), so the chained-product identity `∏(1+daily_return) - == 1+total_return` holds for any run length. `daily_volatility` - returns `None` for runs with fewer than 2 daily returns (no more - misleading 0 / `nan`). `metrics.py` rebuilds `Decimal` via - `Decimal(str(...))` to avoid `Decimal(float)` artifacts. -- **Strategy isolation & audit trail (task 18):** `Order` is now - `@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 - 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 - `analyze_factor_series` against holdings-period factor series - with a 0.1% relative jump band. Any holding-period factor jump emits - a `DATA_WARNING` event and a `FactorDiagnostic` entry; the - diagnostics are observability-only — cash, position and equity are - byte-identical with the no-diagnostics baseline. CLI prints a one-line - summary at run end when diagnostics fired. **`adjustment_policy=none` - still excludes dividends from the NAV** (contract task 9 invariant); - the diagnostics surface this bias; long-window NAV remains - unsuitable for return estimation. -- **CLI first-mile + documentation honesty (task 20):** - `hqbacktest run` (the console script) prepends the config file's - 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` 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 - records the hqbacktest package's own commit (not the user's cwd). - README "项目状态" / "命令行" / "错误信息" / "包布局" sections - brought into line with the implementation. `BaseStrategy.__init__` - accepts and stores `**kwargs` so `[strategy].kwargs` round-trips. - -### Added (test infrastructure) -- **`tests/integration/`** (task 21): four real-data smoke scenarios - against `~/.hqdata/tushare`, auto-skipped when the snapshot is - missing (no credentials, no network): - 1. buy_and_hold across 600000.SH's 2026-07-16 dividend ex-date — the - task-14/19 contract (`factor jump 16.5935 → 17.3774` produces a - `DATA_WARNING`) is enforced end-to-end. - 2. 5-symbol moving-average strategy over the full 139-day window — - byte-deterministic across two runs and below the 60-second budget. - 3. universe containing the known suspended symbol `000008.SZ` - (suspended 2026-07-07..2026-07-13) — no crash, the fallback-close - valuation `DATA_WARNING` is recorded. - 4. first-trading-day `before_trading_start` reads `current_price` — - returns `None` against the sentinel without crashing. - -### Constraints (unchanged from v0.1) -- `adjustment_policy` MUST be `"none"`. -- Market orders only; limit / stop / partial fills raise - `UnsupportedOrderTypeError` / are rejected. -- Only Chinese A-share common stocks (沪深); no ST / 涨跌停 / 新股 / - 北交所 / 融资融券 / 期权 support. -- Default A-share cost model only. -- CSV-only data ingestion via hqdata CLI; no network calls; no tokens - read or written. - -### Known limitations carried forward -- `adjustment_policy=none` means the NAV systematically underestimates - cross-ex-date windows (no dividend accounting). Task-19 factor - diagnostics surface the jumps but do not fabricate dividends. -- `Position.update_buy` uses simple-average cost (not FIFO). -- `BacktestResult.metrics` reconstruction on `load()` is best-effort. -- README's "路线图" section lists capabilities deferred to v0.2+. - -## [0.1.0] - 2026-08-23 - -First public release of `hqbacktest`. The project implements tasks 1-13 of -[TODO.md](./TODO.md). - -### Added - -- **Domain** (task 3): `Order` / `Fill` / `Position` / `Portfolio` / - `AccountSnapshot` / `CorporateActionAdjustment` dataclasses, order state - machine with `ACCEPTED` → `PENDING` → `FILLED` (or `REJECTED` / - `CANCELLED`), `Decimal` precision helpers with `ROUND_HALF_EVEN`. -- **Data** (task 4): `MarketDataPortal` Protocol with two implementations: - - `HqDataCsvPortal` reads only CSV files dropped by the hqdata CLI at - `{data_root}/{source}/`; no network, no SDK imports. - - `InMemoryDataPortal` for unit tests and example scripts. - `DataView` enforces per-phase `visible_through` cutoffs (contract §4). -- **Engine** (tasks 5-10): five-phase event clock - (`SESSION_START` → `BEFORE_TRADING_START` → `OPEN_MATCH` → `BAR_CLOSE` → - `AFTER_TRADING_END`); `SimulatedBroker` for OPEN_MATCH market orders; - `TradingRuleSet` with six default v0.1 rules (`LongOnly`, `LotSize`, - `NonTradingDay`, `InvalidPrice`, `InsufficientCash`, `T1Sellable`); - `CostModel` with explicit A-share fees (0.025% commission + 5 CNY floor, - 0.1% stamp tax on SELL, 0 transfer fee); `BaseStrategy` lifecycle with - read-only `Context`; `BacktestResult` exposing `equity_curve`, - `orders_table`, `fills_table`, `positions_table`, `costs_table`, - `PerformanceMetrics`, `events.jsonl`, `data_version`, - `factor_diagnostics`; `save(dir)` / `load(dir)` round-trip. -- **CLI** (task 12): `hqbacktest run --config FILE --output DIR` with TOML - schema, validation, and per-run output directory containing - `config.toml`, `run_metadata.json`, `events.jsonl`, the five CSVs and - `summary.json`. Never writes tokens, environment variables, or absolute - local paths to the output. -- **Examples** (task 11): `examples/buy_and_hold.py` and - `examples/moving_average.py` demonstrate the full public API on a 7-day - `InMemoryDataPortal` fixture. -- **Continuous integration & release** (task 13): `.github/workflows/ci.yml` - runs on Python 3.10/3.11/3.12 and enforces `black --check`, `pytest` - (with a ≥80% branch-coverage gate via `pytest-cov`), the end-to-end - examples, a CLI smoke run, and `python -m build` (sdist + wheel). - -### Constraints (v0.1) - -- `adjustment_policy` MUST be `"none"`. `factor_total_return` and any - other value are rejected at config validation with a clear reason. -- Market orders only; limit/stop orders raise `UnsupportedOrderTypeError`. -- Only Chinese A-share common stocks (沪深); no ST / 涨跌停 / 新股 / - 北交所 / 融资融券 / 期权 support. -- Default cost model charges A-share v0.1 fees; no per-account overrides. -- CSV-only data ingestion (via hqdata CLI). hqbacktest never makes network - calls and never reads tokens. - -### Known limitations (planned for v0.2+) - -- `Position.update_buy` uses simple-average cost (not FIFO); `win_rate` is - approximate by design. -- `BacktestResult.metrics` is rebuilt on `load()` only as a JSON dict; - typed `PerformanceMetrics` reconstruction is best-effort. -- The `rule_set` field is stripped from `summary.json` to keep the file - byte-stable across runs; the live engine retains the full set. -- README's §路线图 section lists capabilities deferred to v0.2+. - -[0.1.1]: https://github.com/HonestQuantTech/hqbacktest/releases/tag/v0.1.1 -[0.1.0]: https://github.com/HonestQuantTech/hqbacktest/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 26982e2..53b3e10 100644 --- a/README.md +++ b/README.md @@ -5,21 +5,44 @@

-## 项目状态 - -`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.2 / v0.1.3 / v0.1.4。 - -> **⚠️ v0.1.1 仍未做:** 分红会计 / 涨跌停 / 新股 / 北交所 / 限价单 / 指数基准 / 多账户 / 分钟线 / 实盘对接 —— 见 [CHANGELOG.md](CHANGELOG.md) 与 [TODO.md](TODO.md) 「发布后再排期的增强项」。`adjustment_policy=none` 下跨除权日的净值仍**系统性低估**(少分红现金),任务 19 因子诊断会显式记录此类跳变,但长区间结果不可直接用于收益评估。 - -- **不在 v0.1.1 内(路线图):** 限价 / 止损单、成交量参与率、部分成交;ST / 涨跌停 / 新股 / 北交所规则;融资融券 / 期货 / 期权 / 多账户;指数基准与归因;Notebook 与远程策略入口;JSON Schema 校验以外的策略注册中心;交互图表 / HTML 报告。 - -本文描述的用法、命令行和功能表与 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md) 一致;其中的示例已经可以按 §示例 章节运行。功能表区分「已实现」与「路线图 / 计划中」;任何契约变更必须先更新契约文档。开发顺序与 AI 协作提示见 [TODO.md](TODO.md)。 +`hqbacktest` 是 A 股日频策略回测与交易模拟引擎。它给量化研究者一个**确定性的、可复现的、与实盘严格隔离**的回测沙盒:策略只通过受控的 `Context` / `DataView` 读写数据、提交订单和查询组合,不接触数据源实现或内部账本;引擎负责时钟、撮合、规则与成本、指标和可审计的结果导出。 + +## 当前能力(按模块) + +- **领域模型:** 不可变 `Order`(含 `frozen=True` 与 `tuple` `fill_ids`)、`Fill` / `Position` / `Portfolio` / `AccountSnapshot`、订单状态机(`ACCEPTED → PENDING → FILLED` 或 `REJECTED` / `CANCELLED`)、`Decimal` 精度 + `ROUND_HALF_EVEN`。 +- **数据层:** `MarketDataPortal` Protocol,两个实现——`HqDataCsvPortal` 只读 `~/.hqdata/{source}/` 下的 CSV 快照(`calendar.csv` + `stock_list|stock_daily|stock_factor/{YYYYMMDD}.csv`);`InMemoryDataPortal` 用于测试与示例。`DataView` 强制 `visible_through` 边界,禁止策略读未来数据。`.BJ` 默认过滤,`include_bj=True` 保留。 +- **引擎:** 五阶段日频时钟 `SESSION_START → BEFORE_TRADING_START → OPEN_MATCH → BAR_CLOSE → AFTER_TRADING_END`;按阶段切换数据可见性,全程可追溯事件日志。`BacktestEngine` 编排策略生命周期、撮合、回测收尾。 +- **策略 API:** `BaseStrategy` 暴露 `initialize` / `before_trading_start` / `on_bar` / `after_trading_end` 四个回调;`Context` 提供只读访问(`cash` / `positions` / `universe` / `pending_orders` / `history` / `current_price` / `total_equity`)与下单意图(`order` / `order_value` / `order_target` / `order_target_value` / `order_target_percent` / `cancel_order`)。Universe 锁定、`Order` 不可变、`DataView` 越界即报错,三件事共同把策略隔离在沙盒里。 +- **撮合:** `SimulatedBroker` 在 `OPEN_MATCH` 按当日 `bar.open` 全额成交市价单。同批先 SELL 后 BUY,A 股「卖出资金当日可用」语义;SELL 允许零股(`order_target(0)` 可清零股)。 +- **规则与成本:** `TradingRuleSet` 默认六条 A 股规则(`LongOnly` / `LotSize` / `NonTradingDay` / `InvalidPrice` / `InsufficientCash` / `T1Sellable`)。`CostModel` 默认费率显式配置:0.025% 佣金(5 元保底)+ 0.1% 卖出印花税 + 0 过户费。T+1 日终结算;末交易日 `BACKTEST_ENDED` 自动撤销未成交订单。 +- **公司行为:** `adjustment_policy` 严格只接受 `"none"`;`CorporateActionProvider` 是设计草案(10 个权威字段),v0.1 不接入真实分红 /送配会计。因子诊断 (`analyze_factor_series`) 在持仓期因子跳变时(阈值 0.1%)自动生成 `DATA_WARNING` + `FactorDiagnostic`,结果写入 `summary.json` / `events.jsonl`,账本与净值完全不变。 +- **结果与指标:** `BacktestResult` 含 `equity_curve` / `orders_table` / `fills_table` / `positions_table` / `costs_table` / `PerformanceMetrics` / `events.jsonl` / `data_version` / `factor_diagnostics`。`save(dir)` / `load(dir)` 导出 CSV + JSON 并可重建。指标公式(手算见 `tests/engine/test_metrics.py` 与 `tests/engine/test_metrics_baseline.py`): + - 累计收益 `(final_equity / initial_cash) - 1` + - 日收益 `EquityPoint.daily_return`(引擎写入,首日锚定到 `initial_cash`) + - 年化收益 `(1 + total_return) ** (N / annual_trading_days) - 1`,N < 2 时为 `None` + - 日波动率 `stdev(daily_returns)`(样本,ddof=1),序列 < 2 时为 `None` + - 年化波动率 `daily_volatility * sqrt(annual_trading_days)` + - 夏普比率 `(annualized_return - risk_free_rate) / annualized_volatility`,零波动时为 `None` + - 最大回撤 `max(peak - current) / peak`(从净值曲线取) + - 换手率 `(sum(BUY 成交额) + sum(SELL 成交额)) / 2 / initial_cash`(单边均值) + - 交易次数 `len(fills)` + - 胜率 SELL 且 `成交价 > 当时平均成本` / SELL 总数(按时间顺序单遍重放),无 SELL 时为 `None` +- **CLI:** `hqbacktest run --config FILE --output DIR`(`hqbacktest/cli/` 包,TOML 配置 + 校验 + 策略导入 + 元数据 + 独立输出目录,绝不泄露凭证 / 完整环境 / 本地绝对路径)。`run_metadata.json` 记录包版本、Python 版本、平台、UTC 时间戳、`config_path`(相对 cwd)、`output_directory`(相对 cwd)、`data_root`、`adjustment_policy`、`git_commit`。 +- **示例:** `examples/buy_and_hold.py` 与 `examples/moving_average.py` 用公共 API 跑通端到端流程,并有 7 天确定性 `InMemoryDataPortal` 数据 fixture。 +- **CI:** `.github/workflows/ci.yml` 覆盖 Python 3.10 / 3.11 / 3.12、`black`、`pytest`、`pytest-cov`、示例 smoke 与 CLI smoke;`python -m build` 产出 sdist + wheel。 + +## 已知限制 + +`hqbacktest` 是个**故意做窄**的日频回测沙盒。下面这些明确**不做**——见 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md) §8「非目标」: + +- **精确分红 / 送配 / 配股 / 税费会计**(`adjustment_policy=none`)。跨除权日的回测净值会**系统性低估**(少分红现金)。`analyze_factor_series` 会显式记录跨除权日的因子跳变,但**不**据此伪造任何公司行为账目。长区间结果不可直接用于收益评估,必须先评估 factor 跳变并改用 `factor_total`(v0.1 之后的能力)。 +- **ST / 涨跌停 / 新股 / 北交所细则**:v0.1 没有可靠证券状态数据支撑,`.BJ` 默认过滤,其他规则也不实现。 +- **限价 / 止损单、成交量参与率、部分成交、滑点模型**:全部未实现。 +- **指数基准与归因**:在 `hqdata` 尚未提供指数日线前,不将基准收益率作为引擎的必备功能。 +- **多账户、多币种、融资融券、卖空、期货、期权**:v0.1 全部不支持。 +- **分钟线 / Tick / 盘中回测 / 异步多市场时钟**:v0.1 只做日频。 + +本文描述的用法、命令行和功能表与 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md) 一致;其中的示例已经可以按 §示例 章节运行。功能表区分「已实现」与「路线图 / 计划中」;任何契约变更必须先更新契约文档(见 [`docs/design/mvp-contract.md` §9](docs/design/mvp-contract.md#9-修订记录))。 ## 定位 @@ -51,7 +74,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 字节稳定;CLI 测试覆盖端到端、配置验证、可复现性与错误信息(见 `tests/cli/`);`run_metadata.json` 不写入 token / 完整环境变量;本地绝对路径在写入时脱敏为相对 cwd 的相对路径(任务 22.2) | 已实现 | +| 配置与命令行 | `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 的相对路径 | 已实现 | ## 首个可用版本的范围 @@ -197,9 +220,9 @@ hqbacktest/ - 引擎对**当前持仓**标的的因子跳变(|Δfactor/factor| > **0.1%**)自动生成 `DATA_WARNING` 事件、`FactorDiagnostic` 记录;写入 `result.factor_diagnostics`、`summary.json`、`events.jsonl`。清仓后(持仓归零)停止告警,持有期结束。 - **账本零影响**:诊断是只读观测,cash / position / equity 与无诊断时 byte-identical(`test_diagnostics_do_not_change_ledger` 锁定)。 - CLI 末尾打印一行汇总:`warning: N corporate-action factor jumps detected during holding periods; NAV excludes dividends (adjustment_policy=none), see summary.json`。 -- 跨除权日长区间结果不可用于收益评估,必须先评估 factor 跳变并改用 `factor_total_return`(v0.1.1+ 后续任务);README 显著位置保留此声明并链接诊断输出。 +- 跨除权日长区间结果不可用于收益评估,必须先评估 factor 跳变并改用精确的公司行为会计(v0.1 之后的能力);README 显著位置保留此声明并链接诊断输出。 -完整口径见 `docs/design/mvp-contract.md` §3.7。复刻 600000.SH 2026-07-16 除权案例的回归测试在 `tests/engine/test_task19_factor_diagnostics.py`。 +完整口径见 `docs/design/mvp-contract.md` §3.7。复刻 600000.SH 2026-07-16 除权案例的回归测试在 `tests/engine/test_factor_diagnostics.py`。 ## Python 用法(已实现) @@ -429,17 +452,9 @@ pytest tests/ -v `tests/examples/` 中的端到端测试会再次 `python examples/*.py` 作为子进程,确认示例在干净环境下也能跑。单元测试必须使用内存数据或 mock,不依赖网络和本地行情文件。确需在真实数据上验证 Tushare 或 RiceQuant 适配的集成测试必须在 `~/.hqdata/{name}` 目录不存在或不可读时自动跳过。每一项公开 API、订单规则、时间语义和绩效公式都应有可手算的回归测试。 -## 实施顺序 - -不要直接从策略或图表开始。建议严格按 [TODO.md](TODO.md) 的顺序推进: - -1. 固化产品契约与模块边界(详见 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md)); -2. 建立可安装、可测试的软件包; -3. 先完成领域模型、数据可见性和事件时钟; -4. 再接入策略、订单、经纪商、A 股规则和因子调整; -5. 最后完成结果、示例、CLI、CI 和发布准备。 +## 模块边界 -每完成一步,都应更新 README 中对应能力的状态,确保“计划中”和“已经验证可用”的边界始终清晰。 +`hqbacktest` 的模块边界在 [`docs/design/mvp-contract.md`](docs/design/mvp-contract.md) 固化。任何对默认值、术语、事件顺序或不可变规则的修改,必须**先**改契约,再同步调整 README,并在契约第 9 节追加一条变更记录。 ## 免责声明 diff --git a/docs/design/mvp-contract.md b/docs/design/mvp-contract.md index 678411e..519b5c5 100644 --- a/docs/design/mvp-contract.md +++ b/docs/design/mvp-contract.md @@ -1,14 +1,14 @@ -# hqbacktest v0.1 产品契约 +# hqbacktest 产品契约 -> 状态:**已固化**,对应 `TODO.md` 任务 1。本文档是 v0.1 的领域术语、模块边界、日事件顺序、数据可见性、订单成交时机、精度规则和非目标的**唯一事实来源**。 +> 状态:**已固化**。本文档是领域术语、模块边界、日事件顺序、数据可见性、订单成交时机、精度规则和非目标的**唯一事实来源**。 > > 阅读对象:参与 `hqbacktest` 实现的开发者、审阅者与 AI 助手。任何对默认契约的修改必须先更新本文并在第 9 节登记。 ## 1. 目的与版本 -- 本文档与 `README.md`、`TODO.md` 共同构成项目的设计层;其中本文负责"是什么 / 何时 / 如何",`TODO.md` 负责"按什么顺序交付",`README.md` 负责"面向用户怎么用"。 +- 本文档是 `hqbacktest` 的设计层契约:定义"是什么 / 何时 / 如何"。`README.md` 面向用户说明"怎么用"。 - 本文档面向 `hqbacktest` 的首个可用版本(v0.1);后续版本(指数基准、分钟线、多账户等)必须以独立章节或独立文档承接,不得通过单点补丁混进本文。 -- 修订规则:任何对默认值、术语、事件顺序或不可变规则的修改,必须**先**改本文,**再**同步调整 README 与 TODO 的对应章节,并在第 9 节追加一条变更记录。 +- 修订规则:任何对默认值、术语、事件顺序或不可变规则的修改,必须**先**改本文,再同步调整 README,并在第 9 节追加一条变更记录。 ## 2. 术语表 @@ -254,20 +254,19 @@ ## 9. 修订记录 -| 日期 | 版本 | 变更 | 作者 | -| --- | --- | --- | --- | -| 2026-08-17 | v0.1 初稿 | 依据 `TODO.md` 任务 1 首次固化契约 | hqbacktest 维护者 | -| 2026-08-17 | v0.1 | 修正盘前订单的同日开盘撮合语义;明确收盘估值、结束订单、股票池资格与异常分类;因缺少精确公司行为会计,v0.1 仅支持 `AdjustmentPolicy=none` | hqbacktest 维护者 | -| 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-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 | 任务 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 维护者 | -| 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 +| 日期 | 变更 | 作者 | +| --- | --- | --- | +| 2026-08-17 | 初稿:固化术语、模块边界、日事件顺序、数据可见性、订单成交时机、精度规则和非目标 | hqbacktest 维护者 | +| 2026-08-17 | 修正盘前订单的同日开盘撮合语义;明确收盘估值、结束订单、股票池资格与异常分类;v0.1 仅支持 `AdjustmentPolicy=none` | hqbacktest 维护者 | +| 2026-08-23 | 公司行为扩展设计门槛落地——`adjustment_policy` 严格只接受 `"none"`;`CorporateActionProvider` 列为设计草案并锁定 10 个权威字段;`factor_diagnostics` 字段已就位;因子诊断接口存在但 v0.1 不启用 | hqbacktest 维护者 | +| 2026-08-23 | 修正回测运行时数据边界:`hqbacktest` 直接只读 hqdata CLI 落盘 CSV;`data_root` 默认 `~/.hqdata`,不调用 `hqdata.api` 或网络数据源 | hqbacktest 维护者 | +| 2026-08-23 | 重构数据门户:`HqDataPortal` 替换为 `HqDataCsvPortal`,固定布局 `{data_root}/{source}/calendar.csv` + `stock_list|stock_daily|stock_factor/{YYYYMMDD}.csv`;`source` 名称或绝对路径均可,`CacheKey` 加入 `data_root` 防跨目录串扰 | hqbacktest 维护者 | +| 2026-08-24 | 数据层缺行/停牌/首日语义:钉死 `get_bars` 允许间隙、引入 `SnapshotFileMissingError` 区分整日文件缺失与个股缺行、`current_price` 回看 20 交易日最近有效收盘价、首日哨兵日期不抛异常、删除 `InMemoryDataPortal.get_universe` 向前回退、补双门户 parity 测试、缓存返回防御性拷贝、`.BJ` 股票默认过滤、`Bar.volume` 单位标注为「手」 | hqbacktest 维护者 | +| 2026-08-24 | 撮合与账本语义:同批撮合 SELL 先于 BUY(滚动现金)、SELL 不整手取整、`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 | 净值与指标基准:首日 `daily_return` / `drawdown` 以 `initial_cash` 为基准、后续日 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 | 策略隔离与审计完整性:`Order` 改为 `frozen=True`、`DataView.portal` 改为私有 `_portal`、universe 生效(`RejectReason.OUT_OF_UNIVERSE`)、`Context.historical_universe()` 转发 `DataView.universe()` 受可见性约束;登记 §3.6 | hqbacktest 维护者 | +| 2026-08-24 | 因子诊断接入与分红偏差显性化:engine 在持仓/成交标的的因子跳变(阈值 0.1%)自动生成 DATA_WARNING + `FactorDiagnostic`,结果写入 `summary.json` / `events.jsonl`;CLI 末尾打印汇总警告;账本与净值完全不变;登记 §3.7 | hqbacktest 维护者 | +| 2026-08-24 | CLI 易用性与文档真实性:console script 把 config dir + cwd 加入 sys.path;`initial_cash` 拒绝 nan/inf/float;空交易窗口、空输出目录、`--force` 覆盖;`order_value` 接受 int/str;`git_commit` 改为 hqbacktest 自身版本;README 错误码表与包布局对齐;登记 §3.8 | hqbacktest 维护者 | +| 2026-08-25 | `source` 绝对路径支持(拆为 `data_root` + 名称);`run_metadata.json` 中 `config_path` / `output_directory` / `config_output_directory` 写入相对路径(`os.path.relpath`);`validate_yyyymmdd` 用 `datetime.strptime` 拒绝假日期(保留 `"00000000"` 哨兵);性能夹具生成器改用 `datetime` 迭代;`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」注释 | hqbacktest 维护者 | +| 2026-08-25 | 波动率/夏普首日采样缺口修复:`metrics.compute_metrics` 不再从 `total_equity` 重新推导日收益(旧零种子会丢首日真实收益),改为直接读 `engine` 写好的 `EquityPoint.daily_return`;删除死代码 `_drawdown_series`;新增手算回归(2 日 -9% / +5.5%,`daily_volatility` ≈ 0.10253)。波动率 / Sharpe 与 `max_drawdown` 对首日盈亏的可见性现在一致 | hqbacktest 维护者 | +| 2026-08-25 | 数据层测试覆盖补齐与文档措辞澄清:6 项 `get_factor` 双门户逐值一致性断言;§3.3 删除不存在的 `get_bar(symbol, date)` 引用;`DataView.portal` 措辞改为准确表述(下划线是约定私有,不是 Python 语言级强制力);哨兵常量 `"00000000"` 收敛到 `data.validators.SENTINEL_NO_HISTORY` 一处;`test_version_matches_pyproject` 强化版本号形态校验 | hqbacktest 维护者 | \ No newline at end of file diff --git a/examples/buy_and_hold.py b/examples/buy_and_hold.py index b2be1a9..7d88d7f 100644 --- a/examples/buy_and_hold.py +++ b/examples/buy_and_hold.py @@ -1,4 +1,4 @@ -"""Buy-and-hold example (task 11). +"""Buy-and-hold example. Run from the project root with:: diff --git a/examples/moving_average.py b/examples/moving_average.py index 7403090..143b038 100644 --- a/examples/moving_average.py +++ b/examples/moving_average.py @@ -1,4 +1,4 @@ -"""Moving-average example (task 11). +"""Moving-average example. Run from the project root with:: diff --git a/src/hqbacktest/cli/__init__.py b/src/hqbacktest/cli/__init__.py index 51ed733..31e8ddc 100644 --- a/src/hqbacktest/cli/__init__.py +++ b/src/hqbacktest/cli/__init__.py @@ -1,4 +1,4 @@ -"""Command-line interface for hqbacktest (task 12). +"""Command-line interface for hqbacktest. The CLI is intentionally tiny: it loads a TOML config file, validates it, constructs a `BacktestConfig` and a strategy class, runs the backtest, and diff --git a/src/hqbacktest/cli/__main__.py b/src/hqbacktest/cli/__main__.py index e6b19e3..dd7e986 100644 --- a/src/hqbacktest/cli/__main__.py +++ b/src/hqbacktest/cli/__main__.py @@ -19,11 +19,7 @@ def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="hqbacktest", - description=( - "Run an A-share backtest from a TOML config file. v0.1 ships a " - "complete task 1-11 engine; this CLI is the documented end-user " - "entry point (task 12)." - ), + description=("Run an A-share backtest from a TOML config file."), ) sub = parser.add_subparsers(dest="command") @@ -48,8 +44,7 @@ def build_parser() -> argparse.ArgumentParser: "--force", action="store_true", help=( - "Overwrite an output directory that already contains " - "prior-run files (task 20)." + "Overwrite an output directory that already contains " "prior-run files." ), ) @@ -69,16 +64,15 @@ def main(argv: Optional[Sequence[str]] = None) -> int: def _run(args: argparse.Namespace) -> int: - # Task 20: prepend the config file's directory and the current - # working directory to `sys.path` so the strategy module can be - # resolved by name alone, matching the documented "first-mile" - # workflow. This mirrors what `python -m` would do for an - # in-tree import and makes the console script behave the same - # way as `python -m hqbacktest run`. + # Prepend the config file's directory and the current working directory + # to `sys.path` so the strategy module can be resolved by name alone, + # matching the documented "first-mile" workflow. This mirrors what + # `python -m` would do for an in-tree import and makes the console + # script behave the same way as `python -m hqbacktest run`. _prepare_sys_path(args.config) # Honor a test-only env hook so the CLI can be driven against an # in-memory portal in subprocess tests without touching the real - # `~/.hqdata` snapshot. Task 20. + # `~/.hqdata` snapshot. _maybe_load_test_bootstrap() try: result: RunResult = run_from_file( @@ -102,8 +96,8 @@ def _run(args: argparse.Namespace) -> int: def _maybe_load_test_bootstrap() -> None: """If `HQBACKTEST_CLI_BOOTSTRAP` is set, import that module by name. - Test-only hook used by `tests/cli/test_task20_cli.py` to swap the - portal builder in a subprocess without writing to the real + Test-only hook used by `tests/cli/test_cli_validation.py` to swap + the portal builder in a subprocess without writing to the real `~/.hqdata` snapshot. Production users never set this. """ name = os.environ.get("HQBACKTEST_CLI_BOOTSTRAP") diff --git a/src/hqbacktest/cli/config.py b/src/hqbacktest/cli/config.py index 8f3e19d..81960a4 100644 --- a/src/hqbacktest/cli/config.py +++ b/src/hqbacktest/cli/config.py @@ -1,4 +1,4 @@ -"""Config file loading and validation (task 12). +"""Config file loading and validation. The TOML schema (one example): @@ -237,8 +237,8 @@ def _require_decimal( value = section[key] if isinstance(value, bool): raise ConfigError(f"[{section_name}].{key} must be a number, not bool") - # Task 16: float is forbidden at the CLI layer too, matching the - # engine's contract rule 5. Without this check a TOML like + # Float is forbidden at the CLI layer too, matching the engine's + # contract rule 5. Without this check a TOML like # `initial_cash = 100000.0` would silently convert to a Decimal via # `Decimal(str(float))`, masking the precision concern. if isinstance(value, float): @@ -259,11 +259,10 @@ def _require_decimal( raise ConfigError( f"[{section_name}].{key} must be a number, got {type(value).__name__}" ) - # Task 20: NaN / +Inf / -Inf are technically parseable by - # `Decimal(str('nan'))` but break every downstream comparison - # (e.g. `nan < 0` raises InvalidOperation). Reject them here - # so the user gets a clean single-line ConfigError instead of - # a traceback. + # NaN / +Inf / -Inf are technically parseable by `Decimal(str('nan'))` + # but break every downstream comparison (e.g. `nan < 0` raises + # InvalidOperation). Reject them here so the user gets a clean + # single-line ConfigError instead of a traceback. if not d.is_finite(): raise ConfigError( f"[{section_name}].{key}={d} must be a finite number " diff --git a/src/hqbacktest/cli/runner.py b/src/hqbacktest/cli/runner.py index 907ca7c..b5242d4 100644 --- a/src/hqbacktest/cli/runner.py +++ b/src/hqbacktest/cli/runner.py @@ -1,4 +1,4 @@ -"""Runner: build engine, run, write per-run output directory (task 12). +"""Runner: build engine, run, write per-run output directory. The runner is invoked by `hqbacktest run --config --output `. It is also usable directly (`run_from_file(path)`) for tests. @@ -56,10 +56,10 @@ def run_from_file( `[output].directory` when given. Returns a `RunResult` with the exit code (0 on success) and the path of the output directory. The CLI converts this to a process exit status. `force=True` lets the - runner overwrite a non-empty output directory (task 20). + runner overwrite a non-empty output directory. - Task 20: prepend the config's directory and cwd to `sys.path` so - the user-supplied strategy module can be imported by name alone + Prepends the config's directory and cwd to `sys.path` so the + user-supplied strategy module can be imported by name alone (the documented first-mile workflow). """ _prepare_sys_path(config_path) @@ -79,10 +79,9 @@ def run_from_config( """Build the engine from a validated `ConfigFile` and run the backtest. `force=True` lets the run overwrite an output directory that - already contains prior-run files (task 20). Without `force`, - mixing a fresh run with stale CSVs / summary.json from a - previous run is rejected with exit code 3 to keep the audit - trail honest. + already contains prior-run files. Without `force`, mixing a fresh + run with stale CSVs / summary.json from a previous run is + rejected with exit code 3 to keep the audit trail honest. """ effective_output = output_dir or config_file.output_directory try: @@ -157,10 +156,10 @@ def run_from_config( ) result.save(str(output_path)) - # Task 19: warn the operator when factor diagnostics surfaced a - # holding-period jump. The NAV excludes dividends (policy="none"), - # so this is the only place a human sees the bias. The CLI is the - # operator's terminal; we print one summary line. + # Warn the operator when factor diagnostics surfaced a holding-period + # jump. The NAV excludes dividends (policy="none"), so this is the + # only place a human sees the bias. The CLI is the operator's + # terminal; we print one summary line. holdings_diag = result.factor_diagnostics or [] if holdings_diag: print( @@ -228,7 +227,7 @@ def _write_run_metadata( ) -> None: """Record package/python/source/git info so the run is self-describing. - Task 22.2: the path fields (`config_path`, `output_directory`, + 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. @@ -262,12 +261,11 @@ 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`. + 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 @@ -290,13 +288,12 @@ def _git_commit() -> Optional[str]: """Return the hqbacktest package's own short git commit, or `None` if unavailable. - Task 20: the commit recorded here is the commit of the - hqbacktest repo, NOT the user's cwd repository. A user - running the CLI from inside their own strategy repo gets - hqbacktest's commit (the engine that produced the result); - if they want their strategy's commit too they can record it - themselves in the config. We never raise from here; failure - to read git is a no-op. + The commit recorded here is the commit of the hqbacktest repo, + NOT the user's cwd repository. A user running the CLI from + inside their own strategy repo gets hqbacktest's commit (the + engine that produced the result); if they want their strategy's + commit too they can record it themselves in the config. We never + raise from here; failure to read git is a no-op. """ try: import hqbacktest as _hq_pkg @@ -320,9 +317,9 @@ def _git_commit() -> Optional[str]: def _prepare_sys_path(config_path: str) -> None: """Add the config file's directory and cwd to `sys.path`. - Task 20: lets the user-supplied strategy module be imported by - name alone (e.g. `module = 'strategy'`) without having to add - `sys.path` boilerplate in the strategy file. Mirrors what + Lets the user-supplied strategy module be imported by name alone + (e.g. `module = 'strategy'`) without having to add `sys.path` + boilerplate in the strategy file. Mirrors what `python -m hqbacktest run` would do for in-tree imports. """ candidates: List[str] = [] diff --git a/src/hqbacktest/data/cache.py b/src/hqbacktest/data/cache.py index 858a216..cb5f7b0 100644 --- a/src/hqbacktest/data/cache.py +++ b/src/hqbacktest/data/cache.py @@ -2,8 +2,8 @@ The cache key is the full call identity (source + symbol + field + start + end) so two different sources never share entries, matching contract §6 rule 2 -("one source per run") and the cache rule from task 4 ("缓存必须包含数据源、 -标的、字段和日期范围,不能混用不同数据源"). +("one source per run") and the cache rule ("缓存必须包含数据源、标的、 +字段和日期范围,不能混用不同数据源"). """ from dataclasses import dataclass @@ -16,8 +16,8 @@ class CacheKey: `data_root` is the normalized (resolved, expanded) root directory; including it in the key guarantees that two portals pointing at different snapshot - directories never share cached values (task 4: "缓存键必须包含…规范化后 - 的 data_root"). + directories never share cached values ("缓存键必须包含…规范化后的 + data_root"). """ data_root: str diff --git a/src/hqbacktest/data/data_view.py b/src/hqbacktest/data/data_view.py index 568eb65..d6c0a96 100644 --- a/src/hqbacktest/data/data_view.py +++ b/src/hqbacktest/data/data_view.py @@ -8,7 +8,7 @@ view.current_price(symbol) -> latest close within the lookback window view.universe() -> list[str] (as of visible_through) -Task 14 semantics (per `docs/design/mvp-contract.md` and `TODO.md`): +Semantics (per `docs/design/mvp-contract.md`): - `history(bar_count=N)` only queries the relevant slice (the lookback window); it does NOT scan the full pre-start window of every symbol. - `current_price(symbol)` returns the most recent valid close within the @@ -58,14 +58,14 @@ class DataView: `SENTINEL_NO_HISTORY` in `validators`) that exposes no data: `history(...)` returns `[]` and `current_price(...)` returns `None`. - 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`). + 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 @@ -183,7 +183,7 @@ def history( The result is ordered ascending by date. If `bar_count` exceeds the number of available bars, the shorter list is returned. - Per task 14: when `universe_start` is set, only bars within the + When `universe_start` is set, only bars within the `[universe_start, visible_through]` window are queried — never the full pre-start history. The first-trading-day sentinel returns `[]` rather than raising. @@ -226,19 +226,20 @@ def history( def current_price(self, symbol: str) -> Optional[Decimal]: """Return the most recent valid close on or before `visible_through`. - Task 14 semantics: walk back up to `CURRENT_PRICE_LOOKBACK` (20) - trading days from `visible_through` and return the latest close in - that window. Returns `None` when no bar exists in the lookback - window (e.g. pre-IPO or first-trading-day sentinel). Suspended - symbols therefore keep their last traded price for valuation. + Walks back up to `CURRENT_PRICE_LOOKBACK` (20) trading days from + `visible_through` and returns the latest close in that window. + Returns `None` when no bar exists in the lookback window (e.g. + pre-IPO or first-trading-day sentinel). Suspended symbols + therefore keep their last traded price for valuation. `InvalidDataError` propagates: a corrupt row is an infrastructure failure that must not be silently folded into "no price". `SnapshotFileMissingError` (a whole-day snapshot file missing on - disk) is an infrastructure failure and propagates so the run aborts; - `MissingDataError` (suspended / delisted / pre-IPO) is absorbed. + disk) is an infrastructure failure and propagates so the run + aborts; `MissingDataError` (suspended / delisted / pre-IPO) is + absorbed. - Task 15 performance: one `get_calendar` (cached) resolves the + Performance: one `get_calendar` (cached) resolves the 20-trading-day cutoff, then a single `get_bars(cutoff, end)` is dispatched. This avoids the old N `get_bars(day, day)` round-trips while preserving the 20-**trading-day** lookback bound. @@ -279,16 +280,16 @@ def current_price(self, symbol: str) -> Optional[Decimal]: def _resolve_history_start(self, bar_count: int) -> str: """Compute a bounded start date for `history(bar_count=N)`. - Task 14: do NOT scan the full pre-start history (19000101). A - 5-year lookback window is comfortably larger than any realistic - `bar_count` (most strategies look back < 250 trading days, roughly - one calendar year) and bounds the worst-case file scan. The portal - intersects the window with the trading calendar and filters - per-symbol gaps, so an over-wide window is cheap and never changes - the returned bar sequence. + Do NOT scan the full pre-start history (19000101). A 5-year + lookback window is comfortably larger than any realistic + `bar_count` (most strategies look back < 250 trading days, + roughly one calendar year) and bounds the worst-case file scan. + The portal intersects the window with the trading calendar and + filters per-symbol gaps, so an over-wide window is cheap and + never changes the returned bar sequence. `bar_count` is accepted for API symmetry; a precise - calendar-position bound is deferred to task 15's cache design. + calendar-position bound is deferred to the lookback helper. """ return _trading_day_lookback_start(self.visible_through) diff --git a/src/hqbacktest/data/hqdata_portal.py b/src/hqbacktest/data/hqdata_portal.py index 05bdce0..971f971 100644 --- a/src/hqbacktest/data/hqdata_portal.py +++ b/src/hqbacktest/data/hqdata_portal.py @@ -1,6 +1,6 @@ """HqDataCsvPortal: production portal backed by hqdata CSV snapshots. -Rules enforced here (TODO task 4 + task 15): +Rules enforced here: - **No `import hqdata`**, no `hqdata.api`, no `hqdata.sources`, no SDK imports, no network access. - Reads only the CSV files dropped by the hqdata CLI at @@ -14,11 +14,11 @@ - `source` is the directory name under `data_root` (e.g. `tushare`). - `get_universe(date)` reads exactly `stock_list/{date}.csv`; missing snapshot raises `SnapshotFileMissingError` and never falls back to - other dates (task 14). + other dates. - Cache keys include the normalized `data_root` so two portals pointing at different roots cannot share entries. -Task 15 performance design: +Performance design: - Each `stock_daily/{D}.csv` is parsed at most once per run; the parsed result is cached as `_daily_index[date] = {symbol: Bar}`. - Each `stock_factor/{D}.csv` is parsed at most once per run; cached @@ -66,7 +66,7 @@ def resolve_source_location( ) -> Tuple[str, str]: """Map a source reference to `(data_root, source_name)`. - Per task 22.1, `source` may be either: + `source` may be either: * a bare directory name (e.g. ``"tushare"``). It is paired with ``default_data_root`` (the ``[data].data_root`` config, default @@ -133,7 +133,7 @@ def __init__( self._source_label: str = source # the original string, for display self._root_path: Path = Path(self._data_root) / self._source_name self._cache = DataCache() - # Task 15: per-day file caches (`{date: {symbol: Bar/factor}}`) and + # Per-day file caches (`{date: {symbol: Bar/factor}}`) and # per-symbol cumulative views. The per-day cache ensures each CSV # file is parsed at most once; the cumulative view makes # `get_bars`/`get_factor` O(log N) regardless of window. @@ -281,8 +281,8 @@ def next_trading_day(self, d: str) -> str: def get_universe(self, date: str, include_bj: bool = False) -> List[str]: """Return the historical stock list as of `date`. - Per task 14: `.BJ` (Beijing Stock Exchange) symbols are excluded by - default since v0.1 does not yet support BSE-specific trading rules + `.BJ` (Beijing Stock Exchange) symbols are excluded by default + since v0.1 does not yet support BSE-specific trading rules (no first-day limit-up/down, distinct trading calendar, etc.). Pass `include_bj=True` to opt in. """ @@ -328,7 +328,7 @@ def get_universe(self, date: str, include_bj: bool = False) -> List[str]: def get_bars(self, symbol: str, start: str, end: str) -> List[Bar]: """Return bars for `symbol` in [start, end], **allowing per-day gaps**. - Per-suspension / pre-IPO / post-delisting semantics (task 14): + Per-suspension / pre-IPO / post-delisting semantics: - The window is intersected with the trading calendar; days inside the window with no row for `symbol` are simply absent from the result. @@ -344,7 +344,7 @@ def get_bars(self, symbol: str, start: str, end: str) -> List[Bar]: returns `[]` — an empty result is a legitimate business outcome, matching `InMemoryDataPortal`. - Task 15 performance: + Performance: - The portal maintains a per-symbol cumulative bar list; this method extends that list lazily and slices it with `bisect`, so per-call cost is O(log N) regardless of the window. @@ -371,7 +371,7 @@ def _ensure_symbol_bars(self, symbol: str, start: str, end: str) -> None: date. Only the missing head/tail ranges are scanned — we never re-read files outside [start, end]. This is what guarantees each daily CSV is parsed at most once, and only when a query actually - needs it (task 15). + needs it. """ cumulative = self._symbol_bars.get(symbol) if cumulative and cumulative[0].date <= start and cumulative[-1].date >= end: @@ -469,9 +469,8 @@ def _parse_daily_file(self, date: str) -> Dict[str, Bar]: f"CSV date(s) {file_dates} do not match filename {date}", ) # Build a {symbol: Bar} map for the day. `itertuples` is roughly - # 25x faster than `iterrows` on real-data snapshots (task 15 - # benchmark). Duplicate symbol rows are still rejected as a - # data error. + # 25x faster than `iterrows` on real-data snapshots. Duplicate + # symbol rows are still rejected as a data error. result: Dict[str, Bar] = {} for row in df.itertuples(index=False): sym = getattr(row, "symbol") @@ -510,7 +509,7 @@ def get_factor( day simply omits that day from the result, while a missing whole-day factor file raises `SnapshotFileMissingError`. - Task 15 performance: factor files are parsed once per day; the + Performance: factor files are parsed once per day; the per-symbol cumulative view enables O(log N) window slicing. """ validate_symbol(symbol) @@ -583,8 +582,8 @@ def _parse_factor_file(self, date: str) -> Dict[str, Decimal]: f"CSV date(s) {file_dates} do not match filename {date}", ) result: Dict[str, Decimal] = {} - # `itertuples` mirrors `_parse_daily_file` (task 15: ~25x faster - # than `iterrows` on real-data snapshots). + # `itertuples` mirrors `_parse_daily_file` (~25x faster than + # `iterrows` on real-data snapshots). for row in df.itertuples(index=False): sym = getattr(row, "symbol") if sym in result: diff --git a/src/hqbacktest/data/memory_portal.py b/src/hqbacktest/data/memory_portal.py index cfcee87..eb60862 100644 --- a/src/hqbacktest/data/memory_portal.py +++ b/src/hqbacktest/data/memory_portal.py @@ -176,7 +176,7 @@ def get_universe(self, date: str, include_bj: bool = False) -> List[str]: # Match the production CSV portal: a missing whole-day stock-list # snapshot is an infrastructure failure (SnapshotFileMissingError), # not a per-symbol gap. This keeps the two portals' exception - # types identical for the same fixture (task 14 parity). + # types identical for the same fixture. raise SnapshotFileMissingError( "stock_list", f"/stock_list/{date}.csv" ) @@ -188,10 +188,10 @@ def get_universe(self, date: str, include_bj: bool = False) -> List[str]: def get_bars(self, symbol: str, start: str, end: str) -> List[Bar]: """Return bars in [start, end], allowing per-day gaps. - An empty result (the symbol did not trade at all in the window, or - the window has no trading days) is returned as `[]` rather than - raising. This matches the production `HqDataCsvPortal` semantics - introduced in task 14. + An empty result (the symbol did not trade at all in the window, + or the window has no trading days) is returned as `[]` rather + than raising. This matches the production `HqDataCsvPortal` + semantics. """ bars = self._bars_in_window(symbol, start, end) return list(bars) diff --git a/src/hqbacktest/data/validators.py b/src/hqbacktest/data/validators.py index 3199ae4..155ec44 100644 --- a/src/hqbacktest/data/validators.py +++ b/src/hqbacktest/data/validators.py @@ -26,12 +26,10 @@ def validate_yyyymmdd(value: object, *, name: str = "date") -> str: """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. + Rejects 8-digit strings that are NOT real calendar dates (e.g. + ``"20241399"`` month 13, ``"20240230"`` Feb 30 in a non-leap + year). A bare `len == 8 and isdigit()` check would silently accept + these impossible values and let them reach the engine. The sentinel ``"00000000"`` is explicitly accepted (see `SENTINEL_NO_HISTORY`) because it is a legal engine-internal value diff --git a/src/hqbacktest/domain/adjustment.py b/src/hqbacktest/domain/adjustment.py index ae1d8c5..2e18630 100644 --- a/src/hqbacktest/domain/adjustment.py +++ b/src/hqbacktest/domain/adjustment.py @@ -1,9 +1,9 @@ """CorporateActionAdjustment: v0.1 placeholder. -With AdjustmentPolicy=none in v0.1, this dataclass exists to keep the call site -typed and reserved for the future `CorporateActionProvider` (task 9+). All -adjustments produced in v0.1 are no-ops and must NOT modify cash, holdings, -sellable quantity, cost basis or equity (contract §6 rule 8). +With AdjustmentPolicy=none in v0.1, this dataclass exists to keep the +call site typed and reserved for the future `CorporateActionProvider`. +All adjustments produced in v0.1 are no-ops and must NOT modify cash, +holdings, sellable quantity, cost basis or equity (contract §6 rule 8). """ from dataclasses import dataclass diff --git a/src/hqbacktest/domain/enums.py b/src/hqbacktest/domain/enums.py index 8a51f13..5dc3698 100644 --- a/src/hqbacktest/domain/enums.py +++ b/src/hqbacktest/domain/enums.py @@ -57,8 +57,8 @@ class RejectReason(Enum): MISSING_DATA = "MISSING_DATA" DUPLICATE_ORDER = "DUPLICATE_ORDER" BACKTEST_ENDED = "BACKTEST_ENDED" - # Task 18: order targets a symbol outside the strategy's declared - # universe. Only emitted when `set_universe` has been called. + # Order targets a symbol outside the strategy's declared universe. + # Only emitted when `set_universe` has been called. OUT_OF_UNIVERSE = "OUT_OF_UNIVERSE" OTHER = "OTHER" @@ -98,7 +98,7 @@ class PriceMode(Enum): class AdjustmentPolicy(Enum): - """Adjustment / company-action policy (task 9). + """Adjustment / company-action policy. v0.1 only accepts `NONE`. Any other value is rejected at config validation time with an explicit reason. The enum entry for diff --git a/src/hqbacktest/domain/fill.py b/src/hqbacktest/domain/fill.py index a042ed0..4b25186 100644 --- a/src/hqbacktest/domain/fill.py +++ b/src/hqbacktest/domain/fill.py @@ -65,9 +65,9 @@ def __post_init__(self) -> None: raise ValueError(f"{field_name} must be quantized to 2 decimal places") if len(self.filled_at) != 8 or not self.filled_at.isdigit(): raise ValueError(f"filled_at must be YYYYMMDD, got {self.filled_at!r}") - # Task 16: 印花税 is charged only on SELL. A BUY fill carrying a - # non-zero stamp_tax would let the cash ledger drift from the - # costs table, so we reject it at construction time. + # 印花税 is charged only on SELL. A BUY fill carrying a non-zero + # stamp_tax would let the cash ledger drift from the costs + # table, so we reject it at construction time. if self.side is Side.BUY and self.stamp_tax != 0: raise ValueError( f"stamp_tax on BUY must be 0 (印花税 only on SELL), got {self.stamp_tax}" diff --git a/src/hqbacktest/domain/order.py b/src/hqbacktest/domain/order.py index 27bcdbf..4bf68fb 100644 --- a/src/hqbacktest/domain/order.py +++ b/src/hqbacktest/domain/order.py @@ -22,12 +22,12 @@ class Order: `filled_quantity` accumulates across fills; `avg_fill_price` is recomputed on every fill using a running weighted average. - Task 18: the dataclass is **frozen**. Strategies that receive an - Order via `Context.pending_orders()` cannot mutate any field — - attempted assignment raises `FrozenInstanceError`. Lifecycle - mutations (`transition`, `record_fill`) use `object.__setattr__` - to assign to the frozen fields; that path is engine-internal and - never exposed to strategy code. + The dataclass is **frozen**. Strategies that receive an Order via + `Context.pending_orders()` cannot mutate any field — attempted + assignment raises `FrozenInstanceError`. Lifecycle mutations + (`transition`, `record_fill`) use `object.__setattr__` 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 assign to - frozen fields. Only the engine / broker may call it. + 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) @@ -153,10 +153,10 @@ def record_fill( v0.1 always moves orders through ACCEPTED → PENDING before any fill arrives, so the ACCEPTED branch in the status guard was - unreachable in practice (task 16). + unreachable in practice. - Task 18: this mutator uses `object.__setattr__` to assign to - frozen fields. Only the engine / broker may call it. + This mutator uses `object.__setattr__` to assign to frozen + fields. Only the engine / broker may call it. """ if self.status not in ( OrderStatus.PENDING, @@ -190,10 +190,9 @@ def record_fill( new_avg = (total / Decimal(new_filled)).quantize(PRICE_QUANT) object.__setattr__(self, "avg_fill_price", new_avg) 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 assign to the frozen - # field. + # `fill_ids` is an immutable tuple so a strategy holding the frozen + # Order cannot append/clear it in place. Rebuild the 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/broker.py b/src/hqbacktest/engine/broker.py index 9ec2fd1..15a75d2 100644 --- a/src/hqbacktest/engine/broker.py +++ b/src/hqbacktest/engine/broker.py @@ -1,6 +1,6 @@ """SimulatedBroker: market-on-open matching with rule set and cost model. -Rules (task 7 + task 8 + task 16): +Rules: * Only `OrderType.MARKET` orders are supported; any other type is rejected by `Context` before reaching the broker. * The bar for `today` is read first: `MissingDataError` (suspended / @@ -15,7 +15,7 @@ `InsufficientSharesError`) are produced by `Portfolio.apply_fill` and converted by the engine into typed rejections. -Task 16 batch-matching order (A-share convention): +Batch-matching order (A-share convention): * Within a single `OPEN_MATCH(today)` batch, SELL orders match **before** BUY orders. This mirrors "卖出资金当日可用": the proceeds of a SELL can fund a same-batch BUY, so a rotation @@ -75,8 +75,8 @@ def match( SELL because a SELL only reduces `sellable_quantity`, so stale values only over-estimate availability (fail-safe for the rule). - Task 16: orders are partitioned into SELLs and BUYs. All SELLs - match first (in submission order) so that their proceeds are + Orders are partitioned into SELLs and BUYs. All SELLs match + first (in submission order) so that their proceeds are available to fund the subsequent BUYs (rolling cash). The partition is a stable sort: the relative order within each side is preserved, but cross-side order is normalized to diff --git a/src/hqbacktest/engine/config.py b/src/hqbacktest/engine/config.py index c32295b..2fdc21f 100644 --- a/src/hqbacktest/engine/config.py +++ b/src/hqbacktest/engine/config.py @@ -1,8 +1,12 @@ """BacktestConfig: minimum user-facing configuration for the engine. -Task 5 added date + cash + source fields. Task 8 added pluggable -`TradingRuleSet` and `CostModel` (explicit fees). Task 9 added the -`adjustment_policy` field with strict "none"-only validation. +Fields: + * `start_date` / `end_date` / `initial_cash` / `source` — the + window, capital, and data source. + * `cost_model` — pluggable fee computation (default A-share rates). + * `rule_set` — pluggable trading rules (default A-share rule set). + * `adjustment_policy` — strict "none"-only validation; any other + value is rejected at config validation. """ from dataclasses import dataclass, field @@ -35,8 +39,8 @@ class BacktestConfig: rather than `DataPortalNotConfigured`). `rule_set` and `cost_model` are pluggable; the engine constructs them - from defaults if not supplied. Rates in `DefaultCostModel` are explicit - (TODO task 8 verification: no hidden constants). + from defaults if not supplied. Rates in `DefaultCostModel` are + explicit (no hidden constants). `adjustment_policy` MUST be "none" in v0.1; any other value is rejected at validation time. The corresponding enum lives in @@ -88,8 +92,8 @@ def __post_init__(self) -> None: if not isinstance(self.metrics, MetricsConfig): raise ConfigurationError("metrics must be a MetricsConfig instance") # Adjustment policy: v0.1 only accepts "none". Any other value is - # rejected with an explicit reason (TODO task 9 verification: - # "配置只接受 AdjustmentPolicy=none, 其他值均带明确原因拒绝"). + # rejected with an explicit reason ("配置只接受 + # AdjustmentPolicy=none, 其他值均带明确原因拒绝"). if not isinstance(self.adjustment_policy, str): raise ConfigurationError("adjustment_policy must be a string") if self.adjustment_policy != V01_ADJUSTMENT_POLICY: diff --git a/src/hqbacktest/engine/context.py b/src/hqbacktest/engine/context.py index 64ae1ba..9b37378 100644 --- a/src/hqbacktest/engine/context.py +++ b/src/hqbacktest/engine/context.py @@ -1,6 +1,6 @@ """Context: read-only façade for strategies with order-intent API. -Task 6 adds the full surface required by `BaseStrategy`: +Public surface required by `BaseStrategy`: * read-only accessors: `cash`, `positions`, `position`, `universe`, `pending_orders`, `history`, `current_price`, `now`, `phase`, `visible_through`; @@ -9,11 +9,11 @@ * lifecycle hooks driven by `BacktestEngine` (initialized / run_finished). The Context never mutates the ledger directly. `Portfolio.apply_fill` is -the single writer (task 7); here we only append `Order` objects to +the single writer; here we only append `Order` objects to `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): +Isolation rules enforced here (contract §4): * Date / phase / data view are engine-owned: they can only be changed through `_set_*` hooks, never by strategy code. * Market data is only readable while the scheduler has published a @@ -22,7 +22,7 @@ * Orders may only be submitted from `BEFORE_TRADING_START` or `BAR_CLOSE` (contract §4 "可下单" column). * `set_universe` may only be called from `initialize`; the universe is - locked once `initialize` returns (contract §4 配套约束). + locked once `initialize` returns. * All order paths funnel through `_create_order`, so the order-type allow-list and symbol validation cannot be skipped by the convenience helpers. @@ -130,7 +130,7 @@ def _has_out_of_universe_orders(self) -> bool: Peek-only (does not clear): the scheduler uses this to decide whether to invoke the matcher, while the engine drains the list - via `_consume_out_of_universe_orders` (task 18). + via `_consume_out_of_universe_orders`. """ return bool(self._out_of_universe_orders) @@ -222,10 +222,10 @@ def universe(self) -> List[str]: def historical_universe(self) -> List[str]: """The historical stock list as of the current `visible_through`. - 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 read future data. When no `DataView` - is published (e.g. in `initialize`), an empty list is returned. + This is the only universe accessor that reads through the data + portal. It is constrained by `visible_through` and 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") if self._data_view is None: @@ -235,9 +235,9 @@ def historical_universe(self) -> List[str]: def pending_orders(self) -> List[Order]: """Snapshot of in-flight orders (engine clears them at matching). - Task 18: the returned list and its `Order` elements are - defensive copies / frozen instances — strategies cannot - mutate the engine's view of the order. + The returned list and its `Order` elements are defensive copies / + frozen instances — strategies cannot mutate the engine's view + of the order. """ self._require_active("pending_orders") return list(self._pending_orders) @@ -302,8 +302,8 @@ def _coerce_amount(self, value, name: str) -> Decimal: """Coerce a monetary value to `Decimal`, accepting int/str/Decimal. `float` and `bool` are rejected (contract rule 5: no binary - float enters the ledger), and NaN / Inf are rejected. Task 20: - lets strategies use literal cash values (`15000`, `'15000'`) + float enters the ledger), and NaN / Inf are rejected. Lets + strategies use literal cash values (`15000`, `'15000'`) without wrapping them in `Decimal(...)`. """ if isinstance(value, bool): @@ -361,9 +361,9 @@ def order_value( """Place an order for `value` worth of `symbol` (positive => BUY). `value` may be a `Decimal`, `int`, or numeric string; `float` - remains forbidden (contract rule 5). Task 20: widening the - accepted types here lets strategies use literal cash values - without first wrapping them in `Decimal(...)`. + remains forbidden (contract rule 5). Widening the accepted + types here lets strategies use literal cash values without + first wrapping them in `Decimal(...)`. """ self._require_orderable("order_value") value = self._coerce_amount(value, "value") @@ -419,7 +419,7 @@ def order_target_value( """Reconcile holdings towards `target_value` worth of `symbol`. `target_value` may be a `Decimal`, `int`, or numeric string; - `float` remains forbidden (contract rule 5). Task 20. + `float` remains forbidden (contract rule 5). """ self._require_orderable("order_target_value") target_value = self._coerce_amount(target_value, "target_value") @@ -512,11 +512,11 @@ def _create_order( validate_symbol(symbol) if not is_positive(Decimal(quantity)): raise StrategyLifecycleError(f"quantity must be positive, got {quantity}") - # Task 16: lot-alignment applies to BUY only. A-share rules allow - # odd-lot SELLs so positions holding non-lot quantities can be - # fully closed; round_lot() on a SELL would silently shrink - # the order (e.g. 150 -> 100), violating the contract that the - # broker sees the exact share count the strategy submitted. + # Lot-alignment applies to BUY only. A-share rules allow odd-lot + # SELLs so positions holding non-lot quantities can be fully + # closed; round_lot() on a SELL would silently shrink the order + # (e.g. 150 -> 100), violating the contract that the broker sees + # the exact share count the strategy submitted. if side is Side.BUY: lot_aligned = round_lot(quantity, lot_size=LOT_SIZE) if lot_aligned == 0: @@ -526,10 +526,10 @@ def _create_order( final_quantity = lot_aligned else: final_quantity = quantity - # Task 18: when a universe has been declared, orders for symbols - # outside it must be rejected (typed reason + audit-trail event). - # When the strategy has not called `set_universe`, the universe - # is empty and trading is unrestricted. + # When a universe has been declared, orders for symbols outside + # it must be rejected (typed reason + audit-trail event). When + # the strategy has not called `set_universe`, the universe is + # empty and trading is unrestricted. if self._universe and symbol not in self._universe: return self._reject_out_of_universe( symbol=symbol, side=side, quantity=final_quantity @@ -545,8 +545,8 @@ def _create_order( created_at=self._current_date, created_session=created_session, ) - # Move to ACCEPTED immediately so the broker (task 7) only has to - # handle the matching step. Engine-side validity checks will mark + # Move to ACCEPTED immediately so the broker only has to handle + # the matching step. Engine-side validity checks will mark # REJECTED if necessary. order.transition(OrderStatus.ACCEPTED, at=self._current_date) order.transition(OrderStatus.PENDING, at=self._current_date) @@ -573,11 +573,11 @@ def _reject_out_of_universe( ) -> Optional[Order]: """Build a REJECTED order for a symbol outside the declared universe. Records ORDER_REJECTED + ORDER_CREATED events so the - audit trail is complete (task 18). The order is NOT appended - to `_pending_orders` so the broker never sees it (REJECTED is - a terminal status and would corrupt the broker's state - machine); instead it lands in `_out_of_universe_orders` and - is folded into the engine's `orders_table` at result build. + audit trail is complete. The order is NOT appended to + `_pending_orders` so the broker never sees it (REJECTED is a + terminal status and would corrupt the broker's state machine); + instead it lands in `_out_of_universe_orders` and is folded + into the engine's `orders_table` at result build. """ created_session = self._phase order = Order( @@ -592,7 +592,7 @@ def _reject_out_of_universe( order.transition(OrderStatus.ACCEPTED, at=self._current_date) # Stamp the reason onto the Order itself (not only the event log) # so `orders_table.reject_reason` and the ORDER_REJECTED event - # agree (task 18 audit integrity). + # agree. order.transition( OrderStatus.REJECTED, at=self._current_date, diff --git a/src/hqbacktest/engine/corporate_actions.py b/src/hqbacktest/engine/corporate_actions.py index d4d6fc1..f4cf1de 100644 --- a/src/hqbacktest/engine/corporate_actions.py +++ b/src/hqbacktest/engine/corporate_actions.py @@ -1,4 +1,4 @@ -"""Corporate-action abstractions (task 9). +"""Corporate-action abstractions. v0.1 status: * `AdjustmentPolicy` is exposed in `domain.enums` but only `NONE` is @@ -38,11 +38,11 @@ "note", ) -# Admission criteria for enabling `factor_total_return` (TODO task 9): -# EACH ledger aspect below must have (a) a written accounting semantic and -# (b) a hand-computable regression test BEFORE the policy is accepted by -# `BacktestConfig`. Until every entry is satisfied, the policy stays -# rejected at config validation time. +# Admission criteria for enabling `factor_total_return`: +# EACH ledger aspect below must have (a) a written accounting semantic +# and (b) a hand-computable regression test BEFORE the policy is +# accepted by `BacktestConfig`. Until every entry is satisfied, the +# policy stays rejected at config validation time. FACTOR_TOTAL_RETURN_ADMISSION_CRITERIA: tuple[str, ...] = ( "cash", # how dividends/splits change cash without fabricating payouts "position_quantity", # how share counts change on splits/bonus issues @@ -181,7 +181,7 @@ def analyze_factor_series( jump_band: Tuple[Decimal, Decimal] = DEFAULT_JUMP_BAND, cross_source_tolerance: Decimal = DEFAULT_CROSS_SOURCE_TOLERANCE, ) -> List[FactorDiagnostic]: - """Same-source factor-quality diagnostics (task 9). Pure function. + """Same-source factor-quality diagnostics. Pure function. Reads a factor series and returns one `FactorDiagnostic` per anomaly: * "missing" - an expected trading day has no factor row; diff --git a/src/hqbacktest/engine/cost_model.py b/src/hqbacktest/engine/cost_model.py index 478cbce..8becffd 100644 --- a/src/hqbacktest/engine/cost_model.py +++ b/src/hqbacktest/engine/cost_model.py @@ -1,4 +1,4 @@ -"""CostModel: pluggable fee computation (task 8). +"""CostModel: pluggable fee computation. Every fill carries three fees: * `commission` - per-side, proportional to turnover with a floor. @@ -42,7 +42,7 @@ class DefaultCostModel: """v0.1 default cost model. All rates are explicit configuration; nothing is hidden in code - constants (TODO task 8 verification). + constants. """ commission_rate: Decimal = Decimal("0.00025") # 0.025% of turnover diff --git a/src/hqbacktest/engine/engine.py b/src/hqbacktest/engine/engine.py index 9fcd052..c5782fb 100644 --- a/src/hqbacktest/engine/engine.py +++ b/src/hqbacktest/engine/engine.py @@ -1,6 +1,6 @@ """BacktestEngine: event clock plus controlled strategy context. -Responsibilities after task 10: +Responsibilities: - Build a `MarketDataPortal` from `BacktestConfig` (default: CSV portal). - Iterate the trading days returned by the portal (no natural-day loop). - Dispatch the five phases per day in contract §4 order. @@ -18,7 +18,7 @@ - Compute `PerformanceMetrics` from the equity curve and fills. - Maintain a single `EventLog` covering the whole run. -Out of scope (deferred to task 12+): +Out of scope: - Interactive / HTML reports. """ @@ -90,7 +90,8 @@ def __init__( self._config = config self._strategy = strategy if strategy is not None else NullStrategy() self._portal = portal - # Task 8: broker must share the configured CostModel. + # Broker must share the configured CostModel so a strategy can read + # commission / stamp-tax rates from a single source of truth. cost = config.cost_model self._broker = ( broker if broker is not None else SimulatedBroker(cost_model=cost) @@ -98,15 +99,14 @@ def __init__( self._event_log = EventLog() self._portfolio = Portfolio(initial_cash=config.initial_cash) self._factor_diagnostics = FactorDiagnosticCollector() - # Task 19: per-symbol cumulative factor history (sorted by - # date) so the engine can run holdings-period factor - # diagnostics incrementally without re-reading the portal. + # Per-symbol cumulative factor history (sorted by date) so the + # engine can run holdings-period factor diagnostics incrementally + # without re-reading the portal. self._factor_history: Dict[str, List[Tuple[str, Decimal]]] = {} # Holding-period jump band (relative). A factor ratio outside # this band while a symbol is held is a strong dividend / split - # signal. 0.1% matches task 19's "cannot be ignored" threshold; - # the default `DEFAULT_JUMP_BAND` (0.5, 2.0) is reserved for - # general factor-quality diagnostics. + # signal. The default `DEFAULT_JUMP_BAND` (0.5, 2.0) is reserved + # for general factor-quality diagnostics. self._holding_jump_band: Tuple[Decimal, Decimal] = ( Decimal("0.999"), Decimal("1.001"), @@ -289,10 +289,10 @@ def _run_day_safely( # End-of-day settlement: roll today's buys into sellable (T+1). self._portfolio.settle_t1(today=today, previous_date=None) self._snapshot_equity(today, portal) - # Task 19: run holdings-period factor diagnostics for - # symbols held or traded today. Diagnostics are pure - # observations: they never mutate cash, positions or - # equity, and they cannot abort the run. + # Run holdings-period factor diagnostics for symbols held or + # traded today. Diagnostics are pure observations: they + # never mutate cash, positions or equity, and they cannot + # abort the run. self._run_factor_diagnostics(today, portal) except RunFailed: raise @@ -374,7 +374,7 @@ def _order_row(self, order: Order) -> Dict[str, Any]: def _snapshot_equity(self, today: str, portal: MarketDataPortal) -> None: """Record one EquityPoint using today's close for market value. - Contract §4 + task 14 valuation semantics: + Contract §4 valuation semantics: * Preferred source: today's valid unadjusted close (D's bar). * Fallback: when the held symbol has no bar on `today` (suspended / delisted / pre-IPO), use the most recent valid @@ -432,14 +432,14 @@ def _snapshot_equity(self, today: str, portal: MarketDataPortal) -> None: prices[symbol] = fallback market_value = self._portfolio.market_value(prices) total_equity = self._portfolio.cash + market_value - # Task 17: anchor the first day's `daily_return` and the - # `drawdown` series to `initial_cash` (not a zero seed). A first- - # day P&L must flow into the equity curve so `∏(1 + r) == 1 + - # total_return` and `max_drawdown` can see day-1 drawdowns. + # Anchor the first day's `daily_return` and the `drawdown` series + # to `initial_cash` (not a zero seed). A first-day P&L must flow + # into the equity curve so `∏(1 + r) == 1 + total_return` and + # `max_drawdown` can see day-1 drawdowns. prev_total = self._equity_curve[-1].total_equity if self._equity_curve else None if prev_total is None: # First trading day: benchmark the return against initial_cash - # (task 17) so a first-day P&L flows into the return series. + # so a first-day P&L flows into the return series. daily_return = ( total_equity / self._config.initial_cash - Decimal("1") if self._config.initial_cash > 0 @@ -450,10 +450,10 @@ def _snapshot_equity(self, today: str, portal: MarketDataPortal) -> None: daily_return = Decimal("0") else: daily_return = total_equity / prev_total - Decimal("1") - # Drawdown: the running peak must include `initial_cash`, otherwise - # a first-day loss is silently lost once a later day's equity stays - # below initial_cash but above the prior day's equity (task 17: - # "回撤峰值序列以 initial_cash 为初始峰值"). + # Drawdown: the running peak must include `initial_cash`, + # otherwise a first-day loss is silently lost once a later day's + # equity stays below initial_cash but above the prior day's + # equity. peak = self._config.initial_cash if self._equity_curve: peak = max(peak, *(pt.total_equity for pt in self._equity_curve)) @@ -564,9 +564,8 @@ def _on_open_match( """ for order in pending: self._orders[order.order_id] = order - # Task 18: fold out-of-universe rejections into the orders - # table so the audit trail sees them. These orders never - # reached the broker. + # Fold out-of-universe rejections into the orders table so the + # audit trail sees them. These orders never reached the broker. for order in context._consume_out_of_universe_orders(): self._orders[order.order_id] = order results = self._broker.match( @@ -675,7 +674,7 @@ def data_view(self, today: str) -> DataView: return DataView(portal=self.portal, visible_through=today) # ------------------------------------------------------------------ # - # Task 19: holdings-period factor diagnostics + # Holdings-period factor diagnostics # ------------------------------------------------------------------ # def _run_factor_diagnostics(self, today: str, portal: MarketDataPortal) -> None: @@ -687,19 +686,19 @@ def _run_factor_diagnostics(self, today: str, portal: MarketDataPortal) -> None: the `FactorDiagnosticCollector` and a `DATA_WARNING` event for each new anomaly. - Only symbols with a non-zero position at day-end are scanned - (task 19: "持仓涉及的标的"). A symbol that was fully sold - (position back to zero) must NOT keep emitting holdings-period - warnings — its holding period has ended, and the accumulated - factor history is therefore reset. + Only symbols with a non-zero position at day-end are + scanned (the "持仓涉及的标的" invariant). A symbol that was + fully sold (position back to zero) must NOT keep emitting + holdings-period warnings — its holding period has ended, and + the accumulated factor history is therefore reset. Diagnostics are pure observations: they MUST NOT mutate - cash, positions, or equity (contract task 9 invariant). + cash, positions, or equity (contract invariant: factor + diagnostics never fabricate corporate-action accounting). Missing factors or whole-day snapshot failures are silently - skipped so the run continues (the existing - `_snapshot_equity` / broker paths raise DATA_ERROR on - infrastructure failures, and we do not want to raise a - second error here). + skipped so the run continues (the existing `_snapshot_equity` + / broker paths raise DATA_ERROR on infrastructure failures, + and we do not want to raise a second error here). """ relevant = { sym for sym, pos in self._portfolio.positions.items() if pos.quantity > 0 diff --git a/src/hqbacktest/engine/iterator.py b/src/hqbacktest/engine/iterator.py index c38b32c..e4a8b4f 100644 --- a/src/hqbacktest/engine/iterator.py +++ b/src/hqbacktest/engine/iterator.py @@ -14,10 +14,10 @@ class TradingDayIterator: """Yields trading days in `[start, end]` (inclusive), in ascending order. - Task 20: an empty trading-day window is a hard error. Silent - success on a zero-day run produces misleading "no signals" - reports and breaks reproducibility, so the iterator raises - `ConfigurationError` instead of yielding an empty sequence. + An empty trading-day window is a hard error. Silent success on a + zero-day run produces misleading "no signals" reports and breaks + reproducibility, so the iterator raises `ConfigurationError` + instead of yielding an empty sequence. """ def __init__( diff --git a/src/hqbacktest/engine/metrics.py b/src/hqbacktest/engine/metrics.py index 852f06b..1f26b6e 100644 --- a/src/hqbacktest/engine/metrics.py +++ b/src/hqbacktest/engine/metrics.py @@ -1,12 +1,12 @@ -"""Performance metrics (task 10 + task 17 + task 23). +"""Performance metrics. Formulas (all documented in README and contract doc): * `total_return` = (final_equity / initial_equity) - 1 * `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 + flows into the return series. The + chained-product identity `∏(1 + daily_return) == 1 + total_return` therefore holds for any run. * `annualized_return` = (1 + total_return) ** (n / @@ -19,8 +19,8 @@ `None` when fewer than 2 daily returns are available (single-day run, or two trading days with only one distinct - return — task 23). Reports `0` only - when the series is genuinely flat. + return). 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,18 +38,16 @@ cost / total SELL fills; `None` if no SELL fills -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". +The daily-return series passed to `stdev` is the engine's own +`EquityPoint.daily_return` series, NOT a re-derivation from +`total_equity`. Re-deriving from `total_equity` would silently drop +the day-0 return because the first day has no predecessor to divide +by — the loop `equity[t] / equity[t-1] - 1` starts at t=1 and the +day-0 return would never reach `stdev`. Reading +`EquityPoint.daily_return` directly keeps the volatility / +drawdown / Sharpe trio consistent on what counts as a "first day". -Edge cases (per task 10/17/23 verification "空回测 / 单日 / 零波动 / -全亏损 / 无交易 / 样本不足"): +Edge cases ("空回测 / 单日 / 零波动 / 全亏损 / 无交易 / 样本不足"): * `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 @@ -146,18 +144,18 @@ def compute_metrics( ) -> PerformanceMetrics: """Compute all v0.1 metrics from an equity curve and the fill list. - Task 23 invariants: + 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. + starts at t=1 silently drops it. * `daily_volatility` is `None` whenever fewer than 2 daily 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. + contract rule 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 @@ -190,7 +188,7 @@ def compute_metrics( # exponentiation), then re-encode through str() so the # resulting Decimal never directly inherits binary-float # bits. Quantize to a fixed precision so summary.json stays - # clean (task 17). + # clean. exponent = float(n_days) / float(config.annual_trading_days) annualized_return = Decimal(str(float(growth) ** exponent)).quantize( _METRIC_QUANT @@ -198,11 +196,6 @@ def compute_metrics( # Daily volatility (per-day stdev) and its annualisation; Sharpe uses # 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] sharpe_ratio: Optional[Decimal] diff --git a/src/hqbacktest/engine/result.py b/src/hqbacktest/engine/result.py index db0c6c0..0fe1889 100644 --- a/src/hqbacktest/engine/result.py +++ b/src/hqbacktest/engine/result.py @@ -174,8 +174,7 @@ def save(self, output_dir: str) -> None: @classmethod def load(cls, output_dir: str) -> "BacktestResult": """Rehydrate a `BacktestResult` from disk. Order/fill IDs and dates - are preserved as strings (preserves semantics per task 10 verification - "导出再读入后订单 ID、金额和日期没有丢失语义").""" + are preserved as strings so the round-trip preserves semantics.""" out = Path(output_dir) if not out.exists(): raise FileNotFoundError(f"output directory not found: {output_dir}") diff --git a/src/hqbacktest/engine/rule_set.py b/src/hqbacktest/engine/rule_set.py index 830511a..421e7c5 100644 --- a/src/hqbacktest/engine/rule_set.py +++ b/src/hqbacktest/engine/rule_set.py @@ -1,4 +1,4 @@ -"""TradingRuleSet: pluggable A-share rules (task 8). +"""TradingRuleSet: pluggable A-share rules. The rule set runs BEFORE the broker constructs a fill. Any rule may return a denial with a deterministic `RejectReason`; the engine then diff --git a/src/hqbacktest/engine/scheduler.py b/src/hqbacktest/engine/scheduler.py index 95b8956..60398a1 100644 --- a/src/hqbacktest/engine/scheduler.py +++ b/src/hqbacktest/engine/scheduler.py @@ -3,8 +3,9 @@ The scheduler owns the visible_through settings per phase (contract §4) and fires the strategy callbacks in the exact order required by the contract. Open matching, fill recording and snapshot building are intentionally out of -scope (task 5 "不实现订单实际成交"); the scheduler records the phase -transitions in the event log so tests can assert the exact sequence. +scope here — see `SimulatedBroker` for the matching pipeline; the scheduler +records the phase transitions in the event log so tests can assert the +exact sequence. `initialize` is NOT fired here: it is a run-level lifecycle callback invoked exactly once by `BacktestEngine.run()` before the first trading day @@ -28,7 +29,7 @@ # Callback the engine plugs in to consume pending orders at OPEN_MATCH. # `context` is passed so the engine can drain out-of-universe rejections -# alongside regular pending orders (task 18). +# alongside regular pending orders. OpenMatchCallback = Callable[[str, List[Order], "Context"], None] @@ -80,8 +81,8 @@ def build_view( `universe_start` is deliberately left unset here: it denotes the earliest date the strategy may query (a run-level bound derived from the backtest window), not the phase visibility. Bounding `history(bar_count=N)`'s - lookback window is `DataView`'s own responsibility (task 14), independent - of per-phase visibility. + lookback window is `DataView`'s own responsibility, independent of + per-phase visibility. """ if schedule.visible_through_mode == SAME_DAY_VISIBLE_THROUGH: return DataView(portal=portal, visible_through=today) @@ -109,10 +110,10 @@ def run_day( ) -> None: """Fire the five phases for one trading day, in order. - `on_open_match` (task 7) is invoked at the OPEN_MATCH phase with the - pending orders accumulated by `Context.order_*`. The callback is - expected to apply fills to the portfolio and to mark rejected orders. - When `on_open_match` is `None`, OPEN_MATCH is a no-op (pre-task 7). + `on_open_match` is invoked at the OPEN_MATCH phase with the pending + orders accumulated by `Context.order_*`. The callback is expected to + apply fills to the portfolio and to mark rejected orders. When + `on_open_match` is `None`, OPEN_MATCH is a no-op (no matcher wired in). """ context._set_date(today) for entry in PHASE_SCHEDULE: @@ -142,12 +143,12 @@ def run_day( # Only consume when a matcher is wired in; otherwise pending # orders would silently vanish without any event. pending = context._consume_pending_orders() - # Task 18: invoke the matcher whenever there is anything to - # process — pending orders OR out-of-universe rejections to - # fold into the audit table. The engine drains both in a - # single call; do NOT pre-consume the out-of-universe list - # here (the engine must consume it, or those rejections - # would be silently dropped from the orders table). + # Invoke the matcher whenever there is anything to process — + # pending orders OR out-of-universe rejections to fold into + # the audit table. The engine drains both in a single call; + # do NOT pre-consume the out-of-universe list here (the + # engine must consume it, or those rejections would be + # silently dropped from the orders table). if pending or context._has_out_of_universe_orders(): on_open_match(today, pending, context) continue diff --git a/src/hqbacktest/engine/strategy.py b/src/hqbacktest/engine/strategy.py index d30ca8f..a836bcf 100644 --- a/src/hqbacktest/engine/strategy.py +++ b/src/hqbacktest/engine/strategy.py @@ -2,7 +2,7 @@ Two layers are exposed: -* `Strategy` — the `Protocol` shape used by the engine (task 5, unchanged). +* `Strategy` — the `Protocol` shape used by the engine clock. * `BaseStrategy` — a convenience base class that adds explicit lifecycle tracking: subclassing overrides the same four methods but the engine enforces "must `initialize` before any other callback" and rejects @@ -59,11 +59,11 @@ class BaseStrategy: * raises `StrategyLifecycleError` if the strategy mis-uses the API (e.g. calling `context.order(...)` outside a callback). - Task 21: the constructor accepts and stores `**kwargs` so the CLI - can pass user-supplied parameters via `[strategy].kwargs`. - Subclasses that need parameters should accept them in their own - `__init__` (the base ``**kwargs`` is swallowed so no TypeError - on stray keyword args). + The constructor accepts and stores `**kwargs` so the CLI can pass + user-supplied parameters via `[strategy].kwargs`. Subclasses that + need parameters should accept them in their own `__init__` (the + base ``**kwargs`` is swallowed so no TypeError on stray keyword + args). """ def __init__(self, **kwargs: Any) -> None: @@ -82,9 +82,9 @@ def initialize(self, context) -> None: """Override to set universe and stash initial parameters. Default implementation does nothing; subclasses may call - ``context.set_universe([...])`` (added in task 6) to declare the - tradeable set. The engine guarantees this is the first method - invoked in the run. + ``context.set_universe([...])`` to declare the tradeable set. + The engine guarantees this is the first method invoked in the + run. """ return None diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 01f683b..18848a0 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -1,4 +1,4 @@ -"""CLI tests for `hqbacktest run --config FILE --output DIR` (task 12). +"""CLI tests for `hqbacktest run --config FILE --output DIR`. Covers: * Config file validation (errors, missing keys, unknown sections) @@ -436,13 +436,13 @@ def test_runner_does_not_write_secrets(tmp_path: Path, monkeypatch) -> None: """Tokens, env vars, and absolute local paths must never appear in `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. + 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 @@ -468,8 +468,8 @@ def test_runner_does_not_write_secrets(tmp_path: Path, monkeypatch) -> None: 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. + # 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()) @@ -536,8 +536,8 @@ def test_runner_output_override_beats_config_directory( """`--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. + # `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" @@ -550,9 +550,9 @@ def test_runner_output_override_beats_config_directory( assert (override_out / "equity_curve.csv").exists() assert not config_out.exists() meta = json.loads((override_out / "run_metadata.json").read_text()) - # 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. + # 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" @@ -613,12 +613,9 @@ def test_cli_returns_2_on_config_error(tmp_path: Path, capsys) -> None: def test_python_m_runs_end_to_end(tmp_path: Path) -> None: """`python -m hqbacktest run` works end-to-end from a fresh subprocess. - 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. + Covers the `python -m hqbacktest` shim path; the real + console-script entry point (produced by `pip install -e .`) is + covered by `test_console_script_runs_end_to_end` below. """ repo = Path(__file__).resolve().parents[2] data_root = _write_csv_snapshot(tmp_path) @@ -647,11 +644,10 @@ def test_python_m_runs_end_to_end(tmp_path: Path) -> None: 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. + 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 diff --git a/tests/cli/test_task20_cli.py b/tests/cli/test_cli_validation.py similarity index 95% rename from tests/cli/test_task20_cli.py rename to tests/cli/test_cli_validation.py index 6da5d07..732bb6c 100644 --- a/tests/cli/test_task20_cli.py +++ b/tests/cli/test_cli_validation.py @@ -1,4 +1,4 @@ -"""Task 20: CLI first-mile + README honesty regression tests. +"""CLI validation + first-mile regression tests. Covers: * Console-script (`hqbacktest run`) works from a fresh working @@ -236,13 +236,10 @@ def test_initial_cash_negative_rejected(tmp_path): def test_start_date_impossible_rejected(tmp_path): """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`. + The `end_date` is lex-greater AND a real calendar date so the + failure mode is unambiguously the impossible-date validation in + `validate_yyyymmdd` (and not the `start > end` ordering check + that would fire if both dates were lex-comparable). """ cfg = tmp_path / "c.toml" cfg.write_text( @@ -275,7 +272,8 @@ def test_start_date_impossible_rejected(tmp_path): def test_end_date_impossible_rejected(tmp_path): - """Task 22.3: impossible `end_date` must also be rejected. + """An 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. @@ -416,7 +414,7 @@ def on_bar(self, context, data): def test_order_target_value_accepts_int_cash(): """`order_target_value(symbol, 15000)` (int) must also accept an - int/str monetary literal (task 20), not only `Decimal`.""" + int/str monetary literal, not only `Decimal`.""" class TargetSpend(BaseStrategy): def initialize(self, context): @@ -469,8 +467,7 @@ def test_empty_window_cli_returns_exit_2(tmp_path): def test_git_commit_reports_package_commit_or_none(): """`_git_commit()` must not raise and returns either a short hex - string or `None`. It is documented as a best-effort lookup - (task 20). + string or `None`. Best-effort lookup. """ result = _git_commit() assert result is None or (isinstance(result, str) and len(result) >= 4) @@ -478,8 +475,7 @@ def test_git_commit_reports_package_commit_or_none(): def test_run_metadata_records_package_version(tmp_path): """`run_metadata.json` must record the hqbacktest package - version and the configured start/end dates (task 20: rename - `git_commit` semantics).""" + version and the configured start/end dates.""" from hqbacktest.cli import runner from hqbacktest.cli.config import load_config_file diff --git a/tests/data/test_task15_performance.py b/tests/data/test_data_layer_performance.py similarity index 93% rename from tests/data/test_task15_performance.py rename to tests/data/test_data_layer_performance.py index 0e47892..2291887 100644 --- a/tests/data/test_task15_performance.py +++ b/tests/data/test_data_layer_performance.py @@ -1,4 +1,4 @@ -"""Performance and cache-reuse smoke tests for task 15. +"""Performance and cache-reuse smoke tests for the data layer. Covers: * The per-day CSV files are parsed **at most once** per run; calling @@ -198,9 +198,9 @@ def counting_read(path, *args, **kwargs): def test_bar_objects_reused_across_overlapping_queries(tmp_path): """`get_bars` overlapping windows must return the same `Bar` instances. - Memory control: a fresh object per call would multiply allocation by - the number of overlapping queries. Task 15 says bar objects are - cached and reused. + Memory control: a fresh object per call would multiply + allocation by the number of overlapping queries. Bar objects are + cached and reused across overlapping queries. """ symbols = ["600000.SH"] days = ["20240102", "20240103", "20240104"] @@ -265,13 +265,13 @@ def test_perf_smoke_50_symbols_250_days_history(tmp_path): symbols = [f"{600000 + i:06d}.SH" for i in range(50)] days = [] # 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. + # Earlier the fixture used an integer counter with + # ``if d % 100 == 32: d += 70`` to skip month boundaries, but that + # only caught the day-32 rollover after a 31-day month and emitted + # impossible dates for shorter months (`20240230`, `20240231`, + # `20240431`, `20240631`) — the bare `len == 8 and isdigit()` check + # in `validate_yyyymmdd` silently accepted them. Iterating via + # `datetime` keeps the calendar correct by construction. d = date(2024, 1, 2) while len(days) < 250: days.append(d.strftime("%Y%m%d")) @@ -337,7 +337,8 @@ def test_snapshot_file_missing_propagates_through_cumulative_cache( ): """If a daily file is missing on disk, the cumulative cache path must still raise `SnapshotFileMissingError` rather than silently fold the - failure into an empty list (task 14 invariant preserved). + failure into an empty list (missing-file vs per-symbol-gap + invariant). """ symbols = ["600000.SH"] days = ["20240102", "20240103"] @@ -378,10 +379,10 @@ def test_forward_extend_does_not_parse_unqueried_files(tmp_path): """Forward extension only reads (cend, end]; never files before the already-covered range. - Regression for a bug where the forward-extend path used lo="00000000", - parsing every daily file from the epoch up to `end` — including files - the strategy never queried. A missing/corrupt file outside the query - window must not abort the run (task 15 + task 14 "distinguishable + Regression for a bug where the forward-extend path parsed every + daily file from the epoch up to `end` — including files the + strategy never queried. A missing/corrupt file outside the query + window must not abort the run (per-day / missing-file "distinguishable failure" invariant). """ symbols = ["600000.SH"] diff --git a/tests/data/test_task14_semantics.py b/tests/data/test_data_layer_semantics.py similarity index 97% rename from tests/data/test_task14_semantics.py rename to tests/data/test_data_layer_semantics.py index 085dddd..b7cbb08 100644 --- a/tests/data/test_task14_semantics.py +++ b/tests/data/test_data_layer_semantics.py @@ -1,4 +1,4 @@ -"""Task 14 tests for DataView and snapshot-missing error handling. +"""DataView and snapshot-missing error handling. Covers: * Sentinel `visible_through="00000000"` on the first trading day must not @@ -232,8 +232,8 @@ def test_current_price_returns_latest_close_on_full_coverage(): # --------------------------------------------------------------------------- -# SnapshotFileMissingError propagation (task 14: infrastructure failure must -# not be silently folded into a per-symbol gap) +# SnapshotFileMissingError propagation (infrastructure failure must not +# be silently folded into a per-symbol gap) # --------------------------------------------------------------------------- @@ -280,12 +280,12 @@ def test_engine_lookback_price_propagates_snapshot_missing(): # --------------------------------------------------------------------------- -# history start bound + early-read error type (task 14) +# history start bound + early-read error type # --------------------------------------------------------------------------- def test_history_does_not_query_legacy_19000101_start(): - """`history` must not scan the full pre-start window (task 14).""" + """`history` must not scan the full pre-start window.""" p = _gap_portal() view = DataView(portal=p, visible_through="20240105") diff --git a/tests/data/test_hqdata_portal.py b/tests/data/test_hqdata_portal.py index be29176..31d59ac 100644 --- a/tests/data/test_hqdata_portal.py +++ b/tests/data/test_hqdata_portal.py @@ -126,10 +126,11 @@ def test_resolve_source_location_with_name_uses_default_root(tmp_path): 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). + """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 + 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)`. """ @@ -199,7 +200,7 @@ def test_construction_resolves_snapshot_root(tmp_path): def test_construction_accepts_absolute_path_source_and_splits(tmp_path): - """Task 22.1: `HqDataCsvPortal(source=/abs/path)` splits into (parent, name).""" + """`HqDataCsvPortal(source=/abs/path)` splits into `(parent, name)`.""" snap = _build_snapshot( tmp_path, "tushare", @@ -326,9 +327,9 @@ def test_calendar_rejects_invalid_is_open(tmp_path): snap = tmp_path / "tushare" snap.mkdir() (snap / "calendar.csv").write_text("date,is_open\n20240102,X\n", encoding="utf-8") - # Per task 14, a corrupt calendar is an infrastructure failure: the - # portal surfaces it at construction time (via `_resolve_as_of`) so - # the engine can never publish a misleading `data_version`. + # A corrupt calendar is an infrastructure failure: the portal + # surfaces it at construction time (via `_resolve_as_of`) so the + # engine can never publish a misleading `data_version`. with pytest.raises(InvalidDataError): HqDataCsvPortal(source="tushare", data_root=str(tmp_path)) @@ -513,9 +514,9 @@ def test_get_bars_caches(tmp_path): portal = HqDataCsvPortal(source="tushare", data_root=str(tmp_path)) a = portal.get_bars("600000.SH", "20240102", "20240102") b = portal.get_bars("600000.SH", "20240102", "20240102") - # Task 14: cached lists are returned as defensive copies, never the - # internal reference. Strategies must not be able to mutate the - # cache by mutating a returned list. + # Cached lists are returned as defensive copies, never the internal + # reference. Strategies must not be able to mutate the cache by + # mutating a returned list. assert a == b assert a is not b @@ -523,9 +524,9 @@ def test_get_bars_caches(tmp_path): def test_get_bars_rejects_missing_daily_file(tmp_path): """A trading day without a daily file raises SnapshotFileMissingError. - Per task 14, a missing whole-day file is an infrastructure failure - (distinct from a per-symbol gap) and must propagate so the engine can - abort the run with `DATA_ERROR`. + A missing whole-day file is an infrastructure failure (distinct + from a per-symbol gap) and must propagate so the engine can abort + the run with `DATA_ERROR`. """ from hqbacktest.data import SnapshotFileMissingError @@ -561,7 +562,7 @@ def test_get_bars_rejects_date_mismatch_in_daily(tmp_path): def test_get_bars_rejects_wrong_symbol_row(tmp_path): - """A per-symbol gap returns `[]`, not an error (task 14). + """A per-symbol gap returns `[]`, not an error. The daily file exists for the trading day but contains no row for the requested symbol (suspended / delisted / pre-IPO). This is a diff --git a/tests/data/test_memory_portal.py b/tests/data/test_memory_portal.py index 0347c70..b0e5b9d 100644 --- a/tests/data/test_memory_portal.py +++ b/tests/data/test_memory_portal.py @@ -87,7 +87,7 @@ def test_get_bars_rejects_window_start_after_end(): def test_get_bars_returns_empty_when_window_empty(): - """Task 14: a window with no bars returns [] (per-symbol gap semantics).""" + """Per-symbol gap (no bars for symbol in window) returns [] (per-symbol gap semantics).""" p = InMemoryDataPortal() p.add_bar(_bar("600000.SH", "20240102")) assert p.get_bars("600000.SH", "20240201", "20240205") == [] @@ -100,7 +100,7 @@ def test_get_bars_rejects_invalid_symbol(): def test_universe_exact_date_only_no_walk_back(): - """Task 14: per-date snapshot semantics; no implicit walk-back. + """Per-date snapshot semantics; no implicit walk-back. The in-memory portal must match the production CSV portal which only looks up the snapshot for the exact requested date. This avoids the @@ -203,7 +203,7 @@ def test_constructor_rejects_initial_bar_outside_known_calendar(): def test_missing_bar_on_trading_day_returns_empty_list(): - """Task 14: a fully empty window returns [], not MissingDataError. + """Empty-window (no trading days) returns [], not MissingDataError. "停牌/缺行" is a per-symbol gap, a normal business outcome. The portal surfaces it as an empty list so the caller (engine, DataView) diff --git a/tests/data/test_portal_parity.py b/tests/data/test_portal_parity.py index b255a4d..51490c5 100644 --- a/tests/data/test_portal_parity.py +++ b/tests/data/test_portal_parity.py @@ -1,10 +1,10 @@ """Parity tests between InMemoryDataPortal and HqDataCsvPortal. The two portals must agree on every observable behavior for the same fixture -data. Per task 14 of TODO.md, any divergence means tests can pass on memory -data while production silently misbehaves on CSV snapshots. Each test in this -file constructs equivalent fixtures for both portals and asserts identical -return values and identical exception types. +data. Any divergence means tests can pass on memory data while production +silently misbehaves on CSV snapshots. Each test in this file constructs +equivalent fixtures for both portals and asserts identical return values +and identical exception types. """ from __future__ import annotations @@ -342,10 +342,10 @@ def test_universe_exact_date_agrees(tmp_path): def test_universe_raises_on_missing_snapshot_for_both(tmp_path): - """Neither portal silently walks back to a prior snapshot (task 14). + """Neither portal silently walks back to a prior snapshot. - A missing whole-day stock-list snapshot is an infrastructure failure in - both portals, so the exception type must be identical + A missing whole-day stock-list snapshot is an infrastructure + failure in both portals, so the exception type must be identical (`SnapshotFileMissingError`), not merely a shared base class. """ mem = _memory_with_gaps() @@ -510,17 +510,18 @@ def test_bars_snapshot_missing_vs_per_symbol_missing_classification(): # --------------------------------------------------------------------------- -# 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.) +# Factor parity: `test_factor_rejects_zero_in_memory_portal` below only +# asserts the memory portal rejects a non-positive factor at construction +# time. The CSV-side equivalent lives in +# `tests/data/test_hqdata_portal.py`. These tests run the same fixture +# through both portals and assert identical return values and +# exception types across the full `get_factor` API. # --------------------------------------------------------------------------- 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`). + """`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) @@ -540,9 +541,9 @@ def test_factor_window_returns_identical_series(tmp_path): 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. + """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) @@ -555,8 +556,8 @@ def test_factor_per_symbol_gap_matches_between_portals(tmp_path): 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. + """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) @@ -585,8 +586,8 @@ def test_factor_rejects_bad_symbol_for_both(tmp_path): 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 + """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. """ diff --git a/tests/data/test_validators.py b/tests/data/test_validators.py index f869b2c..d5d8405 100644 --- a/tests/data/test_validators.py +++ b/tests/data/test_validators.py @@ -33,9 +33,9 @@ def test_validate_yyyymmdd_rejects_bad(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()`. + # 8-digit but NOT a real calendar date (e.g. month 13, day 32, + # day 30 of February). These slip through a bare + # `len == 8 and isdigit()` check. "20241399", # month 13 "20240132", # January 32nd "20240230", # Feb 30 in a non-leap year @@ -44,9 +44,9 @@ def test_validate_yyyymmdd_rejects_bad(value): ], ) 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. + """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) @@ -73,7 +73,7 @@ def test_validate_yyyymmdd_accepts_first_day_sentinel(): ], ) def test_validate_yyyymmdd_accepts_real_calendar_dates(value): - """Task 22.3: real-but-edge-case calendar dates must still pass.""" + """Real-but-edge-case calendar dates must still pass.""" assert validate_yyyymmdd(value) == value diff --git a/tests/engine/test_task8_integration.py b/tests/engine/test_a_share_rules.py similarity index 99% rename from tests/engine/test_task8_integration.py rename to tests/engine/test_a_share_rules.py index ac48831..04beb3f 100644 --- a/tests/engine/test_task8_integration.py +++ b/tests/engine/test_a_share_rules.py @@ -1,4 +1,4 @@ -"""End-to-end tests for task 8 (rule set + cost model integration).""" +"""End-to-end tests for the rule set + cost model integration.""" from dataclasses import dataclass from decimal import Decimal diff --git a/tests/engine/test_adjustment.py b/tests/engine/test_adjustment.py index 0262b65..ec6c8a3 100644 --- a/tests/engine/test_adjustment.py +++ b/tests/engine/test_adjustment.py @@ -1,4 +1,4 @@ -"""Tests for task 9: corporate-action design threshold + factor diagnostics. +"""Corporate-action design threshold + factor diagnostics. These cover the TODO verification items: * "配置只接受 AdjustmentPolicy=none, 其他值均带明确原因拒绝" @@ -353,7 +353,7 @@ def on_bar(self, context, data): # --------------------------------------------------------------------- # -# analyze_factor_series: the four diagnostic kinds (TODO task 9 验证) +# analyze_factor_series: the four diagnostic kinds # --------------------------------------------------------------------- # DATES = ["20240102", "20240103", "20240104"] @@ -426,7 +426,7 @@ def test_analyze_diagnostics_carry_symbol_and_traceable_detail(): def test_analyze_never_touches_ledger_state(): """The analyzer is pure: running it over anomalous factors changes - neither the portfolio nor its positions (TODO task 9 验证).""" + neither the portfolio nor its positions.""" portfolio = Portfolio(initial_cash=Decimal("100000")) portfolio.get_position("600000.SH").update_buy(100, Decimal("10")) analyze_factor_series( @@ -448,8 +448,8 @@ def test_diagnostic_kinds_constant_matches_validation(): def test_admission_criteria_cover_every_ledger_aspect(): - """TODO task 9: the factor_total_return admission standard must spell out - cash / quantity / sellable / cost / fills / valuation / metrics.""" + """The factor_total_return admission standard must spell out cash / + quantity / sellable / cost / fills / valuation / metrics.""" for aspect in ( "cash", "position_quantity", diff --git a/tests/engine/test_broker.py b/tests/engine/test_broker.py index 942fc74..3c4aea1 100644 --- a/tests/engine/test_broker.py +++ b/tests/engine/test_broker.py @@ -1,6 +1,6 @@ """Tests for the SimulatedBroker and the engine-side matching flow. -Covers task 7 verification items: +Covers: * buy happy path / sell happy path * insufficient cash / insufficient sellable shares * missing bar (suspended / non-trading day) @@ -310,7 +310,7 @@ def on_bar(self, context, data): def test_engine_fill_links_order_and_fill_ids(): - """Task 7 verification: order/fill/ledger IDs stay associated.""" + """Order / fill / ledger IDs stay associated end-to-end.""" class BuyOnce(BaseStrategy): def initialize(self, context): @@ -611,12 +611,12 @@ def on_bar(self, context, data): def test_engine_conservation_holds_at_every_day_end(): - """Task 7 verification: cash + Σ(quantity × avg_cost) == initial cash + - realized pnl at the end of EVERY trading day, not just at run end. + """Cash + Σ(quantity × avg_cost) == initial cash + realized pnl at + the end of EVERY trading day, not just at run end. - We use a zero-fee cost model so the identity is exact (realized pnl is - always 0 and avg_cost is exactly 10.0000). Task 8 added fees; this test - locks the conservation invariant under the no-fee special case. + We use a zero-fee cost model so the identity is exact (realized + pnl is always 0 and avg_cost is exactly 10.0000). This test locks + the conservation invariant under the no-fee special case. """ from hqbacktest.engine.cost_model import Cost, CostModel diff --git a/tests/engine/test_config_and_context.py b/tests/engine/test_config_and_context.py index 76be5f8..33ac774 100644 --- a/tests/engine/test_config_and_context.py +++ b/tests/engine/test_config_and_context.py @@ -107,8 +107,8 @@ def test_context_exposes_portfolio_and_log(): def test_context_phase_and_date_are_read_only_for_strategies(): - """Task 6 isolation: strategy code must not be able to forge the current - date or phase (they drive order IDs and `created_session`).""" + """Strategy code must not be able to forge the current date or + phase (they drive order IDs and `created_session`).""" portfolio = Portfolio(initial_cash=Decimal("100000")) log = EventLog() portal = InMemoryDataPortal(calendar=["20240102"]) diff --git a/tests/engine/test_context_full.py b/tests/engine/test_context_full.py index 9ea18f5..db5b6a2 100644 --- a/tests/engine/test_context_full.py +++ b/tests/engine/test_context_full.py @@ -1,9 +1,10 @@ -"""Tests for the full Context API (read-only accessors + order intents + lifecycle). +"""Tests for the full Context API (read-only accessors + order intents ++ lifecycle). -These tests use a tiny three-day `InMemoryDataPortal` snapshot and exercise -Context directly, without a `BacktestEngine`. The goal is to lock in the -contract for task 6: read-only access, no ledger mutation, lot alignment, -order-type allow-list, lifecycle errors. +These tests use a tiny three-day `InMemoryDataPortal` snapshot and +exercise Context directly, without a `BacktestEngine`. The goal is to +lock in the contract for Context: read-only access, no ledger +mutation, lot alignment, order-type allow-list, lifecycle errors. """ from dataclasses import dataclass diff --git a/tests/engine/test_engine.py b/tests/engine/test_engine.py index f5c5d66..7a63eed 100644 --- a/tests/engine/test_engine.py +++ b/tests/engine/test_engine.py @@ -2,7 +2,8 @@ These tests use `InMemoryDataPortal` to assert the event sequence, the visible_through settings and the order/fill relationship the contract -requires. No real matching is performed (task 5 scope). +requires. No real matching is performed in this layer (matching is +the broker's job). """ from dataclasses import dataclass @@ -129,10 +130,10 @@ def after_trading_end(self, context): def test_engine_before_trading_start_cannot_read_future(): """The strategy must never see past `visible_through`. - Per task 14: on the first trading day the sentinel `00000000` is - used and `history(...)` returns `[]` rather than raising (the - strategy is allowed to call it; the data layer simply has nothing). - On every other day, a direct future-data access still must raise. + On the first trading day the sentinel `00000000` is used and + `history(...)` returns `[]` rather than raising (the strategy is + allowed to call it; the data layer simply has nothing). On every + other day, a direct future-data access still must raise. """ from hqbacktest.data import FutureDataAccessError @@ -194,9 +195,9 @@ def after_trading_end(self, context): def test_engine_orders_submitted_at_bar_close_cannot_match_on_same_day(): - """Contract: an order created at BAR_CLOSE(D) is queued for OPEN_MATCH(D+1). - - Task 5 does not implement fills, so the strict assertion here is: + """Contract: an order created at BAR_CLOSE(D) is queued for + OPEN_MATCH(D+1). The engine layer doesn't implement fills, so the + strict assertion here is: - the order is created in BAR_CLOSE(D) - the next OPEN_MATCH event is OPEN_MATCH(D+1), never OPEN_MATCH(D) """ @@ -249,7 +250,7 @@ def after_trading_end(self, context): # 3) For each created order, find the next OPEN_MATCH event and assert # it's strictly after the order's creation date. We exclude the last # trading day, which has no follow-up OPEN_MATCH in the backtest - # window (that order would have been CANCELLED at run end by task 7). + # window (that order would have been CANCELLED at run end). last_day = submitter.created_order_dates[-1] for order_date in submitter.created_order_dates: if order_date == last_day: @@ -319,8 +320,8 @@ def test_engine_event_log_preserves_chronological_order(): def test_engine_rejects_empty_calendar_window(): - """Task 20: an empty trading-day window must raise rather than - silently produce an empty result. + """An empty trading-day window must raise rather than silently + produce an empty result. """ from hqbacktest.engine.errors import ConfigurationError diff --git a/tests/engine/test_task19_factor_diagnostics.py b/tests/engine/test_factor_diagnostics.py similarity index 99% rename from tests/engine/test_task19_factor_diagnostics.py rename to tests/engine/test_factor_diagnostics.py index d78431d..3323338 100644 --- a/tests/engine/test_task19_factor_diagnostics.py +++ b/tests/engine/test_factor_diagnostics.py @@ -1,4 +1,4 @@ -"""Task 19: factor-diagnostics-on-holding + CLI summary tests. +"""Factor-diagnostics-on-holding + CLI summary tests. Covers: * Holding through an ex-date with a > 0.1% factor jump emits a diff --git a/tests/engine/test_intents.py b/tests/engine/test_intents.py index 36433b9..a257086 100644 --- a/tests/engine/test_intents.py +++ b/tests/engine/test_intents.py @@ -52,8 +52,8 @@ def test_signed_diff_to_lots_buy(): def test_signed_diff_to_lots_sell(): - # Need to go from 300 to 50; diff = -250. Task 16: SELL preserves - # the requested share count (odd-lot SELLs allowed per A-share rules), + # Need to go from 300 to 50; diff = -250. SELL preserves the + # requested share count (odd-lot SELLs allowed per A-share rules), # so the result is -250, not lot-rounded -200. assert signed_diff_to_lots(50, 300) == -250 @@ -73,8 +73,8 @@ def test_target_quantity_for_value_positive(): def test_target_quantity_for_value_zero_returns_zero_per_docstring(): - # Task 16: per the function's docstring ("may be 0 to flatten"), a - # zero target returns 0 — the caller flattens via order_target(). + # Per the function's docstring ("may be 0 to flatten"), a zero + # target returns 0 — the caller flattens via order_target(). assert ( target_quantity_for_value(Decimal("0"), Decimal("12.50"), current_quantity=300) == 0 diff --git a/tests/engine/test_iterator.py b/tests/engine/test_iterator.py index aa77dd7..c2d43f4 100644 --- a/tests/engine/test_iterator.py +++ b/tests/engine/test_iterator.py @@ -25,8 +25,8 @@ def test_iterator_respects_window(): def test_iterator_raises_when_window_has_no_open_days(): - """Task 20: an empty trading-day window is a hard error, not a - silent success. This avoids the "no signals" misreport bug. + """An empty trading-day window is a hard error, not a silent + success. This avoids the "no signals" misreport bug. """ p = _portal_with(["20240102"]) with pytest.raises(ConfigurationError, match="no trading days"): diff --git a/tests/engine/test_task16_matching.py b/tests/engine/test_matching_ledger.py similarity index 97% rename from tests/engine/test_task16_matching.py rename to tests/engine/test_matching_ledger.py index ebd86b1..d36b38a 100644 --- a/tests/engine/test_task16_matching.py +++ b/tests/engine/test_matching_ledger.py @@ -1,4 +1,4 @@ -"""Task 16 hand-calculated regression tests for matching + lot rounding. +"""Hand-calculated regression tests for matching + lot rounding. Covers: * Same-day SELL proceeds are available to fund the same batch's BUY @@ -90,11 +90,11 @@ def test_same_day_sell_proceeds_fund_buy_in_same_batch(): """Hand-calculated regression: hold 900 @ 10 (cost basis 9000), cash 45; same batch SELL 900 + BUY 900 @ 10. - Before task 16 the BUY was rejected with INSUFFICIENT_CASH because - the broker matched in submission order using a pre-batch cash - snapshot. After task 16 SELL runs first and its net proceeds - (900 * 10 - 5 commission - 9 stamp = 8986) push cash from 45 to - 9031, funding the 9005-cost BUY in the same batch. + Earlier the BUY was rejected with INSUFFICIENT_CASH because the + broker matched in submission order using a pre-batch cash + snapshot. The broker now matches SELLs first and the SELL net + proceeds (900 * 10 - 5 commission - 9 stamp = 8986) push cash from + 45 to 9031, funding the 9005-cost BUY in the same batch. """ class Rotate(BaseStrategy): diff --git a/tests/engine/test_metrics.py b/tests/engine/test_metrics.py index 8352caf..8744518 100644 --- a/tests/engine/test_metrics.py +++ b/tests/engine/test_metrics.py @@ -1,4 +1,4 @@ -"""Tests for `engine/metrics.py` (task 10 verification). +"""Tests for `engine/metrics.py`. Hand-calculated values used here: * 100 -> 110 -> 121 -> 110 over 3 days gives: @@ -326,16 +326,16 @@ def test_no_fills_records_trade_count_zero_and_turnover_zero(): def test_sharpe_with_risk_free_rate_and_simple_equity(): - """Task 23: a 2-day equity curve with two distinct `daily_return` - samples produces a defined Sharpe ratio. + """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`, + Earlier this test asserted `sharpe_ratio is None` because the + older `_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. + return. The series passed to `stdev` is now 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] @@ -370,7 +370,7 @@ def test_sharpe_with_risk_free_rate_and_simple_equity(): m = compute_metrics( equity_curve=eq, fills=[], initial_cash=Decimal("100000"), config=cfg ) - # Task 23: 2 distinct samples -> volatility is defined. + # 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. @@ -385,8 +385,8 @@ def test_sharpe_with_risk_free_rate_and_simple_equity(): def test_sharpe_with_three_days_of_varying_returns(): # 100 -> 110 -> 121 -> 110. Returns: 0, 0.10, 0.10, -0.0909. - # 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). + # stdev (sample) of the full series [0, 0.10, 0.10, -0.0909] + # ≈ 0.0918 (the first day's return is included, not sliced off). eq = [ EquityPoint( "20240102", diff --git a/tests/engine/test_task17_metrics.py b/tests/engine/test_metrics_baseline.py similarity index 95% rename from tests/engine/test_task17_metrics.py rename to tests/engine/test_metrics_baseline.py index ee14dee..c5f9c59 100644 --- a/tests/engine/test_task17_metrics.py +++ b/tests/engine/test_metrics_baseline.py @@ -1,4 +1,4 @@ -"""Task 17: equity curve / metrics baseline regression tests. +"""Equity curve / metrics baseline regression tests. Covers: * First-day P&L flows into `daily_return` (no longer hard-coded 0). @@ -139,8 +139,8 @@ def on_bar(self, context, data): # at day-2 close = 11500 * 8.2 = 77900. drawdown ~= (100000 - 72876) / # 100000 ≈ 0.27 once fees are subtracted; but max_drawdown uses the # equity curve which is initial_cash (no holding) -> -0.18 the day - # the position marks. We just assert drawdown > 0 (was 0 before - # task 17). + # the position marks. We just assert drawdown > 0 (used to be 0 + # before first-day P&L flowed into the series). assert result.metrics.max_drawdown > Decimal("0.17") assert result.metrics.max_drawdown < Decimal("0.20") @@ -219,8 +219,8 @@ def test_single_day_volatility_is_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`. + """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) @@ -228,19 +228,19 @@ def test_two_day_volatility_uses_both_daily_returns(): Day 2: total_equity = 96005 (up 5.5% from 91000) -> daily_return = +0.055 - Pre-fix: `_daily_returns` re-derived the series from + Earlier: `_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: + `compute_metrics` now 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). + way `max_drawdown` does. """ eq = [ EquityPoint( @@ -376,8 +376,8 @@ def test_drawdown_peak_includes_initial_cash_on_continued_drop(): Scenario: BUY 9500 @ 10 at day-1 open, close 9.1 -> equity 91426.25 (drawdown ~8.57%). Day-2 close 8.9 -> equity 89526.25. The correct day-2 drawdown is (100000 - 89526.25) / 100000 = 10.47%, NOT - (91426.25 - 89526.25) / 91426.25 = 2.08% — the peak must be - `initial_cash` (task 17: "回撤峰值序列以 initial_cash 为初始峰值"). + (91426.25 - 89526.25) / 91426.25 = 2.08% — the peak must include + `initial_cash` so a first-day loss is never silently lost. """ class BuyAtOpen(BaseStrategy): diff --git a/tests/engine/test_result_export.py b/tests/engine/test_result_export.py index 99cd285..43df72e 100644 --- a/tests/engine/test_result_export.py +++ b/tests/engine/test_result_export.py @@ -1,4 +1,4 @@ -"""End-to-end + save/load tests for task 10.""" +"""End-to-end + save/load tests for `BacktestResult`.""" import json from decimal import Decimal @@ -90,8 +90,8 @@ def on_bar(self, context, data): def test_engine_empty_calendar_raises(): - """Task 20: an empty trading-day window must raise rather than - silently produce an empty result. + """An empty trading-day window must raise rather than silently + produce an empty result. """ from hqbacktest.engine.errors import ConfigurationError @@ -305,9 +305,9 @@ def initialize(self, context): def test_engine_valuation_uses_lookback_for_suspended_symbol(): - """Contract §4 + task 14: a suspended holding is valued at the most - recent valid close within the lookback window and a DATA_WARNING is - recorded. The run continues normally. + """A suspended holding is valued at the most recent valid close + within the lookback window and a DATA_WARNING is recorded. The + run continues normally. """ class BuyHold(BaseStrategy): diff --git a/tests/engine/test_task18_isolation.py b/tests/engine/test_strategy_isolation.py similarity index 98% rename from tests/engine/test_task18_isolation.py rename to tests/engine/test_strategy_isolation.py index 2dccfc4..7275e65 100644 --- a/tests/engine/test_task18_isolation.py +++ b/tests/engine/test_strategy_isolation.py @@ -1,4 +1,4 @@ -"""Task 18: strategy isolation + audit-trail integrity tests. +"""Strategy isolation + audit-trail integrity tests. Covers: * `Order` is frozen after creation: a strategy that mutates the @@ -268,7 +268,7 @@ def on_bar(self, context, data): def test_order_fill_ids_is_immutable(): """`Order.fill_ids` must be an immutable tuple so a strategy holding - a frozen Order cannot append/clear it in place (task 18). + a frozen Order cannot append/clear it in place. A frozen dataclass only blocks attribute *reassignment*; a `list` field would still be mutable in place. Switching to `tuple` removes diff --git a/tests/examples/strategies/__init__.py b/tests/examples/strategies/__init__.py index dac00dc..36f39a1 100644 --- a/tests/examples/strategies/__init__.py +++ b/tests/examples/strategies/__init__.py @@ -1,4 +1,4 @@ -"""Helper strategy modules for the CLI tests (task 12). +"""Helper strategy modules for the CLI tests. These are intentionally separate from the public example modules so `resolve_strategy` exercises kwargs plumbing without affecting the diff --git a/tests/examples/test_buy_and_hold.py b/tests/examples/test_buy_and_hold.py index c38f7b3..9bf8b7b 100644 --- a/tests/examples/test_buy_and_hold.py +++ b/tests/examples/test_buy_and_hold.py @@ -1,4 +1,4 @@ -"""End-to-end regression test for `examples/buy_and_hold.py` (task 11). +"""End-to-end regression test for `examples/buy_and_hold.py`. Drives the public API: builds the same `InMemoryDataPortal` fixture the example uses, instantiates `BuyAndHold` from the example module, and locks diff --git a/tests/examples/test_moving_average.py b/tests/examples/test_moving_average.py index e6ef339..4e143a1 100644 --- a/tests/examples/test_moving_average.py +++ b/tests/examples/test_moving_average.py @@ -1,4 +1,4 @@ -"""End-to-end regression test for `examples/moving_average.py` (task 11).""" +"""End-to-end regression test for `examples/moving_average.py`.""" import importlib.util import subprocess diff --git a/tests/fixtures/sample_data.py b/tests/fixtures/sample_data.py index 5771a01..7bba39a 100644 --- a/tests/fixtures/sample_data.py +++ b/tests/fixtures/sample_data.py @@ -1,4 +1,4 @@ -"""Shared test fixtures for the end-to-end example tests (task 11). +"""Shared test fixtures for the end-to-end example tests. The data here is intentionally small, deterministic and hand-traceable. The end-to-end tests in `tests/examples/` use these fixtures and verify diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 4bb626d..3eb130b 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,4 +1,4 @@ -"""Task 21: real-data integration smoke tests for v0.1.1. +"""Real-data integration smoke tests. These tests run against the local `~/.hqdata/tushare` snapshot only when that directory exists AND contains the calendar.csv + a non-empty @@ -12,7 +12,7 @@ local snapshot changes. The four scenarios cover the v0.1 findings that previously broke -real-data runs (per TODO.md 21): +real-data runs: 1. buy_and_hold across 600000.SH's 2026-07-16 dividend ex-date: factor jumps 16.5935 -> 17.3774 (~4.7%), factor diagnostics diff --git a/tests/integration/_harness.py b/tests/integration/_harness.py index 9d11e88..2cd32dd 100644 --- a/tests/integration/_harness.py +++ b/tests/integration/_harness.py @@ -11,7 +11,7 @@ DEFAULT_SOURCE = "tushare" # Calibration snapshot window (139 trading days for the -# `~/.hqdata/tushare` snapshot on the v0.1.1 release machine). +# `~/.hqdata/tushare` snapshot). CALIBRATION_START = "20260105" CALIBRATION_END = "20260731" diff --git a/tests/integration/test_real_data_buy_and_hold.py b/tests/integration/test_real_data_buy_and_hold.py index 900e7ad..0804148 100644 --- a/tests/integration/test_real_data_buy_and_hold.py +++ b/tests/integration/test_real_data_buy_and_hold.py @@ -1,6 +1,6 @@ """Integration scenario 1: buy_and_hold across the 2026-07-16 dividend. -Calibrated against the `~/.hqdata/tushare` snapshot for v0.1.1: +Calibrated against the `~/.hqdata/tushare` snapshot: * 600000.SH factor jumps 16.5935 -> 17.3774 (~4.7%) on 20260716. * Buy-and-hold from 20260105 to 20260731 with 95% sizing must produce at least one DATA_WARNING factor diagnostic. diff --git a/tests/integration/test_real_data_first_day.py b/tests/integration/test_real_data_first_day.py index 0e4cb27..9d04402 100644 --- a/tests/integration/test_real_data_first_day.py +++ b/tests/integration/test_real_data_first_day.py @@ -1,11 +1,11 @@ """Integration scenario 4: first-trading-day `before_trading_start` reading `current_price` against the real snapshot. -Per task 14, the first trading day uses the sentinel `visible_through` -of `"00000000"`, so `current_price` must return `None` rather than -crashing. This guards the documented sentinel contract end-to-end -against a real CSV portal (where the empty/invalid date would -otherwise trip the validator). +On the first trading day the sentinel `visible_through` of +`"00000000"` is used, so `current_price` must return `None` rather +than crashing. This guards the documented sentinel contract +end-to-end against a real CSV portal (where the empty/invalid date +would otherwise trip the validator). """ from __future__ import annotations diff --git a/tests/integration/test_real_data_ma_5_symbols.py b/tests/integration/test_real_data_ma_5_symbols.py index faa0b29..c6476f6 100644 --- a/tests/integration/test_real_data_ma_5_symbols.py +++ b/tests/integration/test_real_data_ma_5_symbols.py @@ -1,7 +1,7 @@ """Integration scenario 2: 5-symbol moving-average strategy over the full window, with a wall-clock budget and byte-determinism check. -Calibrated against the `~/.hqdata/tushare` snapshot for v0.1.1: +Calibrated against the `~/.hqdata/tushare` snapshot: * 5 picked symbols (well-known large caps). * Run budget: < 60 s for the full 139-day window. * Two runs with identical inputs must produce byte-identical output @@ -74,7 +74,7 @@ def test_5_symbol_moving_average_full_window_deterministic(tmp_path: Path): same equity_curve.csv / summary.json / fills.csv bytes (modulo the timestamp field). - Calibrated to < 60 s on the v0.1.1 release machine; the + Calibrated to < 60 s on the release machine; the threshold is intentionally generous to survive CI jitter. """ (tmp_path / "strategy.py").write_text(_STRATEGY_SRC) diff --git a/tests/integration/test_real_data_suspended.py b/tests/integration/test_real_data_suspended.py index ddd5a60..3050694 100644 --- a/tests/integration/test_real_data_suspended.py +++ b/tests/integration/test_real_data_suspended.py @@ -1,7 +1,7 @@ """Integration scenario 3: universe with a suspended stock must not crash; warnings must be traceable. -Calibrated against the `~/.hqdata/tushare` snapshot for v0.1.1: +Calibrated against the `~/.hqdata/tushare` snapshot: * 000008.SZ is suspended over 20260707..20260713 (7 trading days). * Universe containing 000008.SZ must let the engine run, with the suspension recorded via task-14's `DATA_WARNING` (fallback close diff --git a/tests/test_package.py b/tests/test_package.py index 7d44715..e9faa48 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -25,14 +25,12 @@ def test_version_matches_pyproject(): `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. + Earlier this was just `test_version_is_non_empty_string`, which + only asserted that `__version__` was a non-empty string. Its + docstring claimed it matched `pyproject.toml` but the body never + compared the two — that gap let `src/hqbacktest/__init__.py` + drift from `pyproject.toml` and made every `run_metadata.json` + record a stale engine version. This test parses `[project].version` from the in-repo `pyproject.toml` and asserts byte equality with the package @@ -86,7 +84,7 @@ def test_version_matches_pyproject(): def test_public_api_includes_domain_models(): - """After task 9, the package re-exports domain, data and engine layers.""" + """The package re-exports domain, data, and engine layers.""" import hqbacktest expected = {