Skip to content

feat: I5d MMP minute-factor grouped backtest — standalone feasibility (incl. I5c plumbing) - #39

Merged
StackOverFlow11 merged 8 commits into
mainfrom
feat/i5d-mmp-feasibility-standalone
Jun 19, 2026
Merged

feat: I5d MMP minute-factor grouped backtest — standalone feasibility (incl. I5c plumbing)#39
StackOverFlow11 merged 8 commits into
mainfrom
feat/i5d-mmp-feasibility-standalone

Conversation

@StackOverFlow11

Copy link
Copy Markdown
Owner

I5d — MMP minute-factor → daily-aggregate → grouped backtest (standalone feasibility)

This is a STANDALONE, EXPLORATORY feasibility branch — NOT a performance claim and NOT a broad architecture refactor. It tests one closed loop: a minute-level signal (MMP) → a PIT-safe daily aggregated factor → a grouped intraday-tail backtest. It is reviewable against main on its own.

Why this branch includes I5c code

I5d (the quintile grouped backtest) depends on the I5c MMP daily-score plumbing (the MMP minute formula + EW daily aggregation, the configurable intraday.score_feature, and the I5c config/tests). Because I5c (PR #37) is still open against main, this standalone branch bundles the minimum I5c plumbing so the feasibility loop builds and runs without being stacked on PR #37. The I5c dependency is disclosed, not hidden.

Supersedes the stacked PR #38 (feat/i5d-mmp-quintile-5y, base feat/i5c-mmp-minute-factor). This PR is base main so a reviewer does not need #37 merged first. PR #37 and #38 are left untouched; #38 can be closed once this is reviewed (left to the user).

Contents (8 commits, main..HEAD)

  • I5c MMP plumbing (5 commits): MMP minute factor + EW daily aggregation, configurable score_feature + report title, MMP score selection + report/diagnostics, I5c formula/PIT/config tests, and the [session_open, decision_time] window fix.
  • I5d grouped backtest (3 commits): optional precomputed exec prices on IntradayTailEventModel, the I5d grouped runner + grouping/figures/report/CLI/config, and the I5d tests.

What I5d does

Ranks the daily MMP score intraday_mmp20_ew_0930_1450 on each monthly rebalance date into analytics.quantiles=5 equal-count rank buckets (Q1 = lowest, Q5 = highest) and runs each group as its own long-only equal-weight portfolio through the SAME BacktestEngine + IntradayTailEventModel + SimExecution(fee_rate=0.001) with I5b raw stk_limit execution feasibility ON (14:50 decision / 14:51 execution, exec-to-exec returns, never close-to-close). Factor math, the event model, and the execution feasibility are unchanged; only the grouping + per-group orchestration is new.

Accepted exploratory result (one window — not a performance claim)

CSI500 (000905.SH), 2021-06-01 → 2026-05-31, 59 monthly rebalances. covered=892/995 — 103 minute-uncovered constituents are dropped and the coverage bias is disclosed; the 5-year window is NOT shortened. stk_mins_live_calls=0 (cache-only). Final NAV increases monotonically Q1→Q5 (0.9822 / 1.0423 / 1.0495 / 1.1275 / 1.1577; monotonicity Spearman 1.0000). The Q5−Q1 spread is a synthetic long-only leg difference, NOT a dollar-neutral executable book. One overlapping window / one universe / no robustness / no tuning — far too little to infer factor quality.

Note on scope

Efficient minute-factor research will likely need a more thorough architecture upgrade later; that refactor is intentionally out of scope here.

Gates

pytest 577 passed; ruff clean; all configs validate; run-phase0 ic_mean=0.9600 / annual_return=0.8408 unchanged. Report/logs/figures secret-scanned: 0 token leaks.

Add the exploratory Minute Microstructure Pressure (MMP) factor to the I3 minute
-> daily aggregation. Per 1min bar t (single symbol/day session, sorted by
bar_end): mid=(high+low)/2; S=(close-mid)/mid; V=sqrt(volume/median(vol[t-20:t]));
B=|close-open|/(high-low+eps); R=(high-low)/(mean(hl[t-20:t])+eps);
MMP_t=S*V*B*R, eps=1e-6.

- compute_minute_mmp: rolling baselines use ONLY the prior 20 bars t-20..t-1
  (rolling(20).shift(1)) — never bar t, never a later bar, never the prior day's
  tail; the first 20 bars of each session are NaN. Invalid denominators (mid<=0,
  median vol<=0, NaN baseline) yield NaN, never inf.
- new feature key 'mmp_ew' -> column intraday_mmp20_ew_0930_1450 = the EQUAL
  -WEIGHT mean of valid MMP_t visible at the cutoff (the volume term is already
  inside MMP_t; the daily aggregation is NOT additionally volume-weighted). No
  valid minute -> NaN.
- mmp_ew is SELECTABLE-ONLY: DEFAULT_FEATURE_KEYS keeps the original four, so a
  no-args asof_daily_features call has the same columns/cost as before.
- mmp_valid_minute_counts: report-only per-(date,symbol) valid-minute count under
  the same PIT cutoff, reusing compute_minute_mmp (one MMP source of truth).

PIT unchanged: available_time <= trade_date + decision_time filters per-bar
timestamps BEFORE daily grouping. Data-layer only: no returns / Tushare / cache /
portfolio / execution.
Add IntradayCfg.score_feature (Literal mirroring INTRADAY_FEATURE_KEYS; default
'ret' reproduces the I5a/I5b smoke score, 'mmp_ew' selects the MMP factor) and
OutputCfg.intraday_report_title so a reused intraday runner can name the actual
study instead of a stale phase label (same precedent as subset_report_title).
All existing configs validate unchanged and keep the 'ret' default; an unknown
score_feature fails readably at validation.
Make the intraday tail runner consume intraday.score_feature: _score_panel
requests ONLY the selected feature from asof_daily_features and uses its returned
column EXACTLY (no hardcoded prefix matching), so default 'ret' reproduces I5b and
'mmp_ew' selects intraday_mmp20_ew_0930_1450. _report_heading is study-aware (I5c
for mmp_ew / I5b when price-limit on / else I5a) with an intraday_report_title
override. Add report-only factor diagnostics: MMP formula + PIT disclosure, daily
score coverage, valid-MMP-minute distribution, and per-settled-rebalance
cross-sectional score stats + Spearman IC of the decision-date score vs the same
period's exec-to-exec return (read in the report layer only, never fed back to
factors/alpha). I5b raw stk_limit feasibility stays ON and disclosed.

