diff --git a/config/phase_i5a_intraday_tail_framework.yaml b/config/phase_i5a_intraday_tail_framework.yaml index fdc9bc7..19a2bfa 100644 --- a/config/phase_i5a_intraday_tail_framework.yaml +++ b/config/phase_i5a_intraday_tail_framework.yaml @@ -2,7 +2,8 @@ # # Proves the shared event-driven backtest engine drives an IntradayTailEventModel # end-to-end on REAL SH/SZ minute data: decision at 14:50, execution at the first -# valid 1min close in [14:51, 14:56:59], exec-to-exec holding returns. The score is +# valid 1min bar in [14:51, 14:56:59] priced on the active execution_price_basis +# (the bar VWAP by default), exec-to-exec holding returns. The score is # a single PIT-safe I3 feature (intraday_ret_0930_1450), not a research alpha. # # Minute bars are read from the EXISTING intraday cache only (read-only; a cache diff --git a/config/phase_i5b_intraday_execution_feasibility.yaml b/config/phase_i5b_intraday_execution_feasibility.yaml index 590273b..5f810de 100644 --- a/config/phase_i5b_intraday_execution_feasibility.yaml +++ b/config/phase_i5b_intraday_execution_feasibility.yaml @@ -1,11 +1,12 @@ # Phase I5b — intraday execution-time price-limit feasibility (hardening, NOT research). # # Same intraday tail-rebalance smoke as I5a (decision 14:50, execute at the first -# valid 1min close in [14:51, 14:56:59], exec-to-exec holding returns, PIT-safe I3 +# valid 1min bar in [14:51, 14:56:59] priced on the active execution_price_basis, exec-to-exec holding returns, PIT-safe I3 # score) PLUS execution-time price-limit feasibility: a buy is blocked at the raw -# upper limit and a sell at the raw lower limit, comparing the selected -# execution-minute RAW 1min close to the raw stk_limit band (NEVER qfq / daily -# close / daily-close-derived flags). +# upper limit and a sell at the raw lower limit, comparing the RAW price that +# EXECUTES at the selected minute -- the bar VWAP under the default +# execution_price_basis -- to the raw stk_limit band (NEVER qfq / daily close / +# daily-close-derived flags). # # Minute bars are read from the EXISTING intraday cache only (read-only; a miss is # a hard blocker -> zero stk_mins live calls). Raw stk_limit flows through the same @@ -42,8 +43,9 @@ universe: filters: # Selection-level daily tradability flags stay DISABLED: at the 14:50 decision # the daily-close-derived limit flag is an EOD value not known yet. The honest - # direction-aware blocking happens at EXECUTION via raw stk_limit vs the raw - # execution-minute close (intraday.price_limit_check below). Disclosed in the report. + # direction-aware blocking happens at EXECUTION via raw stk_limit vs the RAW + # price that EXECUTES at that minute (intraday.price_limit_check below). + # Disclosed in the report. missing_close: true suspended: false st: false @@ -95,7 +97,8 @@ intraday: - '14:56:59' require_cache_coverage: true missing_execution: block - # I5b: execution-time raw price-limit feasibility (raw 1min close vs raw stk_limit). + # I5b: execution-time raw price-limit feasibility (the RAW price that EXECUTES + # at that minute vs raw stk_limit). price_limit_check: true require_price_limit_coverage: true limit_tolerance: 1.0e-06 diff --git a/config/phase_i5c_mmp_minute_factor.yaml b/config/phase_i5c_mmp_minute_factor.yaml index e0378d8..14f4ec7 100644 --- a/config/phase_i5c_mmp_minute_factor.yaml +++ b/config/phase_i5c_mmp_minute_factor.yaml @@ -42,7 +42,8 @@ universe: filters: # Selection-level daily tradability flags stay DISABLED (as in I5b): the daily # close limit flag is an EOD value not known at 14:50. The honest direction-aware - # blocking is execution-time raw stk_limit vs the raw execution-minute close. + # blocking is execution-time raw stk_limit vs the RAW price that EXECUTES at that minute + # (the bar VWAP under the default execution_price_basis). missing_close: true suspended: false st: false diff --git a/config/phase_i5d_mmp_quintile_5y.yaml b/config/phase_i5d_mmp_quintile_5y.yaml index cf42d12..0c8cf9a 100644 --- a/config/phase_i5d_mmp_quintile_5y.yaml +++ b/config/phase_i5d_mmp_quintile_5y.yaml @@ -42,8 +42,9 @@ universe: filters: # Selection-level daily tradability flags stay DISABLED (as in I5b/I5c): the # daily close limit flag is an EOD value not known at 14:50. The honest - # direction-aware blocking is execution-time raw stk_limit vs the raw - # execution-minute close. + # direction-aware blocking is execution-time raw stk_limit vs the RAW price + # that EXECUTES at that minute (the bar VWAP under the default + # execution_price_basis). missing_close: true suspended: false st: false diff --git a/config/phase_i5e_mmp_quintile_csi300_generalization.yaml b/config/phase_i5e_mmp_quintile_csi300_generalization.yaml index 511a3e4..b3726b1 100644 --- a/config/phase_i5e_mmp_quintile_csi300_generalization.yaml +++ b/config/phase_i5e_mmp_quintile_csi300_generalization.yaml @@ -44,8 +44,9 @@ universe: filters: # Selection-level daily tradability flags stay DISABLED (as in I5b/I5c/I5d): the # daily close limit flag is an EOD value not known at 14:50. The honest - # direction-aware blocking is execution-time raw stk_limit vs the raw - # execution-minute close. + # direction-aware blocking is execution-time raw stk_limit vs the RAW price + # that EXECUTES at that minute (the bar VWAP under the default + # execution_price_basis). missing_close: true suspended: false st: false diff --git a/config/phase_i5f_intraday_liquidity_diagnostics.yaml b/config/phase_i5f_intraday_liquidity_diagnostics.yaml index 952875f..113cfdb 100644 --- a/config/phase_i5f_intraday_liquidity_diagnostics.yaml +++ b/config/phase_i5f_intraday_liquidity_diagnostics.yaml @@ -1,7 +1,7 @@ # Phase I5f — intraday execution liquidity diagnostics (REPORT-ONLY, NOT research). # # Same intraday tail-rebalance smoke as I5a/I5b (decision 14:50, execute at the -# first valid 1min close in [14:51, 14:56:59], exec-to-exec holding returns, +# first valid 1min bar in [14:51, 14:56:59] priced on the active execution_price_basis, exec-to-exec holding returns, # PIT-safe I3 score, raw stk_limit execution-time feasibility ON) PLUS an opt-in, # REPORT-ONLY execution liquidity diagnostic. For each desired rebalance trade # (|target_weight - current_weight|) it sizes the trade against the SELECTED @@ -48,7 +48,8 @@ universe: filters: # Selection-level daily tradability flags stay DISABLED (as in I5b): the daily # close limit flag is an EOD value not known at 14:50. The honest direction-aware - # blocking is execution-time raw stk_limit vs the raw execution-minute close. + # blocking is execution-time raw stk_limit vs the RAW price that EXECUTES at that minute + # (the bar VWAP under the default execution_price_basis). missing_close: true suspended: false st: false diff --git a/qt/config.py b/qt/config.py index 2ee17e9..93cac22 100644 --- a/qt/config.py +++ b/qt/config.py @@ -340,8 +340,11 @@ class IntradayCfg(_Strict): # I5b execution-time price-limit feasibility. OFF by default, so every I5a / # daily config validates and behaves unchanged. When enabled, the intraday # tail model gates buys at the raw upper limit and sells at the raw lower - # limit, comparing the selected execution-minute RAW close to raw ``stk_limit`` - # (never qfq / daily close). ``require_price_limit_coverage`` makes a missing + # limit, comparing the RAW price that EXECUTES at the selected minute -- the + # bar VWAP under the default ``execution_price_basis`` -- to raw ``stk_limit`` + # (never qfq / daily close; see qt.intraday_tail_framework.limit_basis_phrase, + # the single authored statement of this, which this module cannot import + # without a cycle). ``require_price_limit_coverage`` makes a missing # required (anchor date, symbol) limit row a hard, pre-result failure instead # of a silent "checked" pass; ``limit_tolerance`` is the raw-price equality # band for the at-limit test. diff --git a/qt/intraday_tail_framework.py b/qt/intraday_tail_framework.py index cb263f3..4d1efcd 100644 --- a/qt/intraday_tail_framework.py +++ b/qt/intraday_tail_framework.py @@ -13,7 +13,8 @@ -> deterministic PIT-safe score = the I3 ``intraday_ret_0930_1450`` feature (only bars with available_time <= 14:50 enter it) -> engine + IntradayTailEventModel: decision 14:50, execute at the first valid - 1min close in [14:51, 14:56:59], exec-to-exec holding returns + 1min bar in [14:51, 14:56:59] priced on the active execution_price_basis + (the bar VWAP by default), exec-to-exec holding returns -> NAV / feasibility / holdings / event logs -> markdown report. It is intentionally small: the score is a single already-PIT-safe feature solely diff --git a/runtime/backtest/event_models.py b/runtime/backtest/event_models.py index 86b634b..330070e 100644 --- a/runtime/backtest/event_models.py +++ b/runtime/backtest/event_models.py @@ -132,6 +132,15 @@ class IntradayTailEventModel: symbol/date's raw ``stk_limit`` band. A buy is blocked at the upper limit, a sell at the lower limit, directionally. + That sentence is authored once, in + ``qt.intraday_tail_framework.limit_basis_phrase``, and every report composes + it. This docstring cannot import it — ``qt.intraday_tail_framework`` already + imports this class, so the reverse would cycle — so it is one of the two + restatements that survive by necessity. It is covered instead by the repo-wide + scan in ``tests/test_i5b_execution_feasibility.py``, which fails if any file + claims this gate compares a CLOSE. Change the behaviour and that scan will + point here. + Why the gate reads the VWAP and not the bar close. A limit-up execution minute has exactly two shapes, and they are the feasibility question: diff --git a/tests/test_i5b_execution_feasibility.py b/tests/test_i5b_execution_feasibility.py index 775c366..16267b4 100644 --- a/tests/test_i5b_execution_feasibility.py +++ b/tests/test_i5b_execution_feasibility.py @@ -476,55 +476,67 @@ def test_group_report_feasibility_prose_names_the_active_execution_basis(): assert "adj_factor" in text -def test_no_module_repeats_the_pre_pr75_close_based_limit_claim(): - """Guard the DEFECT CLASS, not just the two places already fixed. - - The gate input moved from the bar close to the executed price in PR #75. The - first correction pass fixed the prose in the two obvious spots and MISSED a - third copy in `_write_report`'s "Limitations" section, so a run would have - emitted two contradictory descriptions of the same check in one document. - - Review found it, and found why the other tests could not: none of them render - `_write_report` at all, so that location was structurally unreachable. - - SCOPE, stated honestly because the first version of this docstring overstated - it. This catches a LITERAL revert -- a bad merge or rebase restoring the old - sentence, which is the failure that actually happened. It does NOT catch a - reworded restatement: review wrote seven plausible ones ("its last print", - "the final tick", "1-minute" for "1min") and all seven escape this regex. No - lexical guard can assert "no other sentence makes this claim"; English has too - many ways to make it. The durable fix is structural and is the reason - `limit_basis_phrase` exists -- with one authored sentence there is no second - one to reword. This test guards that one sentence against being un-written. +def test_no_file_anywhere_claims_the_gate_compares_a_close(): + """Repo-wide: NO file may state that the I5b gate compares a close. + + The first version of this scanned only the two report modules. Review then + found SIX more live instances it could not reach even in principle -- one in + `qt/config.py` and five in the I5b/c/d/e/f config YAMLs, which are exactly what + an operator reads before enabling the feature, a higher-stakes readership than + a docstring. Scoping a guard to the files you already fixed guarantees it only + ever confirms what you already know. + + Two restatements survive by necessity and are covered by this scan rather than + by composition: `runtime/backtest/event_models.py` and `qt/config.py` both sit + UPSTREAM of `qt.intraday_tail_framework` in the import graph (it imports them), + so neither can call `limit_basis_phrase` without a cycle. + + Deliberately NOT matched: `bar_close` the basis name, "that bar's single + closing tick" the basis description, and the DAILY universe tradability filter + (`universe/filters.py`, `data/clean/tradability.py`), which is a separate and + older mechanism that genuinely does compare a raw daily close -- see + RUNBOOK.md. Those are correct and must not be swept up. + + Scope, stated honestly: this catches a claim written with the word "close" + near "execution minute". It does NOT catch a reworded synonym ("its last + print", "the final tick") -- review demonstrated seven such escapes. No lexical + guard can assert "no sentence anywhere makes this claim". Composition is the + real answer where the import graph allows it; this scan is the net under the + places where it does not. """ import re from pathlib import Path - import qt.intraday_group_report as gr - import qt.intraday_tail_framework as tf - - # Phrasings that assert the LIMIT COMPARISON reads a close. "bar_close" the - # basis name and "that bar's single closing tick" the basis description are - # both legitimate and deliberately not matched. + root = Path(__file__).resolve().parent.parent forbidden = re.compile( - r"execution-minute\s+(\*\*raw\*\*\s+)?(1min\s+)?close" - r"|1min\s+close\s+to\s+the\s+raw" - r"|raw\s+execution-minute\s+close", + r"execution[- ]minute[^.\n]{0,40}\bclose\b" + r"|\b1min close\b" + r"|raw\s+close\s+to\s+(the\s+)?raw\s+`?`?stk_limit", re.IGNORECASE, ) - for mod in (tf, gr): - src = Path(mod.__file__).read_text() - hits = [ - f"line {i}: {ln.strip()}" - for i, ln in enumerate(src.splitlines(), 1) - if forbidden.search(ln) - ] - assert not hits, ( - f"{Path(mod.__file__).name} still claims the price-limit gate compares " - f"a CLOSE. The gate reads the price that executes (the bar VWAP under " - f"the default basis). Derive the wording from execution_price_basis " - f"instead of restating it:\n " + "\n ".join(hits) - ) + targets = [root / "qt" / "config.py", *sorted((root / "config").glob("*.yaml"))] + targets += sorted((root / "qt").glob("intraday*.py")) + targets += [root / "runtime" / "backtest" / "event_models.py", + root / "runtime" / "intraday_execution.py"] + # The exec-basis modules describe the same execution anchor from the factor + # side. They are accurate today; they are listed BEFORE they go stale, which + # is the whole lesson of how this guard had to be widened in the first place. + targets += sorted((root / "qt").glob("exec_basis*.py")) + targets += [root / "qt" / "exec_forward_returns.py"] + + hits = [] + for f in targets: + if not f.exists(): + continue + for i, line in enumerate(f.read_text().splitlines(), 1): + if forbidden.search(line): + hits.append(f"{f.relative_to(root)}:{i}: {line.strip()}") + assert not hits, ( + "these files claim the I5b price-limit gate compares a CLOSE. It compares " + "the price that EXECUTES (the bar VWAP under the default basis). Where the " + "import graph allows it, compose limit_basis_phrase; where it does not, " + "point at it:\n " + "\n ".join(hits) + ) def test_every_gate_description_composes_the_single_phrase():