M9d MOO→9:55 — Triple Audit

3 persona × deep code review + 5 simulated trading days | 19 Apr 2026
Overview
1. Quant Risk
2. Execution Trader
3. Devil's Advocate
Trading Day Sims
Final Verdict

Executive Summary

M9d = M8 + SKIP SHORT if excess_gap_spy < -3% (bounce filter). Reported Sharpe 8.84, Train→Test degradation 0.6%.

Аудит обнаружил 3 критических, 4 высоких, 5 средних и 3 низких проблемы.

📊
Quant Risk Manager
Overfitting, stat. rigor, Sharpe inflation
3 Critical
Non-monotonic grading, April exclusion, astronomical Sharpe
💹
Execution Trader
Fills, slippage, market impact
2 Critical
Entry price mismatch, no slippage in M9d backtest
😈
Devil's Advocate
Forward-looking, data snooping, survivorship
2 Critical
Entry=close_930 (1min lookahead?), survivorship bias in universe

Finding Severity Matrix

#FindingSeverityPersonaImpact on Sharpe
1Entry = close_930 vs open_930CRITICALDevil / Trader-1.0 to -2.5
2Non-monotonic grading (U-shape)CRITICALQuantOverfit flag
3April exclusion = data snoopingCRITICALQuant / Devil-0.5 to -1.0
4Sharpe 8.84 physically implausibleCRITICALQuantTrue ~2-4
5No slippage/spread in build_m9d.pyHIGHTrader-0.8 to -1.5
637 trades/day market impactHIGHTrader-0.3 to -0.8
7Survivorship bias in ADV filterHIGHDevil-0.2 to -0.5
8gap_pm vs gap_pct direction mismatchHIGHDevilUnknown
9Monday SHORT skip = snoopingMEDIUMQuant-0.1 to -0.3
10Hardcoded beta table (not rolling)MEDIUMQuant / Devil-0.1 to -0.3
11Sharpe * 16 assumes 256 trading daysMEDIUMQuantInflated ~3%
12PM volume may include FINRA printsMEDIUMTraderUnknown
13Compound 3x cap amplifies varianceMEDIUMQuantInflated compound
14OOS period too short (3.5 months)LOWQuantNeed more data
15Consec gap DN filter = overfit riskLOWDevil-0.05
📊
Audit #1: Quant Risk Manager
François, 15 лет в quant trading, ex-Two Sigma risk

CRITICAL #1: Non-Monotonic Grading (U-Shape Overfit)

Функция new_grade() в build_m9d.py определяет грейды так:

def new_grade(s):
    if 5.5 <= s <= 11.5: return 'A+'     # medium scores = BEST
    elif 3.0 <= s < 5.5 or s >= 24: return 'A'  # low OR very high = second best
    elif 11.5 <= s < 24: return 'B'       # high scores = WORSE than A+!
    elif 1.0 <= s < 3.0: return 'B+'      # low = third best
    else: return 'SKIP'

Проблема: Скор 6.0 -> A+ (best), скор 15.0 -> B (worse), скор 25.0 -> A (second). Это U-образная зависимость. В здоровой модели БОЛЬШЕ скор = ЛУЧШЕ грейд. Немонотонная зависимость = классический признак переобучения на шум.

Что это значит: Модель "нашла", что средние скоры лучше высоких. Но это скорее артефакт данных — высокие скоры часто приходятся на extreme дни (earnings, news), где конкуренция за ликвидность и crowded trades убивают edge. Вместо понимания ПРИЧИНЫ, модель просто режет интервал.

Тест: Возьмите только score >= 0 и монотонный грейдинг. Если Sharpe падает значительно — overfit confirmed.

CRITICAL #2: Исключение апреля = Data Snooping

keep &= u.month != 4 — полное исключение апреля.

Проблема: Если апрель был худшим месяцем в training set, его исключение = подгонка под прошлое. Это классический "worst period exclusion". Можно так же исключить любой плохой квартал и получить лучший Sharpe.

Реальный вопрос: Есть ли structural reason почему апрель плох? (Tax-loss selling deadline? Earnings season start?). Если да — документируйте ПРИЧИНУ. Если нет — это data snooping.

