From dd1ab368a89c4a5cde114eb646df6bf4d5feea81 Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Tue, 21 Jul 2026 10:34:17 -0700 Subject: [PATCH 1/2] fix(qt): report the price-limit gate input that was actually used PR #75 moved the I5b gate from the execution bar's close to the price that executes -- the bar VWAP under the new default basis -- but left both report writers asserting "the selected execution-minute raw 1min close vs the raw stk_limit band". Every run since has shipped a report describing a check it did not perform, which is worse than shipping no description at all. The prose is now derived from the active execution_price_basis and states why the executed price is the faithful input: a limit-up minute is either LOCKED (every print at the limit, so the VWAP equals it up to rounding and the buy must be blocked) or OPENED (prints landed below it, direct evidence a fill was achievable, so the buy must go through). The bar close misclassifies both edges. Three facts the runs already computed but never disclosed now reach the reader: opened_limit_up_minutes / opened_limit_down_minutes -- the entire set on which this gate and a close-based gate disagree. A change in what does or does not trade must never be silent. missing_adj_factor_pairs -- holding periods dropped because adj_factor was unusable at an anchor. Non-zero means measured coverage is short; it is never defaulted to 1.0, which would reintroduce the ex-date bias PR #75 removed. The tail report additionally states that returns are corporate-action adjusted and gives the identity, since "exec-to-exec" alone no longer describes them. The wording is extracted into limit_basis_lines() so it is testable rather than buried in a 200-line writer, and pinned by three tests. Mutation evidence: reverting the prose to the close-based claim fails 2 of them; dropping the new counters from the group table fails the third. Note for anyone extending tests/test_i5b_execution_feasibility.py: its fixtures price each execution bar with volume=1.0 and amount=close, so VWAP == close. That kept the pre-PR#75 assertions valid on both bases -- and made them blind to which one the gate reads. The new tests cover that distinction directly. Also sets the I5f diagnostic notional to 1,000,000 RMB, the operator's stated capital ceiling. At 10,000,000 the diagnostic reported that about half of all single-minute desired trades exceeded capacity; that described a portfolio an order of magnitude larger than any this project will run. Re-measured at the real size, no trade falls below capacity (min ratio 1.217, median 11.037). --- ...se_i5f_intraday_liquidity_diagnostics.yaml | 12 ++- qt/intraday_group_backtest.py | 10 +++ qt/intraday_group_report.py | 35 +++++++-- qt/intraday_tail_framework.py | 65 ++++++++++++++-- tests/test_i5b_execution_feasibility.py | 77 ++++++++++++++++++- tests/test_i5d_mmp_quintile.py | 5 +- tests/test_i5f_intraday_liquidity.py | 6 +- 7 files changed, 189 insertions(+), 21 deletions(-) diff --git a/config/phase_i5f_intraday_liquidity_diagnostics.yaml b/config/phase_i5f_intraday_liquidity_diagnostics.yaml index d2bc4b3..952875f 100644 --- a/config/phase_i5f_intraday_liquidity_diagnostics.yaml +++ b/config/phase_i5f_intraday_liquidity_diagnostics.yaml @@ -15,9 +15,13 @@ # read-through cache as the daily endpoints. SSE50 (000016.SH) over the same short, # recent window as I5b matches the available minute-cache coverage. # -# portfolio_notional is an ILLUSTRATIVE diagnostic sizing (10,000,000 RMB), not a -# performance or tradability claim. No new factors, no tuning. The report writes to -# its own name so it never overwrites the accepted I5a/I5b artifacts. +# portfolio_notional is 1,000,000 RMB: the operator's stated total capital ceiling. +# It supersedes the original 10,000,000 sizing, which was illustrative and an order +# of magnitude above any book this project will actually run -- so its "about half +# of single-minute desired trades exceed capacity" finding described a portfolio +# that does not exist. This is still a diagnostic sizing, not a performance or +# tradability claim. No new factors, no tuning. The report writes to its own name so +# it never overwrites the accepted I5a/I5b artifacts. project: name: quantitative_trading_phase_i5f_intraday_liquidity_diagnostics @@ -103,7 +107,7 @@ intraday: # I5f: opt-in, report-only execution liquidity diagnostics. liquidity_diagnostics: enabled: true - portfolio_notional: 10000000 + portfolio_notional: 1000000 max_participation_rate: 0.05 mode: report_only cost: diff --git a/qt/intraday_group_backtest.py b/qt/intraday_group_backtest.py index bee0e9a..1f73928 100644 --- a/qt/intraday_group_backtest.py +++ b/qt/intraday_group_backtest.py @@ -327,6 +327,13 @@ class GroupRunResult: up_limit_blocked_buys: int down_limit_blocked_sells: int missing_limit_rows: int + # The only set on which the executed-price gate and a close-based gate differ: + # the minute closed at a limit but traded through it, so the fill stands. + opened_limit_up_minutes: int + opened_limit_down_minutes: int + # Holding-period returns dropped for want of a usable adj_factor at an anchor; + # never defaulted to 1.0, so a non-zero count must reach the reader. + missing_adj_factor_pairs: int blocked_fill_reasons: dict[str, int] @@ -430,6 +437,9 @@ def _run_one_group( up_limit_blocked_buys=model.up_limit_blocked_buys(), down_limit_blocked_sells=model.down_limit_blocked_sells(), missing_limit_rows=model.missing_limit_rows(), + opened_limit_up_minutes=model.opened_limit_up_minutes(), + opened_limit_down_minutes=model.opened_limit_down_minutes(), + missing_adj_factor_pairs=model.missing_adj_factor_pairs(), blocked_fill_reasons=blocked, ) return result, shared_prices diff --git a/qt/intraday_group_report.py b/qt/intraday_group_report.py index 14bd6a3..b641fb4 100644 --- a/qt/intraday_group_report.py +++ b/qt/intraday_group_report.py @@ -94,6 +94,12 @@ def _intro_lines(result) -> list[str]: f"- execution_price_basis: `{cfg.intraday.execution_price_basis}` " "(`bar_vwap` = the selected 1min bar's amount/volume, RAW unadjusted; " "`bar_close` = that bar's single closing tick)", + "- holding returns are **corporate-action adjusted**: the cached minute bars " + "are raw, so `(raw_exit * adj_factor(exit)) / (raw_entry * adj_factor(entry)) " + "- 1` divides out ex-dividend / split drops that would otherwise book as " + "losses. Fills still PAY the raw price and the limit gate stays raw — only " + "the measured return is adjusted (per-group drop counts in the feasibility " + "table below)", f"- trading cost: `cost.fee_rate={cfg.cost.fee_rate}`, " f"`slippage_rate={cfg.cost.slippage_rate}` (cost line = turnover × fee_rate " "inside SimExecution; no extra ad-hoc cost layer)", @@ -178,9 +184,14 @@ def _feasibility_lines(result) -> list[str]: cov = result.limit_coverage lines.extend([ "- **enabled** (`intraday.price_limit_check=true`): 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 " - "(RAW-vs-RAW; never qfq / daily close / a daily-close-derived flag).", + "raw upper limit and a sell at the raw lower limit, comparing the price " + f"that actually EXECUTES — the selected execution minute on the " + f"`{cfg.intraday.execution_price_basis}` basis — to the raw `stk_limit` " + "band (RAW-vs-RAW; never qfq / daily close / a daily-close-derived flag).", + "- a limit-up minute is LOCKED (every print at the limit, VWAP equals it up " + "to rounding, buy blocked) or OPENED (prints landed below it, which is " + "evidence a fill was achievable, buy allowed). The executed price separates " + "the two; the bar close misclassifies both edges.", f"- limit tolerance: `{cfg.intraday.limit_tolerance}`; " f"require_price_limit_coverage: `{cfg.intraday.require_price_limit_coverage}`", f"- limit coverage over rebalance anchors (shared across groups): required " @@ -192,15 +203,27 @@ def _feasibility_lines(result) -> list[str]: "Per-group blocked buy/sell counts (a fresh model per group, so counts do " "NOT double-count across groups):", "", - "| group | up-limit blocked buys | down-limit blocked sells | unchecked limit rows |", - "|---|---|---|---|", + "| group | up-limit blocked buys | down-limit blocked sells | unchecked limit rows " + "| opened limit-up (allowed) | opened limit-down (allowed) | returns dropped: no adj_factor |", + "|---|---|---|---|---|---|---|", ]) for g in result.groups: lines.append( f"| Q{g.group} | {g.up_limit_blocked_buys} | " - f"{g.down_limit_blocked_sells} | {g.missing_limit_rows} |" + f"{g.down_limit_blocked_sells} | {g.missing_limit_rows} | " + f"{g.opened_limit_up_minutes} | {g.opened_limit_down_minutes} | " + f"{g.missing_adj_factor_pairs} |" ) lines.append("") + lines.append( + "The two `opened limit-*` columns are the entire set on which this gate and " + "a close-based gate disagree: the minute CLOSED at a limit but traded " + "through it, so the fill stands and a close-based gate would have blocked " + "it. `returns dropped: no adj_factor` counts holding periods excluded " + "because a usable adj_factor was missing at an anchor — never defaulted to " + "1.0, which would reintroduce the ex-date bias the adjustment removes." + ) + lines.append("") return lines diff --git a/qt/intraday_tail_framework.py b/qt/intraday_tail_framework.py index eee0863..7345e53 100644 --- a/qt/intraday_tail_framework.py +++ b/qt/intraday_tail_framework.py @@ -140,6 +140,16 @@ class I5aResult: up_limit_blocked_buys: int down_limit_blocked_sells: int missing_limit_rows: int + # Where the VWAP gate diverges from a close-based one: the minute CLOSED at a + # limit but traded through it, so the fill stands. Reported because it is the + # only place the two gates disagree, and a silent divergence in what does or + # does not trade is exactly what this layer exists to prevent. + opened_limit_up_minutes: int + opened_limit_down_minutes: int + # Holding-period returns dropped for want of a usable adj_factor at an anchor. + # Never defaulted to 1.0 (that would reintroduce the ex-date bias), so a + # non-zero count is a coverage fact the reader must see. + missing_adj_factor_pairs: int # I5c MMP factor diagnostics (report-only). Empty/None unless score is mmp_ew. score_coverage: dict[str, int] # {rows, valid, nan} over the daily score panel minute_count_summary: dict[str, float] | None # valid-MMP-minutes-per-(date,symbol) distribution @@ -523,6 +533,9 @@ def run_phase_i5a_intraday(config_path: str) -> I5aResult: up_limit_blocked_buys=model.up_limit_blocked_buys(), down_limit_blocked_sells=model.down_limit_blocked_sells(), missing_limit_rows=model.missing_limit_rows(), + opened_limit_up_minutes=model.opened_limit_up_minutes(), + opened_limit_down_minutes=model.opened_limit_down_minutes(), + missing_adj_factor_pairs=model.missing_adj_factor_pairs(), score_coverage=score_coverage, minute_count_summary=minute_count_summary, factor_diagnostics=tuple(factor_diag), @@ -709,6 +722,31 @@ def _append_liquidity_section(lines: list[str], result: I5aResult) -> None: lines.append("") +def limit_basis_lines(execution_price_basis: str) -> list[str]: + """Report prose stating what the I5b price-limit gate ACTUALLY compares. + + Extracted so it is testable. The gate reads the price that EXECUTES — which + since PR #75 is the bar VWAP by default, not the bar close — and an earlier + revision of this text kept claiming "the raw 1min close" after the gate input + had changed. A report that misstates the check it performed is worse than no + report, so the wording is derived from the active basis and pinned by test. + """ + return [ + f"- **comparison basis**: the price that actually EXECUTES — the selected " + f"execution minute on the `{execution_price_basis}` basis — vs the " + f"symbol/date **raw** `stk_limit` band. NOT qfq, NOT the daily close, NOT " + f"a daily-close-derived limit flag. (The intraday cache stores unadjusted " + f"bars and `stk_limit` is raw, and a bar VWAP is raw amount over raw " + f"volume, so the comparison is RAW-vs-RAW either way.)", + "- **why the executed price and not the bar close**: a limit-up minute has " + "two shapes. LOCKED (封死涨停) — every print is at the limit, so the VWAP " + "equals it up to rounding and the buy must be blocked. OPENED (盘中打开) — " + "some prints landed below the limit, which is direct evidence a fill was " + "achievable, so the buy must go through. The executed price separates " + "them; the bar close misclassifies both edges.", + ] + + def _write_report(result: I5aResult, *, elapsed: float) -> None: """Write the I5a architecture-smoke markdown report (auditable event basis).""" cfg = result.config @@ -751,6 +789,18 @@ def _write_report(result: I5aResult, *, elapsed: float) -> None: "at the first valid 1min bar in the execution window on the " f"`{ec.execution_price_basis}` basis.") lines.append("") + lines.append( + "**Returns are corporate-action adjusted.** The cached minute bars are raw, " + "so a holding period spanning an ex-dividend or split date would otherwise " + "book the mechanical price drop as a loss. The return divides it out as " + "`(raw_exit * adj_factor(exit)) / (raw_entry * adj_factor(entry)) - 1`; the " + "per-symbol anchor cancels in the ratio, so nothing is re-derived. Fills " + "still PAY the raw execution price and the price-limit gate stays raw — only " + "the measured return is adjusted. Periods dropped for want of a usable " + f"adj_factor at an anchor: {result.missing_adj_factor_pairs} " + "(never defaulted to 1.0, which would reintroduce the bias)." + ) + lines.append("") _append_factor_section(lines, result) lines.append("## Minute-cache coverage & data provenance") lines.append("") @@ -852,13 +902,7 @@ def _write_report(result: I5aResult, *, elapsed: float) -> None: "- **enabled** (`intraday.price_limit_check=true`): a buy is blocked at " "the raw upper limit and a sell at the raw lower limit, directionally." ) - lines.append( - "- **comparison basis**: the selected execution-minute **raw** 1min " - "close vs the symbol/date **raw** `stk_limit` band — NOT qfq, NOT daily " - "close, NOT a daily-close-derived limit flag. (The intraday cache stores " - "unadjusted bars and `stk_limit` is raw, so the comparison is " - "RAW-vs-RAW.)" - ) + lines.extend(limit_basis_lines(ec.execution_price_basis)) lines.append( f"- limit tolerance (raw-price equality band): " f"`{cfg.intraday.limit_tolerance}`; " @@ -880,6 +924,13 @@ def _write_report(result: I5aResult, *, elapsed: float) -> None: f"- buys blocked by a raw up-limit: {result.up_limit_blocked_buys}; " f"sells blocked by a raw down-limit: {result.down_limit_blocked_sells}" ) + lines.append( + f"- minutes that CLOSED at a limit but traded through it, so the fill " + f"was allowed: {result.opened_limit_up_minutes} up / " + f"{result.opened_limit_down_minutes} down. This is the entire set on " + f"which this gate and a close-based gate disagree; a close-based gate " + f"would have blocked these." + ) lines.append( f"- evaluated pairs with no usable raw limit row (unchecked): " f"{result.missing_limit_rows} " diff --git a/tests/test_i5b_execution_feasibility.py b/tests/test_i5b_execution_feasibility.py index 521929c..84c3794 100644 --- a/tests/test_i5b_execution_feasibility.py +++ b/tests/test_i5b_execution_feasibility.py @@ -15,9 +15,15 @@ 3. Config — existing configs validate unchanged; negative tolerance fails; the I5b config enables the check; price_limit_check=false reproduces I5a feasibility. -The comparison is RAW-vs-RAW only (raw 1min close vs raw stk_limit), never qfq / -daily close — the daily panel here carries deliberately CONTRADICTORY closes to +The comparison is RAW-vs-RAW only — the price that EXECUTES (the bar VWAP under +the default basis, raw amount over raw volume) vs raw stk_limit, never qfq / the +daily close. The daily panel here carries deliberately CONTRADICTORY closes to prove the limit gate ignores them. + +Note for future edits: these fixtures price each execution bar so its VWAP equals +its close, which keeps the pre-PR#75 assertions valid on both bases but makes +them BLIND to which of the two the gate reads. The tests at the bottom of this +module cover that distinction directly. """ from __future__ import annotations @@ -401,3 +407,70 @@ def test_i5b_report_heading_names_the_actual_study(): assert i5a_title.startswith("# Phase I5a") assert i5b_title.startswith("# Phase I5b") assert "execution-feasibility hardening" in i5b_intro.lower() + + +# --- the report must state the gate input it ACTUALLY used (PR #75 follow-up) --- # +# +# PR #75 moved the limit gate from the bar close to the executed price (the bar +# VWAP by default) but left both report writers still claiming "the selected +# execution-minute raw 1min close". The run therefore SHIPPED a report that +# misdescribed the check it had performed. These pin the corrected wording; the +# mutation evidence for them is in the PR body. + + +def test_limit_basis_prose_names_the_active_execution_basis(): + from qt.intraday_tail_framework import limit_basis_lines + + for basis in ("bar_vwap", "bar_close"): + text = " ".join(limit_basis_lines(basis)) + assert f"`{basis}`" in text, f"the prose must name the active basis {basis!r}" + # The defect being locked out: asserting a close comparison regardless of basis. + assert "1min close to the raw" not in text + assert "1min close vs the" not in text + + +def test_limit_basis_prose_does_not_claim_a_close_comparison_under_vwap(): + from qt.intraday_tail_framework import limit_basis_lines + + text = " ".join(limit_basis_lines("bar_vwap")).lower() + assert "executes" in text + # It must explain the LOCKED/OPENED distinction, which is the whole reason the + # executed price rather than the close is the faithful input. + assert "locked" in text and "opened" in text + # And it must still assert the raw-vs-raw property, which the move to a VWAP + # does not break (a bar VWAP is raw amount over raw volume). + assert "raw-vs-raw" in text + + +def test_group_report_feasibility_prose_names_the_active_execution_basis(): + from types import SimpleNamespace + + from qt.intraday_group_report import _feasibility_lines + + cfg = load_config(str(_I5B_CONFIG)) + groups = ( + SimpleNamespace( + group=1, + up_limit_blocked_buys=2, + down_limit_blocked_sells=0, + missing_limit_rows=0, + opened_limit_up_minutes=3, + opened_limit_down_minutes=1, + missing_adj_factor_pairs=4, + ), + ) + result = SimpleNamespace( + config=cfg, + price_limit_check=True, + limit_coverage={"required": 10, "present": 10, "missing": 0}, + stk_limit_gap_fetches=0, + groups=groups, + ) + text = " ".join(_feasibility_lines(result)) + assert f"`{cfg.intraday.execution_price_basis}`" in text + assert "1min close to the raw" not in text + # The divergence counters and the dropped-return count must reach the reader, + # not merely exist on the result object. + assert "opened limit-up" in text + assert "| 3 | 1 | 4 |" in text + assert "adj_factor" in text diff --git a/tests/test_i5d_mmp_quintile.py b/tests/test_i5d_mmp_quintile.py index ea9e516..fc3d0f7 100644 --- a/tests/test_i5d_mmp_quintile.py +++ b/tests/test_i5d_mmp_quintile.py @@ -331,7 +331,10 @@ def _toy_group(group: int, finals: list[float]) -> GroupRunResult: holdings_log=pd.DataFrame(columns=["date", "symbol", "weight", "rank"]), metrics=_group_metrics(nav, pd.DataFrame()), up_limit_blocked_buys=0, down_limit_blocked_sells=0, - missing_limit_rows=0, blocked_fill_reasons={}, + missing_limit_rows=0, + opened_limit_up_minutes=0, opened_limit_down_minutes=0, + missing_adj_factor_pairs=0, + blocked_fill_reasons={}, ) diff --git a/tests/test_i5f_intraday_liquidity.py b/tests/test_i5f_intraday_liquidity.py index 47603e2..776bf3a 100644 --- a/tests/test_i5f_intraday_liquidity.py +++ b/tests/test_i5f_intraday_liquidity.py @@ -182,7 +182,11 @@ def test_i5f_config_loads_and_enables(): cfg = load_config(str(_CONFIG_DIR / "phase_i5f_intraday_liquidity_diagnostics.yaml")) ld = cfg.intraday.liquidity_diagnostics assert ld.enabled is True - assert ld.portfolio_notional == 10_000_000 + # 1,000,000 RMB — the operator's stated total capital ceiling. It replaced the + # original 10,000,000, which was an order of magnitude above any book this + # project will run, so the capacity finding it produced described a portfolio + # that does not exist. The diagnostic is only meaningful at the real size. + assert ld.portfolio_notional == 1_000_000 assert ld.max_participation_rate == 0.05 assert ld.mode == "report_only" From 69e9c776a26b55f086ed3fdbd0b903e2317bfb2f Mon Sep 17 00:00:00 2001 From: shaofl <2899218482@qq.com> Date: Tue, 21 Jul 2026 11:38:07 -0700 Subject: [PATCH 2/2] fix(qt): remove the last close-based limit claim, and guard the defect class Review of the first pass found the SAME stale wording surviving in _write_report()'s "Limitations" section, untouched by that commit and identical to what shipped before PR #75. Every I5b/I5c/I5f report would therefore have carried two contradictory descriptions of one check in a single document: the corrected one mid-report and the pre-#75 one at the end. The dev-facing _load_price_limits docstring had it too. Both now derive from the active execution_price_basis. The more useful half is why the first pass missed it. None of the three tests render _write_report at all -- they exercise limit_basis_lines() in isolation and _feasibility_lines() through a stub -- so that location was structurally unreachable by them, and enumerating the places I happened to know about was never going to converge. The new test scans both report modules for any phrasing that asserts the gate compares a CLOSE, so an (N+1)th copy fails rather than ships. "bar_close" the basis name and "that bar's single closing tick" the basis description are legitimate and deliberately not matched. Mutation evidence: restoring the stale Limitations sentence fails the guard, and it names the offending line: AssertionError: intraday_tail_framework.py still claims the price-limit gate compares a CLOSE. ... Derive the wording from execution_price_basis instead of restating it: line 966: f"execution-minute close to raw limits (see the section " pytest 1732 passed (1731 + 1), ruff clean, phase0 unchanged. --- qt/intraday_tail_framework.py | 19 ++++++----- tests/test_i5b_execution_feasibility.py | 43 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/qt/intraday_tail_framework.py b/qt/intraday_tail_framework.py index 7345e53..3c26c0a 100644 --- a/qt/intraday_tail_framework.py +++ b/qt/intraday_tail_framework.py @@ -259,8 +259,9 @@ def _load_price_limits( Returns ``(limits_df, stk_limit_gap_fetches)``. The limits flow through the SAME P4 read-through cache as the daily endpoints (no new endpoint); a fully-covered window costs zero gap fetches. The cache stores RAW - ``up_limit`` / ``down_limit`` only — the model compares them to the RAW - execution-minute close, never qfq / daily close. + ``up_limit`` / ``down_limit`` only — the model compares them to the RAW price + that EXECUTES at the selected minute (the bar VWAP under the default basis), + never qfq / daily close. """ if cfg.intraday is None or not cfg.intraday.price_limit_check: return None, 0 @@ -959,12 +960,14 @@ def _write_report(result: I5aResult, *, elapsed: float) -> None: lines.append("") if result.price_limit_check: lines.append( - "- **Execution-time feasibility (I5b)**: a missing/NaN execution bar " - "blocks BOTH directions; on top of that, raw `stk_limit` blocks buys at " - "the upper limit and sells at the lower limit, comparing the raw " - "execution-minute close to raw limits (see the section above). Remaining " - "gap: only the price-limit and bar-existence constraints are modeled; " - "partial-fill / liquidity / volume caps at the execution minute are not." + f"- **Execution-time feasibility (I5b)**: a missing/NaN execution bar " + f"blocks BOTH directions; on top of that, raw `stk_limit` blocks buys at " + f"the upper limit and sells at the lower limit, comparing the price that " + f"actually EXECUTES — the selected execution minute on the " + f"`{ec.execution_price_basis}` basis — to raw limits (see the section " + f"above). Remaining gap: only the price-limit and bar-existence " + f"constraints are modeled; partial-fill / liquidity / volume caps at the " + f"execution minute are not." ) else: lines.append( diff --git a/tests/test_i5b_execution_feasibility.py b/tests/test_i5b_execution_feasibility.py index 84c3794..7a9d3b7 100644 --- a/tests/test_i5b_execution_feasibility.py +++ b/tests/test_i5b_execution_feasibility.py @@ -474,3 +474,46 @@ def test_group_report_feasibility_prose_names_the_active_execution_basis(): assert "opened limit-up" in text assert "| 3 | 1 | 4 |" in text 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. The + honest fix for a claim that gets restated in prose in N places is to assert no + (N+1)th copy exists, rather than to enumerate the ones we happen to know. + """ + 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. + 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", + 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) + )