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..3c26c0a 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 @@ -249,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 @@ -523,6 +534,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 +723,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 +790,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 +903,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 +925,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} " @@ -908,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 521929c..7a9d3b7 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,113 @@ 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 + + +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) + ) 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"