Impact: ~21 торговых дней в году × 37 trades/day = ~777 trades. Исключая худший месяц, вы убираете ~3% самых плохих данных и Sharpe прыгает вверх.

CRITICAL #3: Sharpe 8.84 физически невозможен

Sharpe Ratio 8.84 означает, что стратегия зарабатывает 8.84 стандартных отклонений в год. Для сравнения:

StrategySharpe (ann.)Кто
Renaissance Medallion Fund~3.0-4.0Best hedge fund in history
HFT market making~4-8Citadel/Jump (nano-second execution)
Typical stat arb~1.5-2.5Институционалы
M9d заявленный8.84Одиночный retail

Причины инфляции:

  1. Множитель *16 = sqrt(256), но реально ~252 дня → инфляция ~0.8%
  2. Множество мелких позиций ($500-$1500) = низкая std → высокий Sharpe
  3. 37 trades/day = диверсификация сглаживает дневной P&L → std↓ → Sharpe↑
  4. Compound 3x cap создает иллюзию стабильности в equity curve
  5. Без slippage/spread каждый trade ≈ +$5-10 чистый profit

Реалистичная оценка: После корректировки на slippage (+5bps entry + 5bps exit = 10bps round-trip), market impact (еще 5bps на crowded names), и honest entry price → Sharpe ≈ 2-4. Это все еще отличная стратегия, но не "8.84".

MEDIUM #4: Monday SHORT Skip

keep &= ~((u.dow == 'Monday') & (u.direction == 'SHORT'))

Calendar-based rule без structural justification. Понедельники SHORT плохи потому что weekend risk premium? Тогда Friday close уже должен это учитывать. Или это артефакт конкретного sample period?

MEDIUM #5: Compound 3x Cap Amplifies Variance

mult = min(eq / 100000, 3.0)   # up to 3x leverage on compounding
eq += p * mult

Compound $486K при стартовых $100K = +386%. Но cap 3.0 означает, что после $300K вы торгуете с 3x leverage. Это маскирует реальный drawdown risk. Линейный PnL ($172K) — более честная метрика.

LOW #6: OOS Test Period = 3.5 месяца

Test (2025-01 to 2026-04) = ~15 месяцев, не 3.5. Это нормально. Но Train→Test "degradation 0.6%" подозрительно мало — обычно 10-30% деградация для honest OOS. Либо модель genuinely robust, либо OOS не полностью independent (например, ticker bias "past-only" но computed на всем TRAIN который частично overlaps).

💹
Audit #2: Execution Trader
Mike, 12 лет DMA/algo execution, ex-prop desk

CRITICAL #1: Entry Price = close of 9:30 bar, NOT MOO

В 01_data_builder.py:327:

bars_930.columns = ["open_930", "high_930", "low_930", "entry_930", "vol_930"]
#                                                       ^^^^^^^^^^
# entry_930 = CLOSE of 9:30 bar (т.е. цена в 9:30:59)
# open_930  = OPEN of 9:30 bar (т.е. цена в 9:30:00 = реальный MOO)

Есть fix_master_preopen.py который пытается исправить: m["entry_930"] = m["entry_open"]. Но:

  1. В build_m9d.py используется entry_930 напрямую из parquet
  2. Неясно, был ли fix_master_preopen.py запущен ДО создания universe_scored_v4.parquet
  3. Если fix НЕ был запущен → каждый trade имеет 1-минутный lookahead

Magnitude: В первую минуту после MOO средний move = 5-15bps. На 30K trades это $2K-$7K бесплатного P&L в бэктесте. Может объяснять 10-20% общего P&L.

Как проверить:

import pandas as pd
u = pd.read_parquet('moo955_v2/universe_scored_v4.parquet')
# Если entry_930 == close of 930 bar:
diff = (u['entry_930'] - u['open_930']).abs() / u['open_930'] * 100
print(f"Median |entry-open|: {diff.median():.4f}%")
print(f"Mean |entry-open|:   {diff.mean():.4f}%")
# Если median > 0.02% → fix NOT applied, entry = close

CRITICAL #2: build_m9d.py не учитывает slippage и spread

Cost model в build_m9d.py:

BROKER_PS = 0.004    # broker per share
ECN_PS = 0.003       # ECN per share
FINRA_PS = 0.000166  # FINRA TAF
SEC_RATE = 0.0000278 # SEC fee

t['cost'] = t.shares * (BROKER_PS+ECN_PS+FINRA_PS)*2 + t['size']*SEC_RATE

Сравнение с config.py (используется в реалистичных бэктестах):

Costbuild_m9d.pyconfig.py (realistic)
Slippage05 bps
Spread010 bps
Locate (short)0$0.01-0.03/sh
Broker+ECN+FINRA$0.0072/sh$0.0072/sh

Missing cost per trade:

$1000 position × 15bps slippage+spread = $1.50 per trade. На 30K trades = $45,000 недоучтенных costs. Это 26% от заявленного PnL ($172K).

Для SHORT trades добавьте locate fee: ~$0.02/share × ~100 shares = $2/trade × 10K short trades = $20,000.

Итого недоучтено: ~$65K = 38% от PnL.

HIGH #3: 37 trades/day = Market Impact

30,182 trades / 800 days ≈ 37.7 trades/day. При $1000 avg position size = $37,700 daily notional. Это мало для рынка, НО:

  • Все 37 trades executed в 9:30:00 (MOO). Это crowded time slot.
  • Если 10+ trades в одном секторе → correlated market impact
  • Мелкие stocks (mcap $300M-$2B) с ADV 2M shares: ваш $1500 = 50-150 shares. При average bid-ask spread ~10bps на мелких names, реальный fill может быть 15-25bps worse
  • EXIT в 9:55: market-on-close заказ конкурирует с другими algo strategies

MEDIUM #4: PM Volume может содержать FINRA prints

Фильтр pm_vol >= 10000 может пропускать тикеры с "ложной" PM ликвидностью из FINRA ADF. По вашему же исследованию, FINRA prints = exclude. В Datum 1-min bars нет ex_finr варианта. Это может добавлять 5-15% "phantom" PM volume тикеров.

MEDIUM #5: Exit точно в 9:55 нереалистичен

Бэктест использует exit_955 = close of 9:55 bar. В реальности:

  • Нужно отправить 37 market orders в 9:55:00
  • Fills приходят в 9:55:01-9:55:05
  • Spread на exit ≈ 5-10bps additional slippage
  • Если TL Pro — ручной exit ещё медленнее
😈
Audit #3: Devil's Advocate
Dr. Nina, академик по market microstructure, ревьювер journal papers

CRITICAL #1: Forward-Looking Entry Price

Самая опасная находка.

В коде есть ДВЕ версии entry price:

# Version 1 (01_data_builder.py:54):
entry = bar_930.iloc[0]["close"]  # = price at 9:30:59

# Version 2 (01_data_builder.py:327, vectorized path):
bars_930.columns = [..., "entry_930", ...]  # maps to "close" column

Обе версии используют CLOSE of 9:30 bar. fix_master_preopen.py существует для исправления, но:

  1. Это ОТДЕЛЬНЫЙ скрипт, не часть pipeline
  2. Его output: moo955_master_v2.parquet — но pipeline config указывает на moo955_master.parquet
  3. Комментарий в scoring_v4_oos.py: "Fixed entry = open_930 — already done in master_v2" — значит fix СДЕЛАН, но нужно ВЕРИФИЦИРОВАТЬ в данных

Даже если fix applied для ret_955 расчета, в build_m9d.py есть второй lookahead:

u['gap_pm'] = (u.pm_close - u.prev_close) / u.prev_close * 100

Но direction в universe_scored_v4 был назначен по gap_pct из data builder:

# 01_data_builder.py:432-434
gap_pct = (entry_930 - prev_close) / prev_close * 100

# 01_data_builder.py:440
direction = "LONG" if gap_pct > 0 else "SHORT"

Если entry_930 = close_930, то direction определяется по цене 9:30:59, а не по pre-open gap! Stock может gap UP в PM, но close of 9:30 bar ниже prev_close → direction = SHORT. Это forward-looking direction assignment.

HIGH #2: Survivorship Bias в Universe

Universe фильтр: avg_vol_20d >= 2,000,000 + price >= $5 + mcap >= $300M

Проблема: Эти фильтры применяются к ТЕКУЩИМ данным. Но в 2023 году:

  • RIVN торговался по $12 с ADV 30M → включен. Сейчас $10 с ADV 15M → всё ещё включен. Но другие stocks что delisted или упали ниже $5 → ИСКЛЮЧЕНЫ из backtest.
  • SMCI в 2023: ADV 2M, в 2024: ADV 20M. Включен за весь период, но в 2023 реальная ликвидность была хуже.
  • Stocks что IPO'd в 2024 → included in 2024 backtest, но НЕ в 2023 → survivorship mismatch

Направление bias: Survivorship bias ВСЕГДА завышает returns. Delisted stocks — это обычно ЛОСЕРЫ. Их исключение убирает худшие trades.

Estimated impact: ~5-10% universe affected. WR inflation ~0.5-1pp. Sharpe inflation ~0.2-0.5.

HIGH #3: gap_pm vs gap_pct Discrepancy

В build_m9d.py: gap_pm = (pm_close - prev_close) / prev_close

В data builder: gap_pct = (entry_930 - prev_close) / prev_close

Эти могут иметь РАЗНЫЙ ЗНАК! Пример:

Scenarioprev_closepm_close (9:25)entry_930 (9:30)gap_pmgap_pctdirection
MOO sell-off$100$101.50$99.80+1.50%-0.20%SHORT
MOO spike$100$98.50$100.30-1.50%+0.30%LONG

Когда gap_pm и direction disagreement → фильтры в build_m9d.py (которые используют gap_pm) работают на ДРУГИХ данных, чем scoring (который использует direction). Это тихий data leak.

MEDIUM #4: Hardcoded Sector Beta Table

Beta table в M9d:

sector_beta_spy = {
    'Technology': 1.25, 'Communications': 1.05, ...
}
high_beta_names = ['NVDA','TSLA','AMD',...]  # multiply by 1.4
low_beta_names = ['KO','PEP','WMT',...]      # multiply by 0.6

Проблемы:

  • Beta таблица hardcoded = forward-looking. Были ли эти значения верны в 2023?
  • NVDA beta в 2023 ≈ 1.3-1.5. В 2025 ≈ 1.8-2.0. Используется фиксированный 1.25*1.4 = 1.75
  • High/low beta name list — кто-то ВРУЧНУЮ выбрал 21+11 тикеров. Selection bias.
  • APP (AppLovin) — в 2023 был small-cap $5B, сейчас mega $100B+. Beta изменилась драматически.

MEDIUM #5: Multiple Testing / Rule Accumulation

M9d содержит 8 фильтрующих правил:

  1. pm_vol >= 10K
  2. SPY_gap > -2%
  3. VXX_gap < 5%
  4. No SHORT B+
  5. No micro-cap SHORT
  6. No April
  7. No Monday SHORT
  8. No consec gap DN SHORT
  9. No QQQ
  10. NEW: No SHORT if excess < -3%

10 правил, каждое "улучшает" Sharpe. Но каждое добавление = одна степень свободы overfit. С 10 правилами, шансы что ВСЕ genuinely predictive ≈ 0.5^10 = 0.1%.

Proper test: Permutation test — рандомизируйте даты/тикеры и применяйте те же 10 правил. Если Sharpe improvement ~similar → rules = noise.

LOW #6: Consec Gap DN filter uses future-adjacent data

u['prev_gap_pm'] = u.groupby('ticker')['gap_pm'].shift(1)
u['consec_gap_dn'] = (u.gap_pm < -1) & (u.prev_gap_pm < -1)

Технически OK (используется shift(1) = вчерашний gap). Но gap_pm рассчитан из pm_close текущего дня. Если gap_pm использует entry_930 → опять 1min lookahead.

Simulated Trading Days

5 реальных сценариев, показывающих где M9d ломается.

Day 1: Normal Bull Day — SPY +0.4%, QQQ +0.6%, VIX 15
EXPECTED: PROFIT
04:00-07:00
PM trading begins. NVDA gaps +1.2% on no news, SPY futures +0.3%
08:00-09:00
European markets confirm bullish. PM volume builds. 15 candidates A+ grade.
09:25
PM close captured. gap_pm calculated. Scoring runs.
09:28
Decision: 12 LONG A+, 3 LONG A, 5 SHORT A+. Total 20 trades.
09:30:00
MOO fills. But several stocks open 5-10bps ABOVE pm_close. Entry worse than backtest assumes.
09:55:00
Exit. Send 20 market orders simultaneously. Takes 2-5 seconds for all fills.
Backtest P&L
+$187
Slippage Cost
-$30
Realistic P&L
+$157
Verdict
OK
Normal days = стратегия работает. Slippage небольшой. Но 20 trades x $30 каждый день складываются в ~$6K/year.
Day 2: Tariff Shock — SPY -2.5%, VXX +12%, gap down across board
FILTER TRIGGERS
04:00-06:00
Overnight: Tariff announcement. Futures crash. SPY gap -2.8%, VXX +15%.
06:00-09:00
PM: everything gaps down. Many stocks gap -3% to -8%. PM vol surges.
09:25
Filters check:
• SPY_gap = -2.8% → SKIP ALL (filter: SPY > -2%)
• VXX_gap = +15% → SKIP ALL (filter: VXX < 5%)
09:28
Decision: 0 trades. Both SPY and VXX filters triggered.
09:30-09:55
Market rebounds +1.5% from open (oversold bounce). MISSED OPPORTUNITY.
Trades
0
P&L
$0
Opportunity Cost
~$500+
Verdict
MIXED
Правильно пропустить crash day. Но фильтр бинарный: SPY -2.01% = skip, SPY -1.99% = trade. На edge case можно попасть в ловушку.
Day 3: Morning Whipsaw — PM up, MOO reversal
BUG EXPOSED
04:00-09:25
TSLA: prev_close = $180.00. PM trades up to $183.50 (+1.9%).
pm_close (9:25) = $183.20 → gap_pm = +1.78%
09:28
Screener scores TSLA based on gap_pm +1.78% → Direction = LONG.
Score = 7.2, Grade = A+, Size = $1,500.
09:30:00
TSLA MOO = $179.50 (-0.28% vs prev_close!). Sell orders hit. Gap UP reversed to gap DOWN at open.
In backtest: если entry_930 = close of 9:30 bar = $179.80 → gap_pct = -0.11% → direction = SHORT
But scoring graded it as LONG based on PM data!
Backtest says: SHORT trade, SHORT return. Reality: you went LONG.
09:55
TSLA at $178.50. Your LONG position: -$8.33 (-0.56%). Backtest would show this as SHORT profit +$10.83.
Backtest P&L
+$10.83
Real P&L
-$8.33
Delta
$19.16
Verdict
DIRECTION BUG
Это ключевая проблема. Backtest direction (based on entry_930=close_930) ≠ live direction (based on PM gap). ~5-10% дней имеют такие reversals, особенно для volatile names. Каждый такой trade = double penalty (you lose, backtest records a win).
Day 4: April 15, 2026 — Normal market, but month = April
MONTH EXCLUSION
04:00-09:25
Normal bull day. SPY +0.3%, 25 good candidates. Market calm, VIX 14.
09:28
Decision: 0 trades. Month = April → skip all.
No structural reason. Just because April 2023 or 2024 was bad in sample.
09:30-09:55
Market drifts up. The 25 candidates average +0.3% return. Missed $75.
Skipped Days
~21/year
Missed P&L (if April = average)
~$3,500/year
Verdict
SNOOPING
Если April 2026 окажется лучшим месяцем → это правило стоило $5K+. Data snooping cost.
Day 5: Sector Divergence — SPY -1.5%, NVDA -6% (excess = -3.5%)
M9d NEW RULE
04:00-09:25
SPY gaps -1.5%. NVDA gaps -6% (chip sanctions rumor).
excess_gap_spy = -6% - (1.25*1.4 * -1.5%) = -6% + 2.625% = -3.375%
09:28
NVDA SHORT candidate: score 8.5, grade A+. BUT excess < -3% → SKIP (M9d rule).
Meanwhile AMD gaps -5.5%: excess = -5.5% + 2.625% = -2.875% > -3% → TRADE
09:30-09:55
NVDA bounces +1.2% from open (capitulation reversal, as predicted).
AMD continues down -0.5% from open.
NVDA skipped
+$18 saved
AMD SHORT
+$7.50
M9d rule
CORRECT
But...
Edge case
M9d rule работает здесь. Но threshold -3% = magic number. Почему не -2% или -4%? Был ли sensitivity analysis? Hardcoded beta (1.25*1.4=1.75) может быть неточным для конкретного дня. Rolling beta бы дала другой excess_gap.
Day 6: Earnings Cascade — 5 mega-cap report BMO, mixed results
CROWDED
06:00-07:00
MSFT +3%, GOOG -4%, META +2%, AMZN +1%, AAPL -1%. All A+ by score.
09:28
15 mega-cap trades all concentrated in Tech. MSFT LONG, META LONG, AMZN LONG. GOOG SHORT, AAPL SHORT.
Sector concentration = 100% Tech. One sector reversal = ALL trades go wrong.
09:30
All 15 trades fill at MOO. But earnings names have wide spreads (20-50bps vs normal 5-10bps).
Slippage much worse than backtest models.
09:40
GOOG bounces on analyst upgrade mid-session. Your SHORT losing. MSFT fades on profit-taking. Your LONG losing.
09:55
Mixed results. 3 wins, 2 losses. But wider spreads eat most of the profit.
Backtest P&L
+$95
Extra Slippage
-$75
Real P&L
+$20
Risk
HIGH CONC.
Earnings days = wider spreads + higher volatility. Backtest doesn't model earnings-day spread widening. Sector concentration risk not modeled at all.

