feat(runtime): P2-2 execution realism — direction-aware fills + min_listing_days - #3
Merged
Merged
Conversation
…sting_days P2-2 closes execution/tradability realism gaps. It splits SELECTION eligibility (what the strategy wants) from EXECUTION feasibility (what the market lets you trade) and stops the backtest from silently forcing impossible trades. No new factor, no parameter tuning. Execution feasibility (new): - runtime/fills.py: simulate_fills(current, target, can_buy, can_sell) — a cash-coherent sell-then-buy fill model. Sells execute first (freeing cash), buys are funded from available cash and scaled down proportionally if blocked sells starved them, so the book never sums to > 1 (no leverage). Blocked trades carry forward; turnover counts only executed trades. feasibility_from_cross derives (can_buy, can_sell) from the panel flags: at_up_limit blocks buys, at_down_limit blocks sells, suspended/missing-close blocks both — independent of the selection toggles (feasibility is market physics, not policy). - SimExecution.rebalance_to routes through simulate_fills (optional can_buy/ can_sell; None -> all feasible -> achieved == target, so demo/P0/P1 numbers are unchanged). settle() returns the invested book's return only; the driver owns the idle-cash return (BT-007), so SimExecution no longer takes a cash_return (it was dead after this split). - BacktestDriver._step reads feasibility from the rebalance date's cross-section (no lookahead), settles over the achieved book, adds idle*cash_return, and records a per-rebalance feasibility_log() (blocked buys/sells, carried, executed turnover, invested fraction) aligned 1:1 with the NAV index. min_listing_days (was a no-op): - apply_tradable_filters enforces it as a buy/selection filter: names younger than min_listing_days as of each date are excluded (boundary age==min allowed); a missing list_date is a disclosed data gap (kept, never silently dropped). - Real path enriches list_date from tushare stock_basic; demo stays a disclosed no-op. enrich_listing + TushareCovariatesFeed.listing_dates + pipeline wiring. Reporting/disclosure: phase2 report gains an Execution feasibility section; BIAS_AUDIT/RUNBOOK/TEST_REPORT/CLAUDE updated; _collect_downgrades is now direction-aware and path-aware for listing. Tests (204 passed): test_fills (10), test_driver_feasibility (5), test_min_listing_days (6). Also fixed a duplicate test-function name across two files that had been silently shadowing one test in the full-suite run. Real validation (SSE50, ~11 min, not in CI): the feasibility machinery runs and honestly reports zero blocks on blue-chips (they don't hit limits); list_date enriched 67/68; numbers unchanged vs P2-1 (P2-2 is behaviour-neutral there).
… list_date coverage Review fixes for the P2-2 auditable report (report/doc correctness, not strategy): 1. HIGH — Holdings per period reported the constructor's DESIRED target (reconstruct_holdings -> constructor.build), so once a real trade is blocked the report would show the wanted book, not the held one — breaking P2-2 auditability. BacktestDriver now records the ACHIEVED book per settled rebalance (holdings_log()); the phase2 report uses it. A blocked sell shows the carried name, a blocked buy is absent. reconstruct_holdings removed. Regression test drives a down-limit blocked sell and asserts the reported holdings are the carried name (achieved), never the desired target, and equal the execution book. 2. MEDIUM — the real run logged "list_date 67/68 known" but the report only stated the generic missing-list_date policy. list_date coverage (known / missing this run) is now in Phase2Result and the report, with a test. 3. Doc consistency — the holdings downgrade text, module docstring, and RUNBOOK still described holdings as a constructor.build reconstruction; updated to the driver's achieved (post-feasibility) book. RUNBOOK downgrade section updated so direction-aware limits and enforced min_listing_days are no longer listed as deferred. Gates: 205 passed, ruff clean. Real re-run (SSE50, ~11.7 min) regenerated the report from final code: ACHIEVED-holdings note, list_date 67/68 (1 disclosed gap), no stale constructor.build text; numbers unchanged (no blocks on blue-chips).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Highlights
min_listing_days(enforced on the real path viastock_basic.list_date; demo no-op).BacktestDriver.holdings_log()), not the constructor's desired target — a blocked sell shows the carried name, a blocked buy is absent.pytest205 passed,ruffclean, 3×validate-configOK (demo / example_tushare / phase2_real_baseline),run-phase0(demo) OK.Summary
Phase 2-2: execution / tradability realism. Splits SELECTION eligibility (what the strategy wants) from EXECUTION feasibility (what the market lets you trade), adds direction-aware limit/suspension fills, and enforces
universe.min_listing_days. No new factor, no parameter tuning, no live/QMT/vnpy. Demo/P0/P1 numbers are unchanged.What changed
Execution feasibility (
runtime/fills.py, new):simulate_fills(current, target, can_buy, can_sell)— cash-coherent sell-then-buy fill model. Sells execute first (freeing cash); buys are funded from available cash and scaled down proportionally if blocked sells starved them, so the book never sums to > 1 (no leverage). Blocked trades carry forward; turnover/cost count only executed trades. ReturnsFillResult(achieved + blocked_buys/blocked_sells/cash_constrained/carried).feasibility_from_crossderives(can_buy, can_sell)from the panel flags — independent of the selection toggles (feasibility is market physics, not policy): at-up-limit blocks buys, at-down-limit blocks sells, suspended/missing-close blocks both.Wiring:
SimExecution.rebalance_toroutes throughsimulate_fills(optionalcan_buy/can_sell;None→ all feasible →achieved == target, so the demo/offline path is unchanged).settle()returns the invested book's return only; the driver owns the idle-cash return (BT-007), soSimExecutionno longer takes acash_return(dead after the split).BacktestDriver._stepreads feasibility from the rebalance date's cross-section (no lookahead), settles over the achieved book, addsidle * cash_return, and recordsfeasibility_log()+holdings_log()(the ACHIEVED book per settled rebalance).min_listing_days (was a no-op):
apply_tradable_filtersenforces it as a buy/selection filter — names younger thanmin_listing_daysas of each date are excluded (boundaryage == minallowed); a missinglist_dateis a disclosed data gap (kept, never silently dropped). Real path enricheslist_datefromstock_basic; demo stays a disclosed no-op.Reporting / disclosure:
holdings_log()), not the constructor's desired target;list_datecoverage (e.g. 67/68) disclosed.BIAS_AUDIT.md/RUNBOOK.md/TEST_REPORT.md/CLAUDE.mdupdated.Review loop (closed)
A
code-reviewerpass returned APPROVE (0 CRITICAL/HIGH); its 1 MEDIUM (SimExecution.cash_returndead code) was removed and LOWs addressed. Two human review rounds then flagged: (1) HIGH — Holdings per period reported the desired target instead of the achieved book (broke auditability once a trade is blocked) → fixed (driver records & reports the achieved book; regression test on a down-limit blocked sell); (2) MEDIUM —list_datecoverage not disclosed for the run → added; (3) doc-consistency text in downgrades/RUNBOOK → corrected. All fixed and verified before this PR.Quality gates (this round, env
quant_mf)pytest -p no:cacheproviderruff check .validate-config×example.yaml/example_tushare.yaml/phase2_real_baseline.yamlrun-phase0 --config config/example.yamlic_mean=0.96, P0/P1 unchanged)run-phase2-baseline --config config/phase2_real_baseline.yamlHygiene: no tushare token, conflict markers, or
artifacts//*.parquettracked.Real-data validation (SSE50, manual, not in CI)
list_dateenriched 67/68 known (1 disclosed data gap, kept).test_driver_feasibility.py), not the real run.Test plan
test_fills.py(10): cash-coherent fills (no leverage), direction semantics, executed-only turnover, demo equivalence.test_driver_feasibility.py(6): end-to-end down-limit carries / up-limit blocks buy / suspended no-trade / holdings == achieved (not desired) / feasibility log == nav index.test_min_listing_days.py(6): boundaries (age<,==,>), missing list_date kept, no-op cases.test_phase2_baseline.py).