Add config/phase_i5c_mmp_minute_factor.yaml: same SSE50 SH/SZ window as I5b with
score_feature=mmp_ew, price_limit_check=true, its own report name + title.
16 I5c tests: exact MMP_t formula on a controlled 22-bar day; rolling baselines
exclude bar t (t-20..t-1); first 20 bars NaN; daily score is equal-weight (asserts
!= volume-weighted); NaN-not-inf denominators; daily NaN when no valid minute;
PIT (post-cutoff bars + delayed availability don't move the score); future bars
don't change earlier MMP_t; multi-day no prior-tail carryover; multi-symbol
isolation; old configs default to 'ret'; I5c config selects mmp_ew + titles the
study; invalid score_feature fails; score_feature Literal mirrors feature keys;
_score_panel selects MMP without prefix matching; heading names I5c. Update the
I5b heading test for the new _report_heading signature.
…ime] window

The MMP daily score promised aggregation over bars with bar_end in
[session_open, decision_time] but only applied the upper bound (available_time <=
cutoff); pre-session bars (before session_open) leaked into the rolling baseline,
so the first in-session bar wrongly got a non-NaN MMP and the report's
[session_open, decision_time] claim did not hold.

Add the lower bound bar_end >= trade_date + session_open on the MMP path only
(_mmp_ew_daily + mmp_valid_minute_counts) so pre-session bars enter neither the
rolling baseline nor the daily mean; the first 20 IN-SESSION bars are NaN. The
existing four features (ret/realized_vol/vwap/last30m_ret) are untouched — only
mmp_ew gains the lower bound — so I5a/I5b behavior cannot drift.

Regression: 20 pre-session bars (09:00..09:19) + one 09:31 bar with session_open
09:30 -> daily MMP NaN / 0 valid minutes (was a spurious value); prepending
pre-session bars leaves a full in-session day's MMP unchanged. Real SSE50 stk_mins
has no pre-09:30 continuous bars, so the smoke is unaffected.
…Model

The execution-price matrix + fills are a pure function of
(bars, anchor_dates, symbols, cfg). When several fresh models share the same
bars/cfg (one per quantile group in I5d), pass the precomputed (prices, fills)
so the heavy matrix is built once and reused while each model keeps its own
fresh mutable feasibility diagnostics. Default None reproduces the byte-identical
I5a/I5b in-place build (regression tests unchanged).
Rank the I5c MMP daily score (intraday_mmp20_ew_0930_1450) on each monthly
rebalance date into analytics.quantiles equal-count rank buckets (Q1 = lowest,
QN = highest) and run each group as its own long-only equal-weight portfolio
through the SAME BacktestEngine + IntradayTailEventModel + SimExecution
(fee_rate) with I5b raw stk_limit execution feasibility ON.

- intraday_groups.py: assign_quantile_buckets (equal-count rank buckets,
  deterministic ties, too-few-names leaves high groups empty), GroupScores,
  EqualWeightAll (whole-bucket equal weight, ignores top_n).
- intraday_group_backtest.py: run-phase-i5d-intraday-groups runner. Cache-only,
  anchor-date-sliced minute load (only rebalance/exit days -> memory-conscious,
  zero stk_mins live calls), one shared immutable exec-price matrix across N
  fresh per-group models, per-group metrics, QN-Q1 synthetic spread + group
  monotonicity (report-only).
- intraday_group_figures.py / intraday_group_report.py: Agg PNG figures (NAV
  curves / Q5-Q1 spread / grouped metric bars) + markdown report.
- cli.py: run-phase-i5d-intraday-groups subcommand.
- config/phase_i5d_mmp_quintile_5y.yaml: CSI500, 2021-06-01..2026-05-31, mmp_ew,
  price_limit_check on, quantiles=5, fee_rate=0.001. require_cache_coverage=false
  drops minute-uncovered constituents (disclosed; window not shortened).

Factor math, daily close-to-close, the event model and execution feasibility are
unchanged; only the grouping + per-group orchestration is new. EXPLORATORY, not a
performance claim, no tuning.
19 tests over the goal's five groups: equal-count bucket assignment (Q1 low / QN
high, NaN/inf excluded, deterministic ties, too-few-names), EqualWeightAll
ignores top_n, GroupScores selects one group, fee_rate reaches SimExecution; PIT
invariants (grouping uses only the <=14:50 MMP score, post-cutoff and exit bars
cannot change the assignment); grouped execution feasibility (raw stk_limit
blocks, missing bar blocks without daily-close fallback, per-group counts do not
double-count); report H1 names I5d (not stale) and mentions fee_rate=0.001 /
analytics.quantiles=5; figures written and non-empty.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant