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 = {