Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions config/phase_i5f_intraday_liquidity_diagnostics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions qt/intraday_group_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]


Expand Down Expand Up @@ -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
Expand Down
35 changes: 29 additions & 6 deletions qt/intraday_group_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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 "
Expand All @@ -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


Expand Down
84 changes: 69 additions & 15 deletions qt/intraday_tail_framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("")
Expand Down Expand Up @@ -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}`; "
Expand All @@ -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} "
Expand Down Expand Up @@ -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(
Expand Down
Loading