Final Verdict

Estimated Real Sharpe After All Corrections
AdjustmentSharpe ImpactCumulative
Reported M9d8.84
Add slippage + spread (15bps RT)-1.5 to -2.56.3 - 7.3
Fix entry price (if close→open)-0.5 to -1.55.0 - 6.8
Remove April exclusion-0.3 to -0.84.5 - 6.5
Add locate fees (SHORT)-0.2 to -0.54.0 - 6.3
Survivorship bias correction-0.2 to -0.53.5 - 6.1
Market impact on MOO fills-0.1 to -0.33.2 - 6.0
Direction mismatch (gap_pm vs gap_pct)-0.2 to -0.52.7 - 5.8
Estimated Real Sharpe3.0 - 5.0

Mandatory Action Items

MUST DO (Before Live)

  1. VERIFY entry price: Check if universe_scored_v4.parquet has entry_930 = open or close of 9:30 bar. Run the diagnostic code from Audit #2.
  2. Fix direction assignment: Direction should be based on pm_close gap (known before open), NOT entry_930 gap.
  3. Add slippage + spread to build_m9d.py cost model (15bps RT minimum).
  4. Monotonic grading: Replace U-shape grading with simple threshold: A+ >= X, A >= Y, etc.
  5. Remove or justify April exclusion: If no structural reason → remove.

SHOULD DO (Before Scaling)

  1. Rolling beta: Replace hardcoded sector_beta_spy with rolling 60d or 90d beta.
  2. Permutation test: Randomize dates, apply same 10 rules. Compare Sharpe.
  3. Sector concentration limit: Max 40% of daily trades in one sector.
  4. Earnings-day spread model: 2x spread on BMO/AMC day 0 names.
  5. Point-in-time universe: Use historical ADV/mcap for each date, not current.

Is M9d Worth Trading?

YES, but with corrections.

Even at Sharpe 3.0-5.0 (after all haircuts), this is a strong strategy. The core edge — gap continuation/reversal in the first 25 minutes — is real and backed by market microstructure theory (opening auction price discovery + mean reversion).

The M9d rule (skip SHORT if excess_gap < -3%) is intuitively sound — capitulation bounces are real. But the threshold, beta table, and surrounding calendar rules need denoising.

Bottom line: Fix the entry price bug, add realistic costs, remove calendar snooping → you likely have a Sharpe 3-4 strategy generating $50K-$100K/year on $100K capital. That's excellent.

Confidence Assessment

ComponentConfidenceReason
Core edge (gap + 25min mean-reversion)HIGH 85%Microstructure theory + massive N
Scoring v4 OOS methodologyHIGH 80%Honest train/test, FDR, past-only bias
M9d excess_gap ruleMEDIUM 60%Sound logic, but hardcoded beta + threshold
Calendar rules (April, Monday)LOW 30%No structural justification documented
Reported Sharpe 8.84LOW 10%Physically implausible without HFT infra
Real profitability after correctionsHIGH 75%Even with 50% haircut, still profitable