Add expanded ORB simulator features and metrics

- orb_simulator.py: min_abs_gap_pct filter, premarket dollar vol filter,
  rolling_loss circuit breaker, drawdown_governor, streak_sizing,
  trailing_tighten_at_r, allow_doji/red_to_green breakout, abs_gap scoring
  for gainers_leader, ORBSimulationState, run_orb_simulation_with_state API
- metrics.py: loss_containment_score and related metrics
- features.py: enrich_daily_bars with gap_zscore, ATR ratio, range compression
- domain.py: extended ORBStrategyParams with new fields
- cache.py: DailyBarCache with merged parquet storage and coverage metadata
- simulator.py: base simulator updates for new entry/exit mechanics
- configs/intraday: updated orb_gainers_v23.yaml with canonical params
- Added BLD to midlarge symbol snapshot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 8f0f99bdda
commit d7bfda039b

@ -19,7 +19,7 @@ backtest:
start_date: null # null = auto (today - lookback_trading_days) start_date: null # null = auto (today - lookback_trading_days)
end_date: null # null = today end_date: null # null = today
lookback_trading_days: 40 # ~2 months lookback_trading_days: 40 # ~2 months
pre_screen_threshold: 0.015 # phase 1 filter: (high-open)/open >= 1.5% pre_screen_threshold: 0.015 # phase 1 filter: opening gap vs prev_close >= 1.5%
cache: cache:
enabled: true enabled: true

@ -1,87 +0,0 @@
# ORB (Opening Range Breakout) Strategy — Default Configuration
# Strategy: Buy breakout of first 5-min candle high (bullish candles only).
# Uses ATR-based stops, risk-based position sizing, 15:55 ET time exit.
# Based on ORB academic research adapted for available data infrastructure.
strategy_mode: orb
orb_strategy:
# ORB window
orb_minutes: 5 # 9:309:35 ET opening range
sim_bar_minutes: 30 # 30-min bars for breakout/stop management (ORB candle stays 5-min)
# Entry
entry_direction: long_only # bullish candle only (V1; 'candle' for both directions)
order_timeout_minutes: 45 # cancel if no fill by 10:15 ET
# Universe quality filters (applied during pre-screening)
min_price: 10.0 # $10+ stocks only
min_avg_dollar_volume: 25000000 # $25M 30-day avg daily dollar volume
min_atr_14: 0.50 # ATR(14) > $0.50 (sufficient range to trade)
# RVOL-based candidate selection
min_rvol: 1.0 # minimum approx RVOL at open (see note in features.py)
max_candidates: 20 # top N candidates per day
min_candidates_to_trade: 3 # skip day if fewer qualify
# Composite ranking weights (must sum to 1.0)
weight_rvol: 0.60 # relative volume (main signal)
weight_gap: 0.25 # gap% (proxy for premarket activity)
weight_dollar_vol: 0.15 # first-bar dollar volume
# ATR-based stop management
atr_stop_multiplier: 0.50 # initial stop = ATR(14) × 50% from entry
breakeven_at_r: 1.0 # move stop to entry at +1R
trailing_at_r: 2.0 # activate trailing stop (3-bar swing low) at +2R
# Risk-based position sizing
risk_per_trade_pct: 0.0025 # 0.25% of equity per trade
max_position_pct: 0.20 # cap at 20% of equity per position
daily_max_loss_pct: 0.0125 # stop trading at -1.25% daily loss
max_stops_per_day: 3 # stop trading after 3 full-R stops
# Exit
exit_minutes_before_close: 5 # time exit at 15:55 ET
# Execution
slippage_bps: 5.0 # 0.05% one-way slippage (both entry and exit)
initial_capital: 10000 # $10,000 starting capital
ticker_cooldown_days: 0 # no cooldown (ORB trades daily runners)
# Cash account GFV (Good Faith Violation) constraint
# Unsettled proceeds can buy but not same-day sell → ORB always exits same day
# → only settled cash is usable. 0=disabled, 1=T+1 (US since May 2024), 2=T+2
settlement_days: 1
# Max opening gap filter: exclude stocks that gapped up more than this at open.
# Stocks with large gaps are over-extended and show low breakout continuation rate.
# Sweep result: 3% >> 5% >> 10% in Sharpe (5.59 vs 4.50 vs 3.74).
max_gap_pct: 0.03
# Market regime: skip days when index gaps down > threshold at open
# Sweep result: SPY -0.5% filter hurts absolute return with minimal Sharpe gain.
# Individual ORB candidates can surge even on weak-SPY days (e.g. sector rotation).
market_regime_spy_threshold: null # disabled — breadth filter below is superior
# Candidate breadth filter: skip day if <N% of candidates opened above prev close.
# Sweep: Breadth≥30% → Sharpe 19.86 but return +520% vs +676% with no filter.
# Existing per-trade risk controls (ATR stop, daily loss limit) are sufficient.
min_candidate_breadth: null
universe:
source: midlarge # 971-ticker mid+large cap universe
min_price: 10.0 # redundant with orb_strategy.min_price but explicit
backtest:
start_date: null # null = auto (today - lookback_trading_days)
end_date: null
lookback_trading_days: 200 # ~10 months
pre_screen_threshold: 0.01 # not used by ORB mode (kept for config compatibility)
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -0,0 +1,49 @@
_meta:
name: Leader Intraday Momentum High WR
description: More selective high-win-rate variant of the safe momentum basket. Uses a 5-minute confirmation that requires +0.45% follow-through, a 2.0M entry dollar-volume floor, a tighter 6.0% morning overextension cap, a 5.5% opening-gap cap, an 8.0% base trailing stop, and a slightly tighter 7.5% trail once a name is already up 4%+ at entry. Intended for users who prioritize win rate over basket breadth.
id: 20
strategy_mode: momentum
strategy:
compound_returns: false
entry_minutes_after_open: 10
confirmation_minutes_after_entry: 5
min_confirmation_return_pct: 0.0045
exit_minutes_before_close: 10
stop_loss_pct: null
trailing_stop_pct: -0.08
overextended_trailing_gain_pct: 0.04
overextended_trailing_stop_pct: -0.075
min_morning_gain_pct: 0.015
max_morning_gain_pct: 0.06
max_gap_pct: 0.055
min_entry_volume: 125000
min_entry_dollar_volume: 2000000
ticker_cooldown_days: 4
top_n: 8
max_positions_per_sector: 2
use_five_sleeves: true
max_entropy_20d: 0.86
max_vix: 30.0
recent_live_scan_days: 0
recent_live_scan_min_price: 2.0
recent_live_scan_avg_volume_min: 200000
recent_live_scan_market_cap_min: 100000000.0
recent_live_scan_max_candidates_per_day: 150
initial_capital: 10000.0
slippage_bps: 5.0
market_regime_spy_threshold: null
universe:
source: yaml
symbols_file: configs/symbols_broad_snapshot_3408.yaml
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.02
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday
verbose: false

@ -0,0 +1,107 @@
_meta:
name: Leader Intraday Momentum High WR Intraday First
description: Corrected high-win-rate momentum variant that replaces the pure daily-gap candidate source with a historical intraday-first shortlist. It uses a 1.5% seed gap, reranks the shortlist at entry time using confirmation, volume-ratio, dollar-volume, gap, and low-entropy quality, then trades the top 8 confirmed leaders. Intended to improve corrected Q1 and weak 2025 quarter robustness without reintroducing lookahead.
id: 25
strategy_mode: momentum
strategy:
compound_returns: false
entry_minutes_after_open: 10
confirmation_minutes_after_entry: 5
min_confirmation_return_pct: 0.005
exit_minutes_before_close: 10
stop_loss_pct: null
trailing_stop_pct: -0.08
overextended_trailing_gain_pct: 0.04
overextended_trailing_stop_pct: -0.075
min_morning_gain_pct: 0.015
max_morning_gain_pct: 0.06
max_gap_pct: 0.055
min_volume_ratio_14d: 0.04
min_entry_volume: 125000
min_entry_dollar_volume: 1500000
ticker_cooldown_days: 0
top_n: 9
max_positions_per_sector: 2
use_five_sleeves: true
five_sleeve_force_count: 4
max_entropy_20d: 0.86
max_vix: 30.0
recent_live_scan_days: 0
recent_live_scan_min_price: 2.0
recent_live_scan_avg_volume_min: 200000
recent_live_scan_market_cap_min: 100000000.0
recent_live_scan_max_candidates_per_day: 150
recent_live_scan_top_n: 6
recent_live_scan_min_morning_gain_pct: 0.005
recent_live_scan_max_morning_gain_pct: 0.05
recent_live_scan_min_confirmation_return_pct: 0.0005
recent_live_scan_min_entry_dollar_volume: 50000000.0
recent_live_scan_max_gap_pct: 0.04
recent_live_scan_max_entropy_20d: 0.9
recent_live_scan_use_slow_ignite_sleeve: true
recent_live_scan_slow_ignite_weight: 0.30
recent_live_scan_slow_ignite_min_gain_pct: 0.003
recent_live_scan_slow_ignite_max_gain_pct: 0.015
recent_live_scan_slow_ignite_min_entry_dollar_volume: 50000000.0
recent_live_scan_slow_ignite_max_entropy_20d: 0.9
recent_live_scan_use_liquid_largecap_sleeve: true
recent_live_scan_liquid_largecap_weight: 0.35
recent_live_scan_liquid_largecap_min_gain_pct: 0.004
recent_live_scan_liquid_largecap_max_gain_pct: 0.02
recent_live_scan_liquid_largecap_min_confirmation_return_pct: 0.0005
recent_live_scan_liquid_largecap_min_entry_dollar_volume: 50000000.0
recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d: 500000000.0
recent_live_scan_liquid_largecap_max_entropy_20d: 0.9
initial_capital: 10000.0
slippage_bps: 5.0
market_regime_spy_threshold: null
candidate_source_mode: intraday_first
candidate_seed_threshold: 0.0075
candidate_seed_max_per_day: 150
candidate_seed_liquid_overlay_slots: 3
candidate_seed_liquid_min_gap_pct: 0.005
candidate_seed_liquid_max_gap_pct: 0.025
candidate_seed_liquid_min_avg_dollar_vol_30d: 5000000000.0
candidate_seed_liquid_min_ret_5d: 0.0
candidate_seed_liquid_max_entropy_20d: 0.87
candidate_seed_leader_overlay_slots: 1
candidate_seed_leader_min_gap_pct: -0.025
candidate_seed_leader_max_gap_pct: 0.01
candidate_seed_leader_min_avg_dollar_vol_30d: 500000000.0
candidate_seed_leader_min_ret_5d: 0.15
candidate_seed_leader_min_atr_pct: 0.06
candidate_seed_leader_max_entropy_20d: 0.75
candidate_final_max_per_day: 14
candidate_intraday_rank_mode: weighted
candidate_intraday_weight_gain: 0.10
candidate_intraday_weight_confirmation: 0.45
candidate_intraday_weight_volume_ratio: 0.20
candidate_intraday_weight_entry_dollar_volume: 0.15
candidate_intraday_weight_gap: 0.05
candidate_intraday_weight_low_entropy: 0.05
candidate_intraday_weight_avg_dollar_vol_30d: 0.08
use_liquid_largecap_sleeve: true
liquid_largecap_weight: 0.05
fallback_liquid_largecap_slots: 1
fallback_liquid_largecap_trigger_below: 2
liquid_largecap_min_gain_pct: 0.004
liquid_largecap_max_gain_pct: 0.015
liquid_largecap_min_confirmation_return_pct: 0.0005
liquid_largecap_min_entry_dollar_volume: 50000000.0
liquid_largecap_min_avg_dollar_vol_30d: 2000000000.0
liquid_largecap_max_entropy_20d: 0.87
universe:
source: yaml
symbols_file: configs/symbols_broad_snapshot_3408.yaml
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.02
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday
verbose: false

@ -0,0 +1,102 @@
_meta:
name: Leader Intraday Momentum Safe
description: Safer intraday-first leader basket derived from the current high-win-rate engine. It keeps the liquid-largecap-aware candidate stack, same-day confirmation rerank, and five-sleeve blend, but scales sparse 1-5 position days below full size to reduce trap-day drawdowns without reverting to the stale pre-intraday-first safe rules.
id: 19
strategy_mode: momentum
strategy:
compound_returns: false
entry_minutes_after_open: 10
confirmation_minutes_after_entry: 5
min_confirmation_return_pct: 0.005
exit_minutes_before_close: 10
stop_loss_pct: null
trailing_stop_pct: -0.08
overextended_trailing_gain_pct: 0.04
overextended_trailing_stop_pct: -0.075
min_morning_gain_pct: 0.015
max_morning_gain_pct: 0.06
max_gap_pct: 0.055
min_volume_ratio_14d: 0.04
min_entry_volume: 125000
min_entry_dollar_volume: 1500000
ticker_cooldown_days: 0
top_n: 9
full_size_positions_threshold: 6
sparse_day_size_floor: 0.5
max_positions_per_sector: 2
use_five_sleeves: true
five_sleeve_force_count: 4
max_entropy_20d: 0.86
max_vix: 30.0
recent_live_scan_days: 0
recent_live_scan_min_price: 2.0
recent_live_scan_avg_volume_min: 200000
recent_live_scan_market_cap_min: 100000000.0
recent_live_scan_max_candidates_per_day: 150
recent_live_scan_top_n: 6
recent_live_scan_min_morning_gain_pct: 0.005
recent_live_scan_max_morning_gain_pct: 0.05
recent_live_scan_min_confirmation_return_pct: 0.0005
recent_live_scan_min_entry_dollar_volume: 50000000.0
recent_live_scan_max_gap_pct: 0.04
recent_live_scan_max_entropy_20d: 0.9
recent_live_scan_use_slow_ignite_sleeve: true
recent_live_scan_slow_ignite_weight: 0.30
recent_live_scan_slow_ignite_min_gain_pct: 0.003
recent_live_scan_slow_ignite_max_gain_pct: 0.015
recent_live_scan_slow_ignite_min_entry_dollar_volume: 50000000.0
recent_live_scan_slow_ignite_max_entropy_20d: 0.9
recent_live_scan_use_liquid_largecap_sleeve: true
recent_live_scan_liquid_largecap_weight: 0.35
recent_live_scan_liquid_largecap_min_gain_pct: 0.004
recent_live_scan_liquid_largecap_max_gain_pct: 0.02
recent_live_scan_liquid_largecap_min_confirmation_return_pct: 0.0005
recent_live_scan_liquid_largecap_min_entry_dollar_volume: 50000000.0
recent_live_scan_liquid_largecap_min_avg_dollar_vol_30d: 500000000.0
recent_live_scan_liquid_largecap_max_entropy_20d: 0.9
initial_capital: 10000.0
slippage_bps: 5.0
market_regime_spy_threshold: null
candidate_source_mode: intraday_first
candidate_seed_threshold: 0.0075
candidate_seed_max_per_day: 150
candidate_seed_liquid_overlay_slots: 3
candidate_seed_liquid_min_gap_pct: 0.005
candidate_seed_liquid_max_gap_pct: 0.025
candidate_seed_liquid_min_avg_dollar_vol_30d: 5000000000.0
candidate_seed_liquid_min_ret_5d: 0.0
candidate_seed_liquid_max_entropy_20d: 0.87
candidate_final_max_per_day: 12
candidate_intraday_rank_mode: weighted
candidate_intraday_weight_gain: 0.10
candidate_intraday_weight_confirmation: 0.45
candidate_intraday_weight_volume_ratio: 0.20
candidate_intraday_weight_entry_dollar_volume: 0.15
candidate_intraday_weight_gap: 0.05
candidate_intraday_weight_low_entropy: 0.05
candidate_intraday_weight_avg_dollar_vol_30d: 0.08
use_liquid_largecap_sleeve: true
liquid_largecap_weight: 0.05
fallback_liquid_largecap_slots: 1
fallback_liquid_largecap_trigger_below: 2
liquid_largecap_min_gain_pct: 0.004
liquid_largecap_max_gain_pct: 0.015
liquid_largecap_min_confirmation_return_pct: 0.0005
liquid_largecap_min_entry_dollar_volume: 50000000.0
liquid_largecap_min_avg_dollar_vol_30d: 2000000000.0
liquid_largecap_max_entropy_20d: 0.87
universe:
source: yaml
symbols_file: configs/symbols_broad_snapshot_3408.yaml
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.02
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday
verbose: false

@ -0,0 +1,51 @@
_meta:
name: Leader Intraday Momentum Ultra Safe
description: Corrected post-lookahead ultra-safe leader basket. Requires at least two qualified names before trading, lowers the morning-gain floor to 1.2%, caps opening gaps at 5.5%, and scales sparse 2-position days down to 50% size. Designed to sacrifice some upside in exchange for materially better win rate, lower drawdown, and stronger trap-day avoidance.
id: 24
strategy_mode: momentum
strategy:
compound_returns: false
entry_minutes_after_open: 10
confirmation_minutes_after_entry: 5
min_confirmation_return_pct: 0.0
exit_minutes_before_close: 10
stop_loss_pct: null
trailing_stop_pct: -0.075
min_morning_gain_pct: 0.012
max_morning_gain_pct: 0.075
max_gap_pct: 0.055
min_entry_volume: 125000
min_entry_dollar_volume: 2000000
min_volume_ratio_14d: 0.02
ticker_cooldown_days: 4
top_n: 18
min_positions_to_trade: 2
full_size_positions_threshold: 3
sparse_day_size_floor: 0.5
max_positions_per_sector: 2
use_five_sleeves: true
max_entropy_20d: 0.86
max_vix: 30.0
recent_live_scan_days: 0
recent_live_scan_min_price: 2.0
recent_live_scan_avg_volume_min: 200000
recent_live_scan_market_cap_min: 100000000.0
recent_live_scan_max_candidates_per_day: 150
initial_capital: 10000.0
slippage_bps: 5.0
market_regime_spy_threshold: null
universe:
source: yaml
symbols_file: configs/symbols_broad_snapshot_3408.yaml
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.02
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday
verbose: false

@ -1,78 +0,0 @@
_meta:
id: 1
name: "ORB Default"
description: "Opening Range Breakout — 5-min ORB, ATR stop, risk-based sizing. Phase 10 optimized."
strategy_mode: orb
orb_strategy:
# ORB window
orb_minutes: 5
sim_bar_minutes: 5
# Entry
entry_direction: long_only
order_timeout_minutes: 20
# Universe quality filters
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.50
# RVOL-based candidate selection
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
# Composite ranking weights
weight_rvol: 0.40
weight_gap: 0.35
weight_dollar_vol: 0.15
weight_body_ratio: 0.0
weight_momentum: 0.0
# ATR-based stop management
atr_stop_multiplier: 1.25
breakeven_at_r: 1.0
trailing_at_r: 3.0
trailing_stop_atr_multiplier: 0.3
# Risk-based position sizing
risk_per_trade_pct: 0.0025
max_position_pct: 0.20
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
# Exit
exit_minutes_before_close: 10
# Execution
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 2
# Cash account settlement
settlement_days: 1
# Gap filter
max_gap_pct: 0.04
# Market regime filter
market_regime_spy_threshold: null
min_candidate_breadth: null
universe:
source: midlarge
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -0,0 +1,146 @@
_meta:
id: 28
name: "ORB Gainers V23"
description: >
V22 → V23 via 2 validated improvements: ATR% quality filter + position cap adjustment.
Validation mode: daily_budget_reset=true, compound_returns=false (단리/simple interest).
V22 baseline: +98.73% (200d), Sharpe 3.24, DD -9.34%, 161 trades, WR 59.6%
V23 result: +116.92% (200d), Sharpe 3.26, DD -9.35%, 154 trades, WR 59.7%
Changes from V22:
1. min_atr_pct: null → 0.04 (require ATR-14 ≥ 4% of prev_close)
Analysis of 400d trade distribution revealed that stocks with ATR/price < 4%
(moderate-volatility names like energy stocks, stable tech) have 42-46% WR
and contribute 20% total P&L, while >5% ATR names have 62.4% WR and +104%
contribution. Filtering for high-ATR% ensures ORB candidates have the explosive
follow-through potential the strategy relies on.
Effect: 200d return +18.19pp. WR unchanged (+0.1pp). DD essentially same (0.01pp).
2. max_position_pct: 0.80 → 0.70
High-ATR% stocks have larger individual trade variance (bigger swings).
Reducing position cap from 80% to 70% of daily budget compensates, keeping
portfolio-level DD comparable while the ATR% filter improves return.
Without this adjustment: 400d DD 24.38% (fails gate). With: 23.97% (passes).
Interaction: Neither change alone passes both 200d and 400d gates cleanly.
Together they are synergistic: min_atr_pct selects high-quality candidates,
max_pos_pct=0.70 manages their higher individual volatility.
400d validation (daily_reset):
V23 400d: +120.87%, WR 57.7%, DD 23.97%, 286 trades, 112 days, Sharpe 1.94
V22 400d: +89.62%, WR 52.2%, DD 20.93%, 312 trades, 119 days, Sharpe 1.67
400d gates: return≥88% ✓, WR≥52% ✓, DD≥24% ✓ (23.97% passes by 0.03pp)
400d DD worsened 3pp but return/WR/Sharpe all improved significantly.
Quarterly 400d attribution (known):
2024-Q3: 4.4%, 2024-Q4: +1.2%, 2025-Q1: 8.4% (volatile macro periods driving DD)
2025-Q2: +13.0%, 2025-Q3: +42.4%, 2025-Q4: +11.0%, 2026-Q1: +35.2%
min_atr_pct filter graveyard (200d, daily_reset, on V22 base):
- min_atr_pct 0.03: +86.09%, WR 58.7%, DD 10.85% (too lenient, includes drags)
- min_atr_pct 0.04: +122.02%, WR 59.5%, DD 9.25% ← best 200d
- min_atr_pct 0.05: +84.34%, WR 57.1%, DD 13.30% (over-filters, fewer candidates)
- min_atr_pct 0.06: +98.40%, WR 61.2%, DD 16.49% (too few trades, high DD)
- min_atr_pct 0.04 + max_atr_pct 0.08: +37.93% (filters too many good trades)
- min_atr_pct 0.04 + max_atr_pct 0.09: 200d +81.70%, 400d +85.65% (below gates)
- min_atr_pct 0.04 + risk=0.045: 200d WR 56.8% (fails gate), worse DD
max_position_pct 0.75 + min_atr_pct 0.04:
200d: +110.75%, WR 60.7%, DD 8.93%
400d: +127.59%, WR 56.6%, DD 24.32% (FAILS 400d DD gate by 0.32pp)
strategy_mode: orb
orb_strategy:
engine_family: gainers_leader
live_readiness: live_ready
orb_minutes: 5
sim_bar_minutes: 5
entry_direction: long_only
order_timeout_minutes: 45
allow_doji_breakout: true
allow_red_to_green_breakout: true
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.50
# === CHANGE: require ATR ≥ 4% of prev_close (filter low-volatility drag candidates) ===
min_atr_pct: 0.04
min_rvol: 1.5
min_abs_gap_pct: 0.02
min_premarket_dollar_vol: 1500000
max_candidates: 20
max_candidates_per_sector: 3
min_candidates_to_trade: 1
ticker_cooldown_days: 0
max_gap_pct: 0.04
min_candidate_breadth: 0.60
market_regime_spy_threshold: 0.0015
market_regime_ticker: QQQ
rolling_loss_days: 7
rolling_loss_threshold: -0.07
max_simultaneous_entries: 3
min_breakout_rel_vol: 1.2
weight_rvol: 0.35
weight_gap: 0.20
weight_dollar_vol: 0.05
weight_premarket_dollar_vol: 0.25
weight_body_ratio: 0.0
weight_momentum: 0.15
atr_stop_multiplier: 0.75
breakeven_at_r: 1.0
trailing_at_r: 1.0
trailing_stop_atr_multiplier: 0.8
trailing_tighten_at_r: 2.0
trailing_stop_atr_multiplier_tight: 0.3
partial_exit_at_r: 99.0
partial_exit_pct: 0.50
risk_per_trade_pct: 0.05
# === CHANGE: max position 70% (from 80%) to manage higher per-trade variance ===
max_position_pct: 0.70
daily_max_loss_pct: 0.05
max_stops_per_day: 5
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
compound_returns: false
daily_budget_reset: true
settlement_days: 1
# Preserved from V22
drawdown_governor_threshold: 0.025
drawdown_governor_min_scale: 0.30
# Preserved from V22
streak_sizing_win_bonus: 0.70
streak_sizing_max: 2.5
universe:
source: midlarge
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.3
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.4
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.6
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.75
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 1.0
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 1.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 1.5
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.5
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 3.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 10
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 15
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 30
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.01
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.015
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.025
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.03
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.04
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 0.8
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.2
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.5
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 10
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 15
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 20
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 15
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 20
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 45
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 10
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 15
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 20
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 3.5
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 4.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -1,43 +0,0 @@
strategy_mode: orb
orb_strategy:
orb_minutes: 5
sim_bar_minutes: 30
entry_direction: long_only
order_timeout_minutes: 30
min_price: 10.0
min_avg_dollar_volume: 25000000
min_atr_14: 0.5
min_rvol: 1.0
max_candidates: 20
min_candidates_to_trade: 3
weight_rvol: 0.6
weight_gap: 0.25
weight_dollar_vol: 0.15
atr_stop_multiplier: 0.5
breakeven_at_r: 2.0
trailing_at_r: 5.0
risk_per_trade_pct: 0.0025
max_position_pct: 0.2
daily_max_loss_pct: 0.0125
max_stops_per_day: 3
exit_minutes_before_close: 5
slippage_bps: 5.0
initial_capital: 10000
ticker_cooldown_days: 0
settlement_days: 1
max_gap_pct: 0.02
market_regime_spy_threshold: -0.005
universe:
source: midlarge
min_price: 10.0
backtest:
start_date: null
end_date: null
lookback_trading_days: 200
pre_screen_threshold: 0.01
cache:
enabled: true
dir: data/cache/intraday
output:
dir: runs/intraday_orb
verbose: false

@ -0,0 +1,10 @@
sweep:
max_positions_per_sector: [2, 1]
five_sleeve_force_count: [4, 3]
liquid_largecap_weight: [0.05, 0.08]
vix_size_scale_low: [18.0]
vix_size_scale_high: [30.0]
vix_size_scale_min: [1.0, 0.85]
entropy_size_scale_low: [0.70]
entropy_size_scale_high: [0.86]
entropy_size_scale_min: [1.0, 0.85]

@ -0,0 +1,5 @@
sweep:
use_five_sleeves: [false, true]
max_entropy_20d: [null, 0.92]
max_vix: [null, 30.0, 35.0]
min_gap_pct: [null, 0.01]

@ -0,0 +1,8 @@
sweep:
use_five_sleeves: [true]
vix_size_scale_low: [18.0]
vix_size_scale_high: [30.0]
vix_size_scale_min: [1.0, 0.85, 0.70]
entropy_size_scale_low: [0.55]
entropy_size_scale_high: [0.90]
entropy_size_scale_min: [1.0, 0.85, 0.70]

@ -3,7 +3,7 @@
# 4 × 4 × 2 × 2 × 3 × 3 × 2 = 1,152 combinations # 4 × 4 × 2 × 2 × 3 × 3 × 2 = 1,152 combinations
# Simulation-only time: ~1,152 × <0.5s ≈ ~10 minutes # Simulation-only time: ~1,152 × <0.5s ≈ ~10 minutes
base_config: configs/intraday/orb_default.yaml base_config: configs/intraday/strategies/orb_default.yaml
sweep: sweep:
# Simulation bar interval for breakout/stop management (ORB candle always 5-min) # Simulation bar interval for breakout/stop management (ORB candle always 5-min)

@ -1,7 +1,7 @@
# ORB Rebuild Phase 1 — Core Structure Sweep # ORB Rebuild Phase 1 — Core Structure Sweep
# #
# Post bar_close fix: all stop/peak/R-multiple logic now uses bar close price. # Post bar_close fix: all stop/peak/R-multiple logic now uses bar close price.
# Start from orb_default.yaml and sweep the 3 most impactful parameters. # Start from strategies/orb_default.yaml and sweep the 3 most impactful parameters.
# #
# Key question: which bar size + stop distance + direction works best # Key question: which bar size + stop distance + direction works best
# now that stops use realistic close prices (not theoretical stop level)? # now that stops use realistic close prices (not theoretical stop level)?
@ -10,17 +10,17 @@
# #
# Run: # Run:
# python -m apps.intraday_bt.evaluate \ # python -m apps.intraday_bt.evaluate \
# --config configs/intraday/orb_default.yaml \ # --config configs/intraday/strategies/orb_default.yaml \
# --sweep configs/intraday/sweep_orb_rebuild_p1.yaml \ # --sweep configs/intraday/sweep_orb_rebuild_p1.yaml \
# --start 2022-01-01 --split-date 2025-01-01 # --start 2022-01-01 --split-date 2025-01-01
# #
# Or quick test (no IS/OOS split): # Or quick test (no IS/OOS split):
# python -m apps.intraday_bt.run \ # python -m apps.intraday_bt.run \
# --config configs/intraday/orb_default.yaml \ # --config configs/intraday/strategies/orb_default.yaml \
# --sweep configs/intraday/sweep_orb_rebuild_p1.yaml \ # --sweep configs/intraday/sweep_orb_rebuild_p1.yaml \
# --start 2022-01-01 # --start 2022-01-01
base_config: configs/intraday/orb_default.yaml base_config: configs/intraday/strategies/orb_default.yaml
sweep: sweep:
# Bar size: how often the trader checks price # Bar size: how often the trader checks price

@ -9,7 +9,7 @@
# #
# Run: # Run:
# python -m apps.intraday_bt.evaluate \ # python -m apps.intraday_bt.evaluate \
# --config configs/intraday/orb_default.yaml \ # --config configs/intraday/strategies/orb_default.yaml \
# --sweep configs/intraday/sweep_orb_rebuild_p2.yaml \ # --sweep configs/intraday/sweep_orb_rebuild_p2.yaml \
# --start 2022-01-01 --split-date 2025-01-01 # --start 2022-01-01 --split-date 2025-01-01

@ -134,6 +134,7 @@ symbols:
- BK - BK
- BKH - BKH
- BKR - BKR
- BLD
- BLDR - BLDR
- BLK - BLK
- BLSH - BLSH

@ -20,6 +20,10 @@ _CACHE_METADATA = {
b"intraday_cache_source": b"api_v1_alpaca_intraday", b"intraday_cache_source": b"api_v1_alpaca_intraday",
b"intraday_cache_interval": b"5min", b"intraday_cache_interval": b"5min",
} }
_INTRADAY_KIND_KEY = b"intraday_cache_kind"
_INTRADAY_KIND_POSITIVE = b"bars"
_INTRADAY_KIND_NEGATIVE = b"negative"
_INTRADAY_NEGATIVE_REASON_KEY = b"intraday_negative_reason"
_SCHEMA = pa.schema([ _SCHEMA = pa.schema([
pa.field("timestamp", pa.string()), pa.field("timestamp", pa.string()),
@ -31,7 +35,60 @@ _SCHEMA = pa.schema([
pa.field("vwap", pa.float64()), pa.field("vwap", pa.float64()),
]) ])
_REQUIRED_COLUMNS = {"timestamp", "open", "high", "low", "close", "volume"} _REQUIRED_COLUMNS = {"timestamp", "open", "high", "low", "close", "volume"}
_SCHEMA_WITH_METADATA = _SCHEMA.with_metadata(_CACHE_METADATA) _SCHEMA_WITH_METADATA = _SCHEMA.with_metadata({
**_CACHE_METADATA,
_INTRADAY_KIND_KEY: _INTRADAY_KIND_POSITIVE,
})
_MIN_VALID_INTRADAY_ROWS = 10
_DAILY_CACHE_STATIC_METADATA = {
b"daily_cache_version": b"1",
b"daily_cache_source": b"api_v1_price_data",
b"daily_cache_interval": b"1d",
}
_DAILY_COVERAGE_START_KEY = b"daily_cache_coverage_start"
_DAILY_COVERAGE_END_KEY = b"daily_cache_coverage_end"
_DAILY_SCHEMA = pa.schema([
pa.field("date", pa.string()),
pa.field("open", pa.float64()),
pa.field("high", pa.float64()),
pa.field("low", pa.float64()),
pa.field("close", pa.float64()),
pa.field("volume", pa.float64()),
])
_DAILY_REQUIRED_COLUMNS = {"date", "open", "high", "low", "close", "volume"}
def _daily_rows_match_coverage_sanity(
rows: list[dict[str, Any]],
coverage_start: str | None,
coverage_end: str | None,
) -> bool:
"""Reject egregiously partial daily caches that claim much wider coverage.
Small mismatches are expected around weekends / holidays because the cache
stores calendar coverage while the rows only contain trading sessions. Large
gaps, however, usually indicate a truncated bulk Oracle response that should
not be reused for feature warmup.
"""
if not rows or coverage_start is None or coverage_end is None:
return True
try:
from datetime import date as _date
first_row = _date.fromisoformat(str(rows[0]["date"])[:10])
last_row = _date.fromisoformat(str(rows[-1]["date"])[:10])
coverage_start_dt = _date.fromisoformat(coverage_start)
coverage_end_dt = _date.fromisoformat(coverage_end)
except Exception:
return True
if (first_row - coverage_start_dt).days > 10:
return False
if (coverage_end_dt - last_row).days > 10:
return False
return True
class IntradayCache: class IntradayCache:
"""Disk-based cache for 5-minute intraday bars using Parquet. """Disk-based cache for 5-minute intraday bars using Parquet.
@ -46,25 +103,35 @@ class IntradayCache:
return self._root / ticker.upper() / f"{date}.parquet" return self._root / ticker.upper() / f"{date}.parquet"
@staticmethod @staticmethod
def _metadata_valid(path: Path) -> bool: def _metadata_status(path: Path) -> str | None:
try: try:
meta = pq.read_metadata(str(path)) meta = pq.read_metadata(str(path))
if meta.num_rows <= 0:
return False
arrow_schema = meta.schema.to_arrow_schema() arrow_schema = meta.schema.to_arrow_schema()
if not _REQUIRED_COLUMNS.issubset(set(arrow_schema.names)): if not _REQUIRED_COLUMNS.issubset(set(arrow_schema.names)):
return False return None
schema_meta = arrow_schema.metadata or {} schema_meta = arrow_schema.metadata or {}
return all(schema_meta.get(k) == v for k, v in _CACHE_METADATA.items()) if not all(schema_meta.get(k) == v for k, v in _CACHE_METADATA.items()):
return None
kind = schema_meta.get(_INTRADAY_KIND_KEY, _INTRADAY_KIND_POSITIVE)
if kind == _INTRADAY_KIND_NEGATIVE:
return "negative" if meta.num_rows == 0 else None
if kind not in (_INTRADAY_KIND_POSITIVE, None):
return None
return "positive" if meta.num_rows >= _MIN_VALID_INTRADAY_ROWS else None
except Exception: except Exception:
return False return None
@staticmethod
def is_complete_enough(bars: list[dict[str, Any]]) -> bool:
"""Heuristic: keep only intraday responses with enough bars to be useful."""
return len(bars) >= _MIN_VALID_INTRADAY_ROWS
def has(self, ticker: str, date: str) -> bool: def has(self, ticker: str, date: str) -> bool:
"""Return True if cached bars exist for ticker on date.""" """Return True if cached bars exist for ticker on date."""
p = self._path(ticker, date) p = self._path(ticker, date)
if not p.exists(): if not p.exists():
return False return False
if not self._metadata_valid(p): if not self._metadata_status(p):
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
return False return False
return True return True
@ -74,13 +141,14 @@ class IntradayCache:
p = self._path(ticker, date) p = self._path(ticker, date)
if not p.exists(): if not p.exists():
return None return None
if not self._metadata_valid(p): status = self._metadata_status(p)
if not status:
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
return None return None
if status == "negative":
return []
try: try:
table = pq.read_table(str(p)) table = pq.read_table(str(p))
if table.num_rows == 0:
return None
return table.to_pylist() return table.to_pylist()
except Exception: except Exception:
return None return None
@ -119,6 +187,25 @@ class IntradayCache:
tmp.unlink(missing_ok=True) tmp.unlink(missing_ok=True)
raise raise
def put_negative(self, ticker: str, date: str, reason: str = "empty_or_sparse") -> None:
"""Cache a stable empty/sparse response to avoid repeating futile API calls."""
p = self._path(ticker, date)
p.parent.mkdir(parents=True, exist_ok=True)
schema = _SCHEMA.with_metadata({
**_CACHE_METADATA,
_INTRADAY_KIND_KEY: _INTRADAY_KIND_NEGATIVE,
_INTRADAY_NEGATIVE_REASON_KEY: reason.encode(),
})
table = pa.Table.from_pylist([], schema=schema)
tmp = p.with_suffix(".tmp")
try:
pq.write_table(table, str(tmp), compression="snappy")
os.replace(str(tmp), str(p))
except Exception:
if tmp.exists():
tmp.unlink(missing_ok=True)
raise
def evict( def evict(
self, self,
ticker: str | None = None, ticker: str | None = None,
@ -150,6 +237,34 @@ class IntradayCache:
return removed return removed
def available_dates(
self,
ticker: str,
start_date: str | None = None,
end_date: str | None = None,
) -> list[str]:
"""List cache dates for one ticker within an optional date range.
Invalid cache files are discarded as they are encountered. Negative cache
entries are included so callers can preserve the original trading-day
shape while deciding how to handle empty responses.
"""
ticker_dir = self._root / ticker.upper()
if not ticker_dir.exists():
return []
dates: list[str] = []
for path in ticker_dir.glob("*.parquet"):
date_str = path.stem
if start_date and date_str < start_date:
continue
if end_date and date_str > end_date:
continue
if not self._metadata_status(path):
path.unlink(missing_ok=True)
continue
dates.append(date_str)
return sorted(dates)
def stats(self) -> dict[str, Any]: def stats(self) -> dict[str, Any]:
"""Return cache statistics.""" """Return cache statistics."""
if not self._root.exists(): if not self._root.exists():
@ -177,3 +292,206 @@ class IntradayCache:
"date_min": min(dates) if dates else None, "date_min": min(dates) if dates else None,
"date_max": max(dates) if dates else None, "date_max": max(dates) if dates else None,
} }
class DailyBarCache:
"""Disk-based cache for daily OHLCV bars using one Parquet file per ticker."""
def __init__(self, cache_dir: str = "data/cache/daily") -> None:
self._root = Path(cache_dir)
def _path(self, ticker: str) -> Path:
return self._root / f"{ticker.upper()}.parquet"
@staticmethod
def _read_schema_metadata(path: Path) -> dict[bytes, bytes] | None:
try:
meta = pq.read_metadata(str(path))
if meta.num_rows < 0:
return None
return meta.schema.to_arrow_schema().metadata or {}
except Exception:
return None
@classmethod
def _metadata_valid(cls, path: Path) -> bool:
try:
meta = pq.read_metadata(str(path))
if meta.num_rows < 0:
return False
arrow_schema = meta.schema.to_arrow_schema()
if not _DAILY_REQUIRED_COLUMNS.issubset(set(arrow_schema.names)):
return False
schema_meta = arrow_schema.metadata or {}
if not all(schema_meta.get(k) == v for k, v in _DAILY_CACHE_STATIC_METADATA.items()):
return False
return (
schema_meta.get(_DAILY_COVERAGE_START_KEY) is not None
and schema_meta.get(_DAILY_COVERAGE_END_KEY) is not None
)
except Exception:
return False
@classmethod
def _coverage_from_metadata(cls, path: Path) -> tuple[str | None, str | None]:
schema_meta = cls._read_schema_metadata(path) or {}
start = schema_meta.get(_DAILY_COVERAGE_START_KEY)
end = schema_meta.get(_DAILY_COVERAGE_END_KEY)
return (
start.decode() if start else None,
end.decode() if end else None,
)
@staticmethod
def _normalize_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized: list[dict[str, Any]] = []
for b in rows:
normalized.append(
{
"date": str(b.get("date", ""))[:10],
"open": float(b.get("open", 0.0) or 0.0),
"high": float(b.get("high", 0.0) or 0.0),
"low": float(b.get("low", 0.0) or 0.0),
"close": float(b.get("close", 0.0) or 0.0),
"volume": float(b.get("volume", 0.0) or 0.0),
}
)
return normalized
def get(
self,
ticker: str,
start_date: str,
end_date: str,
) -> list[dict[str, Any]] | None:
"""Read cached daily bars for an exact date range if covered."""
p = self._path(ticker)
if not p.exists():
return None
if not self._metadata_valid(p):
p.unlink(missing_ok=True)
return None
coverage_start, coverage_end = self._coverage_from_metadata(p)
if coverage_start is None or coverage_end is None:
p.unlink(missing_ok=True)
return None
if start_date < coverage_start or end_date > coverage_end:
return None
try:
table = pq.read_table(
str(p),
filters=[
("date", ">=", start_date),
("date", "<=", end_date),
],
)
rows = self._normalize_rows(table.to_pylist())
if not _daily_rows_match_coverage_sanity(rows, coverage_start, coverage_end):
p.unlink(missing_ok=True)
return None
return rows
except Exception:
return None
def get_with_tail(
self,
ticker: str,
start_date: str,
end_date: str,
) -> tuple[list[dict[str, Any]] | None, str | None]:
"""Like get(), but supports partial hits when end_date > coverage_end.
Returns:
(bars, None) full hit (end_date <= coverage_end)
(bars, tail_start) partial hit; bars cover start_date..coverage_end,
tail_start is the first calendar day to fetch from Oracle
(None, None) true miss (no file, or start_date not covered)
"""
from datetime import date as _date, timedelta as _td
p = self._path(ticker)
if not p.exists():
return None, None
if not self._metadata_valid(p):
p.unlink(missing_ok=True)
return None, None
coverage_start, coverage_end = self._coverage_from_metadata(p)
if coverage_start is None or coverage_end is None:
p.unlink(missing_ok=True)
return None, None
if start_date < coverage_start:
return None, None # true miss: need earlier data than cached
effective_end = coverage_end if end_date > coverage_end else end_date
try:
table = pq.read_table(
str(p),
filters=[("date", ">=", start_date), ("date", "<=", effective_end)],
)
rows = self._normalize_rows(table.to_pylist())
except Exception:
return None, None
if not _daily_rows_match_coverage_sanity(rows, coverage_start, coverage_end):
p.unlink(missing_ok=True)
return None, None
if end_date <= coverage_end:
return rows, None # full hit
# Partial hit — caller must fetch from tail_start to end_date
tail_start = (_date.fromisoformat(coverage_end) + _td(days=1)).isoformat()
return rows, tail_start
def put(
self,
ticker: str,
start_date: str,
end_date: str,
bars: list[dict[str, Any]],
) -> None:
"""Merge a fetched daily-bar range into the ticker cache."""
p = self._path(ticker)
p.parent.mkdir(parents=True, exist_ok=True)
normalized_new = self._normalize_rows(bars)
merged_by_date: dict[str, dict[str, Any]] = {}
coverage_start = start_date
coverage_end = end_date
if p.exists() and self._metadata_valid(p):
try:
existing = pq.read_table(str(p)).to_pylist()
for row in self._normalize_rows(existing):
merged_by_date[row["date"]] = row
existing_start, existing_end = self._coverage_from_metadata(p)
if existing_start:
coverage_start = min(coverage_start, existing_start)
if existing_end:
coverage_end = max(coverage_end, existing_end)
except Exception:
p.unlink(missing_ok=True)
merged_by_date = {}
elif p.exists():
p.unlink(missing_ok=True)
for row in normalized_new:
merged_by_date[row["date"]] = row
rows = [merged_by_date[d] for d in sorted(merged_by_date)]
metadata = dict(_DAILY_CACHE_STATIC_METADATA)
metadata[_DAILY_COVERAGE_START_KEY] = coverage_start.encode()
metadata[_DAILY_COVERAGE_END_KEY] = coverage_end.encode()
schema = _DAILY_SCHEMA.with_metadata(metadata)
table = pa.Table.from_pylist(rows, schema=schema)
tmp = p.with_suffix(".tmp")
try:
pq.write_table(table, str(tmp), compression="snappy")
os.replace(str(tmp), str(p))
except Exception:
if tmp.exists():
tmp.unlink(missing_ok=True)
raise

File diff suppressed because it is too large Load Diff

@ -8,6 +8,8 @@ Used by orb_simulator.py and orb_pre_screen_candidates in screener.py.
from __future__ import annotations from __future__ import annotations
from collections import deque from collections import deque
import math
import statistics
def compute_atr_from_dicts(daily_bars: list[dict], period: int = 14) -> float | None: def compute_atr_from_dicts(daily_bars: list[dict], period: int = 14) -> float | None:
@ -76,6 +78,118 @@ def compute_gap_pct(prev_close: float, today_open: float) -> float | None:
return (today_open - prev_close) / prev_close return (today_open - prev_close) / prev_close
def compute_entropy_approx(daily_bars: list[dict], lookback: int = 20) -> float | None:
"""Normalized Shannon entropy of recent close-to-close returns.
Uses fixed return buckets and returns a value in [0, 1], where lower values
indicate more ordered / repetitive recent behaviour and higher values
indicate a broader return distribution.
"""
if len(daily_bars) < max(lookback, 2):
return None
sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
recent = sorted_bars[-(lookback + 1):]
returns: list[float] = []
for i in range(1, len(recent)):
prev_close = recent[i - 1].get("close")
curr_close = recent[i].get("close")
if not prev_close or prev_close <= 0 or curr_close is None:
continue
returns.append((curr_close - prev_close) / prev_close)
if len(returns) < lookback:
return None
edges = [-0.05, -0.02, -0.01, -0.0025, 0.0025, 0.01, 0.02, 0.05]
counts = [0] * (len(edges) + 1)
for ret in returns[-lookback:]:
placed = False
for idx, edge in enumerate(edges):
if ret < edge:
counts[idx] += 1
placed = True
break
if not placed:
counts[-1] += 1
total = sum(counts)
if total <= 0:
return None
probs = [count / total for count in counts if count > 0]
if not probs:
return None
entropy = -sum(p * math.log(p) for p in probs)
max_entropy = math.log(len(counts))
if max_entropy <= 0:
return None
return entropy / max_entropy
def compute_average_true_range(daily_bars: list[dict], lookback: int) -> float | None:
"""Average true range over the last `lookback` completed daily bars."""
if len(daily_bars) < 2:
return None
sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
true_ranges: list[float] = []
for i in range(1, len(sorted_bars)):
curr = sorted_bars[i]
prev = sorted_bars[i - 1]
tr = max(
curr["high"] - curr["low"],
abs(curr["high"] - prev["close"]),
abs(curr["low"] - prev["close"]),
)
true_ranges.append(tr)
if len(true_ranges) < lookback:
return None
recent = true_ranges[-lookback:]
return sum(recent) / len(recent)
def compute_average_range(daily_bars: list[dict], lookback: int) -> float | None:
"""Average high-low range over the last `lookback` completed daily bars."""
if len(daily_bars) < lookback:
return None
sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
recent = sorted_bars[-lookback:]
ranges = [b["high"] - b["low"] for b in recent if b.get("high") is not None and b.get("low") is not None]
if len(ranges) < lookback:
return None
return sum(ranges) / len(ranges)
def compute_gap_zscore(
daily_bars: list[dict],
today_open: float,
lookback: int = 20,
) -> float | None:
"""Today's opening gap z-score relative to prior completed daily gaps."""
if len(daily_bars) < max(lookback + 1, 2):
return None
sorted_bars = sorted(daily_bars, key=lambda b: b["date"])
if today_open <= 0:
return None
prev_close = sorted_bars[-1].get("close")
if prev_close is None or prev_close <= 0:
return None
gaps: list[float] = []
for i in range(1, len(sorted_bars)):
prev = sorted_bars[i - 1].get("close")
curr_open = sorted_bars[i].get("open")
if prev and prev > 0 and curr_open and curr_open > 0:
gaps.append((curr_open - prev) / prev)
if len(gaps) < lookback:
return None
sample = gaps[-lookback:]
mean_gap = statistics.mean(sample)
std_gap = statistics.stdev(sample) if len(sample) >= 2 else 0.0
if std_gap <= 0:
return 0.0
today_gap = (today_open - prev_close) / prev_close
return (today_gap - mean_gap) / std_gap
def compute_rvol_approx( def compute_rvol_approx(
first_bar_volume: float, first_bar_volume: float,
avg_daily_volume: float, avg_daily_volume: float,
@ -122,6 +236,10 @@ def enrich_daily_bars(
"avg_daily_vol_14d": float | None, 14-day avg daily share volume "avg_daily_vol_14d": float | None, 14-day avg daily share volume
"prev_close": float | None, prior day's close (for gap calc) "prev_close": float | None, prior day's close (for gap calc)
"today_open": float | None, today's open (from today's bar) "today_open": float | None, today's open (from today's bar)
"entropy_20d": float | None, normalized entropy of recent returns
"atr_ratio_10_60": float | None, ATR(10) / ATR(60)
"range_compression_10_60": float | None, avg_range_10 / avg_range_60
"gap_zscore_20d": float | None, today's opening gap z-score
}}} }}}
""" """
result: dict[str, dict[str, dict]] = {} result: dict[str, dict[str, dict]] = {}
@ -168,9 +286,32 @@ def enrich_daily_bars(
"prev_close": prev_close, "prev_close": prev_close,
"today_open": today_bar.get("open"), "today_open": today_bar.get("open"),
"ret_5d": ret_5d, "ret_5d": ret_5d,
"entropy_20d": (
compute_entropy_approx(prev_bars, lookback=20)
if len(prev_bars) >= 20 else None
),
"atr_ratio_10_60": _compute_ratio(
compute_average_true_range(prev_bars, lookback=10),
compute_average_true_range(prev_bars, lookback=60),
),
"range_compression_10_60": _compute_ratio(
compute_average_range(prev_bars, lookback=10),
compute_average_range(prev_bars, lookback=60),
),
"gap_zscore_20d": (
compute_gap_zscore(prev_bars, today_bar.get("open") or 0.0, lookback=20)
if len(prev_bars) >= 21 and (today_bar.get("open") or 0.0) > 0
else None
),
} }
if ticker_result: if ticker_result:
result[ticker] = ticker_result result[ticker] = ticker_result
return result return result
def _compute_ratio(numerator: float | None, denominator: float | None) -> float | None:
if numerator is None or denominator is None or denominator == 0:
return None
return numerator / denominator

@ -33,6 +33,275 @@ def _get_initial_capital(config: IntradayConfig) -> float:
return config.strategy.initial_capital return config.strategy.initial_capital
class IntradayMetricsAccumulator:
"""Streaming metrics accumulator for bounded-memory intraday research runs."""
def __init__(self, config: IntradayConfig, run_id: str = "") -> None:
self.config = config
self.run_id = run_id or str(uuid.uuid4())[:8]
self.initial_capital = _get_initial_capital(config)
self.is_orb = getattr(config, "strategy_mode", "momentum") == "orb"
self.active_strategy = (
config.orb_strategy if (self.is_orb and config.orb_strategy) else config.strategy
)
self.n_days = 0
self.days_with_trades = 0
self.start_date = ""
self.end_date = ""
self.total_trades = 0
self.stop_loss_exits = 0
self.win_count = 0
self.loss_count = 0
self.sum_win_pct = 0.0
self.sum_loss_pct = 0.0
self.gross_profit = 0.0
self.gross_loss = 0.0
self.hold_minutes_sum = 0.0
self.hold_minutes_count = 0
self.daily_returns: list[float] = []
self.equity = self.initial_capital
self.max_equity = self.initial_capital
self.max_drawdown = 0.0
def update(self, day_result: DayResult) -> None:
if not self.start_date:
self.start_date = day_result.date
self.end_date = day_result.date
self.n_days += 1
self.daily_returns.append(day_result.daily_return_pct)
if day_result.trades:
self.days_with_trades += 1
for trade in day_result.trades:
self.total_trades += 1
if trade.exit_reason == "stop_loss":
self.stop_loss_exits += 1
if trade.pnl > 0:
self.win_count += 1
self.sum_win_pct += trade.pnl_pct
self.gross_profit += trade.pnl
else:
self.loss_count += 1
self.sum_loss_pct += trade.pnl_pct
self.gross_loss += abs(trade.pnl)
try:
entry = datetime.fromisoformat(trade.entry_time.replace("Z", "+00:00"))
exit_ = datetime.fromisoformat(trade.exit_time.replace("Z", "+00:00"))
self.hold_minutes_sum += (exit_ - entry).total_seconds() / 60
self.hold_minutes_count += 1
except Exception:
pass
self.equity += day_result.daily_pnl
self.max_equity = max(self.max_equity, self.equity)
if self.max_equity > 0:
dd = (self.equity - self.max_equity) / self.max_equity
self.max_drawdown = min(self.max_drawdown, dd)
def extend(self, day_results: list[DayResult]) -> None:
for day_result in day_results:
self.update(day_result)
def snapshot(self) -> dict[str, Any]:
"""Serialize accumulator state for chunk-level checkpoint/resume."""
return {
"run_id": self.run_id,
"initial_capital": self.initial_capital,
"n_days": self.n_days,
"days_with_trades": self.days_with_trades,
"start_date": self.start_date,
"end_date": self.end_date,
"total_trades": self.total_trades,
"stop_loss_exits": self.stop_loss_exits,
"win_count": self.win_count,
"loss_count": self.loss_count,
"sum_win_pct": self.sum_win_pct,
"sum_loss_pct": self.sum_loss_pct,
"gross_profit": self.gross_profit,
"gross_loss": self.gross_loss,
"hold_minutes_sum": self.hold_minutes_sum,
"hold_minutes_count": self.hold_minutes_count,
"daily_returns": list(self.daily_returns),
"equity": self.equity,
"max_equity": self.max_equity,
"max_drawdown": self.max_drawdown,
}
@classmethod
def from_snapshot(
cls,
config: IntradayConfig,
snapshot: dict[str, Any],
*,
run_id: str = "",
) -> "IntradayMetricsAccumulator":
"""Restore a previously serialized accumulator state."""
accumulator = cls(config, run_id=run_id or snapshot.get("run_id", ""))
accumulator.initial_capital = float(snapshot.get("initial_capital", accumulator.initial_capital))
accumulator.n_days = int(snapshot.get("n_days", 0))
accumulator.days_with_trades = int(snapshot.get("days_with_trades", 0))
accumulator.start_date = snapshot.get("start_date", "") or ""
accumulator.end_date = snapshot.get("end_date", "") or ""
accumulator.total_trades = int(snapshot.get("total_trades", 0))
accumulator.stop_loss_exits = int(snapshot.get("stop_loss_exits", 0))
accumulator.win_count = int(snapshot.get("win_count", 0))
accumulator.loss_count = int(snapshot.get("loss_count", 0))
accumulator.sum_win_pct = float(snapshot.get("sum_win_pct", 0.0))
accumulator.sum_loss_pct = float(snapshot.get("sum_loss_pct", 0.0))
accumulator.gross_profit = float(snapshot.get("gross_profit", 0.0))
accumulator.gross_loss = float(snapshot.get("gross_loss", 0.0))
accumulator.hold_minutes_sum = float(snapshot.get("hold_minutes_sum", 0.0))
accumulator.hold_minutes_count = int(snapshot.get("hold_minutes_count", 0))
accumulator.daily_returns = [
float(value) for value in snapshot.get("daily_returns", [])
]
accumulator.equity = float(snapshot.get("equity", accumulator.initial_capital))
accumulator.max_equity = float(snapshot.get("max_equity", accumulator.initial_capital))
accumulator.max_drawdown = float(snapshot.get("max_drawdown", 0.0))
return accumulator
def finalize(self) -> IntradayMetrics:
if self.total_trades == 0:
return IntradayMetrics(
run_id=self.run_id,
params_hash=_hash_strategy(self.active_strategy),
start_date=self.start_date,
end_date=self.end_date,
trading_days=self.n_days,
days_with_trades=self.days_with_trades,
total_trades=0,
stop_loss_exits=0,
total_return_pct=0.0 if self.n_days > 0 else None,
annualized_return_pct=0.0 if self.n_days > 0 else None,
avg_daily_return_pct=round(statistics.mean(self.daily_returns), 6) if self.daily_returns else None,
max_drawdown_pct=0.0 if self.n_days > 0 else None,
initial_capital=self.initial_capital,
final_equity=round(self.equity, 2),
)
win_rate = self.win_count / self.total_trades if self.total_trades else None
avg_win_pct = self.sum_win_pct / self.win_count if self.win_count > 0 else None
avg_loss_pct = self.sum_loss_pct / self.loss_count if self.loss_count > 0 else None
profit_factor = (
self.gross_profit / self.gross_loss if self.gross_loss > 0 else None
)
expectancy_pct = (
(win_rate * avg_win_pct + (1 - win_rate) * avg_loss_pct)
if win_rate is not None and avg_win_pct is not None and avg_loss_pct is not None
else None
)
total_return_pct = (
(self.equity - self.initial_capital) / self.initial_capital
if self.initial_capital > 0 else None
)
annualized = (
total_return_pct * (252 / self.n_days)
if total_return_pct is not None and self.n_days > 0 else None
)
avg_daily = statistics.mean(self.daily_returns) if self.daily_returns else None
sharpe = sortino = calmar = None
if len(self.daily_returns) >= 5:
try:
mean_r = statistics.mean(self.daily_returns)
std_r = statistics.stdev(self.daily_returns)
if std_r > 0:
sharpe = (mean_r / std_r) * math.sqrt(252)
down_devs = [r for r in self.daily_returns if r < 0]
if down_devs:
downside_std = math.sqrt(
sum(r ** 2 for r in down_devs) / len(self.daily_returns)
)
if downside_std > 0:
sortino = (mean_r / downside_std) * math.sqrt(252)
except Exception:
pass
if annualized is not None and self.max_drawdown < 0:
calmar = annualized / abs(self.max_drawdown)
stop_pct = self.stop_loss_exits / self.total_trades if self.total_trades else None
loss_stats = _loss_containment_stats(self.daily_returns, include_score=True)
return IntradayMetrics(
run_id=self.run_id,
params_hash=_hash_strategy(self.active_strategy),
start_date=self.start_date,
end_date=self.end_date,
trading_days=self.n_days,
days_with_trades=self.days_with_trades,
total_trades=self.total_trades,
stop_loss_exits=self.stop_loss_exits,
win_rate=round(win_rate, 4) if win_rate is not None else None,
avg_win_pct=round(avg_win_pct, 4) if avg_win_pct is not None else None,
avg_loss_pct=round(avg_loss_pct, 4) if avg_loss_pct is not None else None,
profit_factor=round(profit_factor, 4) if profit_factor is not None else None,
expectancy_pct=round(expectancy_pct, 4) if expectancy_pct is not None else None,
total_return_pct=round(total_return_pct, 4) if total_return_pct is not None else None,
annualized_return_pct=round(annualized, 4) if annualized is not None else None,
avg_daily_return_pct=round(avg_daily, 6) if avg_daily is not None else None,
max_drawdown_pct=round(self.max_drawdown, 4),
sharpe_ratio=round(sharpe, 4) if sharpe is not None else None,
sortino_ratio=round(sortino, 4) if sortino is not None else None,
calmar_ratio=round(calmar, 4) if calmar is not None else None,
loss_day_rate=loss_stats["loss_day_rate"],
avg_loss_day_pct=loss_stats["avg_loss_day_pct"],
tail_loss_20_pct=loss_stats["tail_loss_20_pct"],
worst_day_return_pct=loss_stats["worst_day_return_pct"],
loss_containment_score=loss_stats["loss_containment_score"],
avg_hold_minutes=(
round(self.hold_minutes_sum / self.hold_minutes_count, 1)
if self.hold_minutes_count > 0 else None
),
stop_loss_exit_pct=round(stop_pct, 4) if stop_pct is not None else None,
initial_capital=self.initial_capital,
final_equity=round(self.equity, 2),
)
def _loss_containment_stats(
daily_returns: list[float],
*,
include_score: bool,
) -> dict[str, float | None]:
if not daily_returns:
return {
"loss_day_rate": None,
"avg_loss_day_pct": None,
"tail_loss_20_pct": None,
"worst_day_return_pct": None,
"loss_containment_score": None,
}
loss_days = sorted(r for r in daily_returns if r < 0)
loss_day_rate = len(loss_days) / len(daily_returns)
worst_day = min(daily_returns)
avg_loss_day = statistics.mean(loss_days) if loss_days else None
tail_loss = None
if loss_days:
tail_n = max(1, math.ceil(len(loss_days) * 0.2))
tail_loss = statistics.mean(loss_days[:tail_n])
score = None
if include_score:
if not loss_days:
score = 100.0
else:
avg_abs = abs(avg_loss_day or 0.0) * 100.0
tail_abs = abs(tail_loss or 0.0) * 100.0
worst_abs = abs(worst_day) * 100.0
score = max(0.0, min(100.0, 100.0 - avg_abs * 12.0 - tail_abs * 6.0 - worst_abs * 2.0))
return {
"loss_day_rate": round(loss_day_rate, 4),
"avg_loss_day_pct": None if avg_loss_day is None else round(avg_loss_day, 4),
"tail_loss_20_pct": None if tail_loss is None else round(tail_loss, 4),
"worst_day_return_pct": round(worst_day, 4),
"loss_containment_score": None if score is None else round(score, 2),
}
def compute_metrics( def compute_metrics(
day_results: list[DayResult], day_results: list[DayResult],
config: IntradayConfig, config: IntradayConfig,
@ -43,10 +312,28 @@ def compute_metrics(
# Include ALL days (0% for no-trade days) — idle capital dilutes Sharpe correctly # Include ALL days (0% for no-trade days) — idle capital dilutes Sharpe correctly
daily_returns = [r.daily_return_pct for r in day_results] daily_returns = [r.daily_return_pct for r in day_results]
initial_capital = _get_initial_capital(config) initial_capital = _get_initial_capital(config)
n_days = len(day_results)
days_with_trades = sum(1 for r in day_results if r.trades)
dates = sorted(r.date for r in day_results)
start_date = dates[0] if dates else ""
end_date = dates[-1] if dates else ""
is_orb = getattr(config, "strategy_mode", "momentum") == "orb"
active_strategy = config.orb_strategy if (is_orb and config.orb_strategy) else config.strategy
if not all_trades: if not all_trades:
return IntradayMetrics( return IntradayMetrics(
run_id=run_id or str(uuid.uuid4())[:8], run_id=run_id or str(uuid.uuid4())[:8],
params_hash=_hash_strategy(active_strategy),
start_date=start_date,
end_date=end_date,
trading_days=n_days,
days_with_trades=days_with_trades,
total_trades=0,
stop_loss_exits=0,
total_return_pct=0.0 if n_days > 0 else None,
annualized_return_pct=0.0 if n_days > 0 else None,
avg_daily_return_pct=round(statistics.mean(daily_returns), 6) if daily_returns else None,
max_drawdown_pct=0.0 if n_days > 0 else None,
initial_capital=initial_capital, initial_capital=initial_capital,
final_equity=initial_capital, final_equity=initial_capital,
) )
@ -76,7 +363,6 @@ def compute_metrics(
# Returns # Returns
total_return_pct = (equity_curve[-1] - equity_curve[0]) / equity_curve[0] total_return_pct = (equity_curve[-1] - equity_curve[0]) / equity_curve[0]
n_days = len(day_results)
annualized = total_return_pct * (252 / n_days) if n_days > 0 else None annualized = total_return_pct * (252 / n_days) if n_days > 0 else None
avg_daily = statistics.mean(daily_returns) if daily_returns else None avg_daily = statistics.mean(daily_returns) if daily_returns else None
@ -112,6 +398,7 @@ def compute_metrics(
# Intraday-specific # Intraday-specific
stop_exits = [t for t in all_trades if t.exit_reason == "stop_loss"] stop_exits = [t for t in all_trades if t.exit_reason == "stop_loss"]
stop_pct = len(stop_exits) / len(all_trades) if all_trades else None stop_pct = len(stop_exits) / len(all_trades) if all_trades else None
loss_stats = _loss_containment_stats(daily_returns, include_score=True)
# Average hold time (in minutes) # Average hold time (in minutes)
hold_minutes: list[float] = [] hold_minutes: list[float] = []
@ -125,16 +412,6 @@ def compute_metrics(
except Exception: except Exception:
pass pass
days_with_trades = sum(1 for r in day_results if r.trades)
# Date range from day_results
dates = sorted(r.date for r in day_results)
start_date = dates[0] if dates else ""
end_date = dates[-1] if dates else ""
is_orb = getattr(config, "strategy_mode", "momentum") == "orb"
active_strategy = config.orb_strategy if (is_orb and config.orb_strategy) else config.strategy
return IntradayMetrics( return IntradayMetrics(
run_id=run_id or str(uuid.uuid4())[:8], run_id=run_id or str(uuid.uuid4())[:8],
params_hash=_hash_strategy(active_strategy), params_hash=_hash_strategy(active_strategy),
@ -156,6 +433,11 @@ def compute_metrics(
sharpe_ratio=round(sharpe, 4) if sharpe is not None else None, sharpe_ratio=round(sharpe, 4) if sharpe is not None else None,
sortino_ratio=round(sortino, 4) if sortino is not None else None, sortino_ratio=round(sortino, 4) if sortino is not None else None,
calmar_ratio=round(calmar, 4) if calmar is not None else None, calmar_ratio=round(calmar, 4) if calmar is not None else None,
loss_day_rate=loss_stats["loss_day_rate"],
avg_loss_day_pct=loss_stats["avg_loss_day_pct"],
tail_loss_20_pct=loss_stats["tail_loss_20_pct"],
worst_day_return_pct=loss_stats["worst_day_return_pct"],
loss_containment_score=loss_stats["loss_containment_score"],
avg_hold_minutes=round(statistics.mean(hold_minutes), 1) if hold_minutes else None, avg_hold_minutes=round(statistics.mean(hold_minutes), 1) if hold_minutes else None,
stop_loss_exit_pct=round(stop_pct, 4) if stop_pct is not None else None, stop_loss_exit_pct=round(stop_pct, 4) if stop_pct is not None else None,
initial_capital=initial_capital, initial_capital=initial_capital,
@ -171,6 +453,23 @@ def _hash_strategy(strategy: Any) -> str:
# ── Reporting ────────────────────────────────────────────────────────────── # ── Reporting ──────────────────────────────────────────────────────────────
def _describe_momentum_stop(strategy) -> str:
if strategy.atr_stop_multiplier is not None:
base = f"{strategy.atr_stop_multiplier:.2f}xATR"
elif strategy.opening_range_stop_multiplier is not None:
base = f"{strategy.opening_range_stop_multiplier:.2f}xOR"
elif strategy.stop_loss_pct is not None:
base = f"{strategy.stop_loss_pct:.3f}"
else:
base = "none"
if strategy.trailing_stop_pct is None:
return base
trail = f"trail {strategy.trailing_stop_pct:.3f}"
if strategy.trailing_activation_gain_pct is not None:
trail += f" @+{strategy.trailing_activation_gain_pct*100:.1f}%"
return f"{base} + {trail}"
def format_summary(metrics: IntradayMetrics, config: IntradayConfig) -> str: def format_summary(metrics: IntradayMetrics, config: IntradayConfig) -> str:
"""Format summary table for terminal output using rich.""" """Format summary table for terminal output using rich."""
from rich.console import Console from rich.console import Console
@ -203,7 +502,7 @@ def format_summary(metrics: IntradayMetrics, config: IntradayConfig) -> str:
f"[dim]Universe: {config.universe.source} | " f"[dim]Universe: {config.universe.source} | "
f"Entry: +{config.strategy.entry_minutes_after_open}min | " f"Entry: +{config.strategy.entry_minutes_after_open}min | "
f"Exit: -{config.strategy.exit_minutes_before_close}min | " f"Exit: -{config.strategy.exit_minutes_before_close}min | "
f"Stop: {config.strategy.stop_loss_pct or 'none'} | " f"Stop: {_describe_momentum_stop(config.strategy)} | "
f"Top N: {config.strategy.top_n}[/dim]" f"Top N: {config.strategy.top_n}[/dim]"
) )
console.print() console.print()
@ -240,6 +539,9 @@ def format_summary(metrics: IntradayMetrics, config: IntradayConfig) -> str:
t.add_row("Sharpe ratio", _f(metrics.sharpe_ratio)) t.add_row("Sharpe ratio", _f(metrics.sharpe_ratio))
t.add_row("Sortino ratio", _f(metrics.sortino_ratio)) t.add_row("Sortino ratio", _f(metrics.sortino_ratio))
t.add_row("Calmar ratio", _f(metrics.calmar_ratio)) t.add_row("Calmar ratio", _f(metrics.calmar_ratio))
t.add_row("Avg losing day", _pct(metrics.avg_loss_day_pct))
t.add_row("Tail loss (20%)", _pct(metrics.tail_loss_20_pct))
t.add_row("Loss containment", _f(metrics.loss_containment_score))
t.add_section() t.add_section()
t.add_row("Avg hold (min)", _f(metrics.avg_hold_minutes, 0)) t.add_row("Avg hold (min)", _f(metrics.avg_hold_minutes, 0))
t.add_row("Stop-loss rate", _pct(metrics.stop_loss_exit_pct, 1)) t.add_row("Stop-loss rate", _pct(metrics.stop_loss_exit_pct, 1))
@ -390,7 +692,14 @@ def format_sweep_comparison(sweep_results: list[SweepResult], top_n: int = 20) -
f"{p.get('risk_per_trade_pct', 0)*100:.2f}%", f"{p.get('risk_per_trade_pct', 0)*100:.2f}%",
] ]
else: else:
stop = f"{p.get('stop_loss_pct', '')*100:.0f}" if p.get("stop_loss_pct") else "none" if p.get("atr_stop_multiplier") is not None:
stop = f"{p.get('atr_stop_multiplier'):.2f}xATR"
elif p.get("opening_range_stop_multiplier") is not None:
stop = f"{p.get('opening_range_stop_multiplier'):.2f}xOR"
elif p.get("stop_loss_pct") is not None:
stop = f"{p.get('stop_loss_pct', 0)*100:.0f}"
else:
stop = "none"
param_cells = [ param_cells = [
str(p.get("entry_minutes_after_open", "")), str(p.get("entry_minutes_after_open", "")),
str(p.get("exit_minutes_before_close", "")), str(p.get("exit_minutes_before_close", "")),
@ -433,11 +742,27 @@ def write_results(
all_trades = [t.model_dump() for r in day_results for t in r.trades] all_trades = [t.model_dump() for r in day_results for t in r.trades]
# Aggregate skip breakdown and filter stats across all days
skip_breakdown: dict[str, int] = {"traded": 0}
agg_filter_stats: dict[str, int] = {}
for r in day_results:
if r.skip_reason:
skip_breakdown[r.skip_reason] = skip_breakdown.get(r.skip_reason, 0) + 1
elif r.trades:
skip_breakdown["traded"] += 1
else:
skip_breakdown["traded_no_fill"] = skip_breakdown.get("traded_no_fill", 0) + 1
if r.candidate_filter_stats:
for k, v in r.candidate_filter_stats.items():
agg_filter_stats[k] = agg_filter_stats.get(k, 0) + v
payload = { payload = {
"run_id": metrics.run_id, "run_id": metrics.run_id,
"generated_at": datetime.now().isoformat(), "generated_at": datetime.now().isoformat(),
"config": config.model_dump(), "config": config.model_dump(),
"metrics": metrics.model_dump(), "metrics": metrics.model_dump(),
"skip_breakdown": skip_breakdown,
"aggregate_filter_stats": agg_filter_stats,
"trades": all_trades, "trades": all_trades,
"daily_summary": [ "daily_summary": [
{ {
@ -446,6 +771,11 @@ def write_results(
"daily_return_pct": r.daily_return_pct, "daily_return_pct": r.daily_return_pct,
"candidates_found": r.candidates_found, "candidates_found": r.candidates_found,
"trades": len(r.trades), "trades": len(r.trades),
"skip_reason": r.skip_reason,
"candidate_filter_stats": r.candidate_filter_stats,
"regime_scaler": r.regime_scaler,
"breadth_scaler": r.breadth_scaler,
"is_soft_day": r.is_soft_day,
} }
for r in day_results for r in day_results
], ],

@ -8,7 +8,6 @@ making sweep mode trivial (call once per parameter combination).
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
from collections import deque
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from libs.intraday.domain import DayResult, IntradayTrade, StrategyParams from libs.intraday.domain import DayResult, IntradayTrade, StrategyParams
@ -85,6 +84,385 @@ def _volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float:
return total return total
def _dollar_volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float:
"""Sum approximate dollar volume of all bars up to and including entry_ts."""
total = 0.0
for b in bars:
ts = _parse_ts(b["timestamp"])
if ts <= entry_ts:
close = b.get("close") or 0.0
volume = b.get("volume") or 0.0
total += float(close) * float(volume)
return total
def _linear_scaler(
value: float | None,
low: float | None,
high: float | None,
floor: float,
*,
invert: bool = False,
) -> float:
"""Piecewise-linear scaler bounded to [floor, 1.0].
When invert=False, values <= low map to 1.0 and values >= high map to floor.
When invert=True, values <= low map to floor and values >= high map to 1.0.
"""
if value is None or low is None or high is None or high <= low:
return 1.0
floor = max(0.0, min(1.0, floor))
if invert:
if value <= low:
return floor
if value >= high:
return 1.0
frac = (value - low) / (high - low)
return floor + frac * (1.0 - floor)
if value <= low:
return 1.0
if value >= high:
return floor
frac = (value - low) / (high - low)
return 1.0 - frac * (1.0 - floor)
def _vix_day_scaler(vix_value: float | None, strategy: StrategyParams) -> float:
return _linear_scaler(
vix_value,
strategy.vix_size_scale_low,
strategy.vix_size_scale_high,
strategy.vix_size_scale_min,
)
def _entropy_trade_scaler(entropy_20d: float | None, strategy: StrategyParams) -> float:
return _linear_scaler(
entropy_20d,
strategy.entropy_size_scale_low,
strategy.entropy_size_scale_high,
strategy.entropy_size_scale_min,
)
def _sparse_day_scaler(selected_count: int, strategy: StrategyParams) -> float:
threshold = strategy.full_size_positions_threshold
if threshold is None or threshold <= 0:
return 1.0
floor = max(0.0, min(1.0, strategy.sparse_day_size_floor))
if selected_count >= threshold:
return 1.0
ratio = selected_count / threshold
return max(floor, min(1.0, ratio))
def _safe_value(value: float | None, *, default: float = 0.0) -> float:
return default if value is None else float(value)
def _trade_trailing_stop_pct(info: dict, strategy: StrategyParams) -> float | None:
"""Return the per-trade trailing stop, tightening only overextended leaders."""
trailing_stop_pct = strategy.trailing_stop_pct
if (
trailing_stop_pct is None
or strategy.overextended_trailing_gain_pct is None
or strategy.overextended_trailing_stop_pct is None
):
return trailing_stop_pct
gain_pct = info.get("gain_pct")
if gain_pct is None or gain_pct < strategy.overextended_trailing_gain_pct:
return trailing_stop_pct
return strategy.overextended_trailing_stop_pct
def _trade_catastrophic_stop_price(info: dict, strategy: StrategyParams) -> float | None:
"""Return the initial catastrophic stop price for a trade, if any."""
entry_price_raw = info.get("entry_price_raw")
if entry_price_raw is None or entry_price_raw <= 0:
return None
if strategy.atr_stop_multiplier is not None:
atr_14 = info.get("atr_14")
if atr_14 is None or atr_14 <= 0:
return None
return max(0.0, float(entry_price_raw) - float(atr_14) * strategy.atr_stop_multiplier)
if strategy.opening_range_stop_multiplier is not None:
opening_range_width = info.get("opening_range_width")
if opening_range_width is None or opening_range_width <= 0:
return None
return max(
0.0,
float(entry_price_raw) - float(opening_range_width) * strategy.opening_range_stop_multiplier,
)
if strategy.stop_loss_pct is not None:
return max(0.0, float(entry_price_raw) * (1.0 + strategy.stop_loss_pct))
return None
def _five_sleeve_specs(strategy: StrategyParams) -> list[dict[str, object]]:
sleeves: list[dict[str, object]] = [
{
"label": "core",
"weight": strategy.five_sleeve_core_weight,
"key_fn": lambda item: (
item[1]["gain_pct"],
item[1].get("volume_ratio_14d", 0.0),
item[1].get("entry_volume", 0.0),
),
"component": lambda info: info["gain_pct"],
},
{
"label": "gap",
"weight": strategy.five_sleeve_gap_weight,
"key_fn": lambda item: (
_safe_value(item[1].get("gap_pct"), default=-999.0),
item[1]["gain_pct"],
item[1].get("volume_ratio_14d", 0.0),
),
"component": lambda info: max(info.get("gap_pct") or 0.0, 0.0),
},
{
"label": "volume",
"weight": strategy.five_sleeve_volume_weight,
"key_fn": lambda item: (
item[1].get("volume_ratio_14d", 0.0),
item[1].get("entry_volume", 0.0),
item[1]["gain_pct"],
),
"component": lambda info: info.get("volume_ratio_14d") or 0.0,
},
{
"label": "entropy",
"weight": strategy.five_sleeve_entropy_weight,
"key_fn": lambda item: (
-_safe_value(item[1].get("entropy_20d"), default=1.0),
item[1]["gain_pct"],
item[1].get("volume_ratio_14d", 0.0),
),
"component": lambda info: (
1.0 - info["entropy_20d"] if info.get("entropy_20d") is not None else 0.0
),
},
{
"label": "trend",
"weight": strategy.five_sleeve_trend_weight,
"key_fn": lambda item: (
_safe_value(item[1].get("ret_5d"), default=-999.0),
item[1]["gain_pct"],
item[1].get("volume_ratio_14d", 0.0),
),
"component": lambda info: max(info.get("ret_5d") or 0.0, 0.0),
},
]
if strategy.use_slow_ignite_sleeve and strategy.slow_ignite_weight > 0:
sleeves.append(
{
"label": "slow_ignite",
"weight": strategy.slow_ignite_weight,
"key_fn": lambda item: (
1 if item[1].get("is_slow_ignite") else 0,
item[1].get("confirmation_return_pct", -999.0),
item[1].get("volume_ratio_14d", 0.0),
_safe_value(item[1].get("ret_5d"), default=-999.0),
item[1].get("entry_dollar_volume", 0.0),
),
"component": lambda info: (
(
max(info.get("confirmation_return_pct") or 0.0, 0.0) * 5.0
+ max(min(info.get("volume_ratio_14d") or 0.0, 0.5), 0.0)
+ max(min(info.get("ret_5d") or 0.0, 0.2), 0.0)
)
if info.get("is_slow_ignite")
else 0.0
),
}
)
if strategy.use_liquid_largecap_sleeve and strategy.liquid_largecap_weight > 0:
sleeves.append(
{
"label": "liquid_largecap",
"weight": strategy.liquid_largecap_weight,
"key_fn": lambda item: (
1 if item[1].get("is_liquid_largecap") else 0,
item[1].get("entry_dollar_volume", 0.0),
item[1].get("confirmation_return_pct", -999.0),
item[1].get("gain_pct", 0.0),
item[1].get("avg_dollar_vol_30d", 0.0),
),
"component": lambda info: (
(
min(max((info.get("entry_dollar_volume") or 0.0) / 500_000_000.0, 0.0), 4.0)
+ max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0)
+ max((info.get("gain_pct") or 0.0) * 10.0, 0.0)
+ min(max((info.get("avg_dollar_vol_30d") or 0.0) / 1_000_000_000.0, 0.0), 3.0)
)
if info.get("is_liquid_largecap")
else 0.0
),
}
)
if strategy.use_gap_reclaim_sleeve and strategy.gap_reclaim_weight > 0:
sleeves.append(
{
"label": "gap_reclaim",
"weight": strategy.gap_reclaim_weight,
"key_fn": lambda item: (
1 if item[1].get("is_gap_reclaim") else 0,
item[1].get("confirmation_return_pct", -999.0),
item[1].get("recovery_from_opening_low_pct", 0.0),
item[1].get("entry_dollar_volume", 0.0),
item[1].get("gap_pct", 0.0),
),
"component": lambda info: (
(
max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0)
+ max((info.get("recovery_from_opening_low_pct") or 0.0) * 20.0, 0.0)
+ min(max((info.get("entry_dollar_volume") or 0.0) / 250_000_000.0, 0.0), 4.0)
+ min(max((info.get("gap_pct") or 0.0) * 5.0, 0.0), 2.0)
)
if info.get("is_gap_reclaim")
else 0.0
),
}
)
return sleeves
def _select_momentum_sleeves(
morning_gains: dict[str, dict],
strategy: StrategyParams,
ticker_sectors: dict[str, str] | None = None,
) -> list[tuple[str, str]]:
"""Return ordered (ticker, sleeve) picks for the day."""
if not morning_gains:
return []
sector_cap = strategy.max_positions_per_sector if strategy.max_positions_per_sector and strategy.max_positions_per_sector > 0 else None
sector_counts: dict[str, int] = {}
def _sector_for_ticker(ticker: str) -> str | None:
if not ticker_sectors:
return None
sector = str(ticker_sectors.get(ticker) or "").strip()
if not sector or sector.upper() == "UNKNOWN":
return None
return sector
def _can_pick_ticker(ticker: str) -> bool:
if sector_cap is None:
return True
sector = _sector_for_ticker(ticker)
if sector is None:
return True
return sector_counts.get(sector, 0) < sector_cap
def _record_pick(ticker: str) -> None:
if sector_cap is None:
return
sector = _sector_for_ticker(ticker)
if sector is None:
return
sector_counts[sector] = sector_counts.get(sector, 0) + 1
if not strategy.use_five_sleeves:
ranked = sorted(
morning_gains.keys(),
key=lambda t: (
morning_gains[t]["gain_pct"],
morning_gains[t].get("entry_volume", 0.0),
),
reverse=True,
)
picks: list[tuple[str, str]] = []
for ticker in ranked:
if not _can_pick_ticker(ticker):
continue
picks.append((ticker, "core"))
_record_pick(ticker)
if len(picks) >= strategy.top_n:
break
return picks
sleeves = _five_sleeve_specs(strategy)
picks: list[tuple[str, str]] = []
chosen: set[str] = set()
items = list(morning_gains.items())
forced_sleeves = [
sleeve
for sleeve in sorted(sleeves, key=lambda sleeve: float(sleeve["weight"]), reverse=True)
if float(sleeve["weight"]) > 0
][: max(0, min(strategy.five_sleeve_force_count, len(sleeves)))]
for sleeve in forced_sleeves:
key_fn = sleeve["key_fn"]
ranked = sorted(items, key=key_fn, reverse=True)
for ticker, _info in ranked:
if ticker in chosen:
continue
if not _can_pick_ticker(ticker):
continue
picks.append((ticker, str(sleeve["label"])))
chosen.add(ticker)
_record_pick(ticker)
break
if len(picks) >= strategy.top_n:
return picks[: strategy.top_n]
fallback_slots = max(0, int(getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0))
fallback_trigger = max(0, int(getattr(strategy, "fallback_liquid_largecap_trigger_below", 0) or 0))
if (
fallback_slots > 0
and len(picks) < strategy.top_n
and len(picks) < fallback_trigger
):
ranked_liquid = sorted(
(
item for item in items
if item[1].get("is_liquid_largecap")
),
key=lambda item: (
item[1].get("entry_dollar_volume", 0.0),
item[1].get("confirmation_return_pct", 0.0),
item[1].get("gain_pct", 0.0),
item[1].get("avg_dollar_vol_30d", 0.0),
),
reverse=True,
)
added = 0
for ticker, _info in ranked_liquid:
if ticker in chosen:
continue
if not _can_pick_ticker(ticker):
continue
picks.append((ticker, "liquid_largecap_fallback"))
chosen.add(ticker)
_record_pick(ticker)
added += 1
if len(picks) >= strategy.top_n or added >= fallback_slots:
break
def blended_score(item: tuple[str, dict]) -> float:
_ticker, info = item
score = 0.0
for sleeve in sleeves:
weight = float(sleeve["weight"])
if weight <= 0:
continue
score += weight * float(sleeve["component"](info))
return score
ranked_fill = sorted(items, key=blended_score, reverse=True)
for ticker, _info in ranked_fill:
if ticker in chosen:
continue
if not _can_pick_ticker(ticker):
continue
picks.append((ticker, "blend"))
chosen.add(ticker)
_record_pick(ticker)
if len(picks) >= strategy.top_n:
break
return picks
# ── Trade Simulation ─────────────────────────────────────────────────────── # ── Trade Simulation ───────────────────────────────────────────────────────
@ -105,13 +483,14 @@ def simulate_trade(
exit_offset_minutes: int, exit_offset_minutes: int,
stop_loss_pct: float | None, stop_loss_pct: float | None,
trailing_stop_pct: float | None, trailing_stop_pct: float | None,
catastrophic_stop_price_raw: float | None,
trailing_activation_gain_pct: float | None,
slippage_bps: float, slippage_bps: float,
date_str: str, date_str: str,
) -> tuple[float, str, str]: ) -> tuple[float, str, str]:
"""Simulate a single intraday trade. """Simulate a single intraday trade.
Supports both fixed stop-loss and trailing stop. Supports catastrophic/fixed stops plus optional delayed trailing stops.
When trailing_stop_pct is set, it takes precedence over stop_loss_pct.
Returns: Returns:
(exit_price_after_slippage, exit_time_str, exit_reason) (exit_price_after_slippage, exit_time_str, exit_reason)
@ -139,8 +518,17 @@ def simulate_trade(
if b["high"] > peak_price: if b["high"] > peak_price:
peak_price = b["high"] peak_price = b["high"]
peak_gain_pct = (peak_price - entry_price_raw) / entry_price_raw if entry_price_raw > 0 else 0.0
trailing_active = (
trailing_stop_pct is not None
and (
trailing_activation_gain_pct is None
or peak_gain_pct >= trailing_activation_gain_pct
)
)
# Determine effective stop level # Determine effective stop level
if trailing_stop_pct is not None: if trailing_active:
# Trailing: stop = peak × (1 + trailing_pct), trails upward # Trailing: stop = peak × (1 + trailing_pct), trails upward
stop_price = peak_price * (1.0 + trailing_stop_pct) # trailing_pct is negative stop_price = peak_price * (1.0 + trailing_stop_pct) # trailing_pct is negative
low_price = b["low"] low_price = b["low"]
@ -149,11 +537,12 @@ def simulate_trade(
exit_time_str = b["timestamp"] exit_time_str = b["timestamp"]
exit_reason = "trailing_stop" exit_reason = "trailing_stop"
break break
elif stop_loss_pct is not None: else:
# Fixed stop: relative to entry stop_price = catastrophic_stop_price_raw
low_return = (b["low"] - entry_price_raw) / entry_price_raw if stop_price is None and stop_loss_pct is not None:
if low_return <= stop_loss_pct: stop_price = entry_price_raw * (1.0 + stop_loss_pct)
exit_price_raw = entry_price_raw * (1.0 + stop_loss_pct) if stop_price is not None and b["low"] <= stop_price:
exit_price_raw = stop_price
exit_time_str = b["timestamp"] exit_time_str = b["timestamp"]
exit_reason = "stop_loss" exit_reason = "stop_loss"
break break
@ -182,6 +571,8 @@ def compute_morning_gains(
date_str: str, date_str: str,
blacklisted_tickers: set[str] | None = None, blacklisted_tickers: set[str] | None = None,
spy_bars: list[dict] | None = None, spy_bars: list[dict] | None = None,
daily_features_by_ticker: dict[str, dict] | None = None,
vix_value: float | None = None,
) -> dict[str, dict]: ) -> dict[str, dict]:
"""Compute each ticker's gain from open to entry time, applying all filters. """Compute each ticker's gain from open to entry time, applying all filters.
@ -209,6 +600,9 @@ def compute_morning_gains(
if spy_gain < strategy.market_regime_spy_threshold: if spy_gain < strategy.market_regime_spy_threshold:
return {} # Skip this day entirely return {} # Skip this day entirely
if strategy.max_vix is not None and vix_value is not None and vix_value > strategy.max_vix:
return {}
result = {} result = {}
for ticker, all_bars in bars_by_ticker.items(): for ticker, all_bars in bars_by_ticker.items():
@ -224,29 +618,221 @@ def compute_morning_gains(
if open_price <= 0: if open_price <= 0:
continue continue
entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) initial_entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open)
if entry_bar is None: if initial_entry_bar is None:
continue continue
entry_bar = initial_entry_bar
if strategy.confirmation_minutes_after_entry > 0:
confirmation_bar = _bar_at_offset(
mkt_bars,
market_open,
strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry,
)
if confirmation_bar is None:
continue
confirmation_return = (
confirmation_bar["close"] - initial_entry_bar["close"]
) / initial_entry_bar["close"]
entry_bar = confirmation_bar
else:
confirmation_return = None
entry_price_raw = entry_bar["close"] entry_price_raw = entry_bar["close"]
if entry_price_raw <= 0: if entry_price_raw <= 0:
continue continue
gain_pct = (entry_price_raw - open_price) / open_price gain_pct = (entry_price_raw - open_price) / open_price
# min gain filter
if gain_pct < strategy.min_morning_gain_pct:
continue
# max gain filter (avoid extreme gap-ups that tend to mean-revert)
if strategy.max_morning_gain_pct is not None and gain_pct > strategy.max_morning_gain_pct:
continue
# volume filter: cumulative volume up to entry time # volume filter: cumulative volume up to entry time
entry_ts = _parse_ts(entry_bar["timestamp"]) entry_ts = _parse_ts(entry_bar["timestamp"])
entry_vol = _volume_up_to_bar(mkt_bars, entry_ts) entry_vol = _volume_up_to_bar(mkt_bars, entry_ts)
if strategy.min_entry_volume is not None and entry_vol < strategy.min_entry_volume: if strategy.min_entry_volume is not None and entry_vol < strategy.min_entry_volume:
continue continue
entry_dollar_vol = _dollar_volume_up_to_bar(mkt_bars, entry_ts)
if (
strategy.min_entry_dollar_volume is not None
and entry_dollar_vol < strategy.min_entry_dollar_volume
):
continue
daily_features = (daily_features_by_ticker or {}).get(ticker, {})
gap_pct = daily_features.get("gap_pct")
gap_min_ok = (
strategy.min_gap_pct is None
or (gap_pct is not None and gap_pct >= strategy.min_gap_pct)
)
gap_max_ok = (
strategy.max_gap_pct is None
or (gap_pct is not None and gap_pct <= strategy.max_gap_pct)
)
volume_ratio_14d = None
avg_daily_vol_14d = daily_features.get("avg_daily_vol_14d")
if avg_daily_vol_14d and avg_daily_vol_14d > 0:
volume_ratio_14d = entry_vol / avg_daily_vol_14d
if (
strategy.min_volume_ratio_14d is not None
and (volume_ratio_14d is None or volume_ratio_14d < strategy.min_volume_ratio_14d)
):
continue
ret_5d = daily_features.get("ret_5d")
if strategy.min_ret_5d is not None and (ret_5d is None or ret_5d < strategy.min_ret_5d):
continue
entropy_20d = daily_features.get("entropy_20d")
avg_dollar_vol_30d = daily_features.get("avg_dollar_vol_30d")
atr_14 = daily_features.get("atr_14")
if strategy.min_entropy_20d is not None and (entropy_20d is None or entropy_20d < strategy.min_entropy_20d):
continue
global_max_entropy_ok = True
if strategy.max_entropy_20d is not None and (entropy_20d is None or entropy_20d > strategy.max_entropy_20d):
global_max_entropy_ok = False
opening_range_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts]
if not opening_range_bars:
continue
opening_range_high = max(float(bar["high"]) for bar in opening_range_bars)
opening_range_low = min(float(bar["low"]) for bar in opening_range_bars)
opening_range_width = max(0.0, opening_range_high - opening_range_low)
recovery_from_opening_low_pct = (
(entry_price_raw - opening_range_low) / opening_range_low
if opening_range_low > 0
else None
)
if strategy.atr_stop_multiplier is not None and (atr_14 is None or atr_14 <= 0):
continue
if strategy.opening_range_stop_multiplier is not None and opening_range_width <= 0:
continue
confirmation_ok = (
strategy.min_confirmation_return_pct is None
or confirmation_return is None
or confirmation_return >= strategy.min_confirmation_return_pct
)
regular_ok = gap_min_ok and gap_max_ok and global_max_entropy_ok and confirmation_ok and gain_pct >= strategy.min_morning_gain_pct and (
strategy.max_morning_gain_pct is None or gain_pct <= strategy.max_morning_gain_pct
)
slow_ignite_ok = False
if (
strategy.use_slow_ignite_sleeve
and gap_min_ok
and gap_max_ok
and global_max_entropy_ok
and confirmation_ok
and gain_pct < strategy.min_morning_gain_pct
):
if strategy.slow_ignite_min_gain_pct is not None and gain_pct < strategy.slow_ignite_min_gain_pct:
pass
elif strategy.slow_ignite_max_gain_pct is not None and gain_pct > strategy.slow_ignite_max_gain_pct:
pass
elif (
strategy.slow_ignite_min_entry_dollar_volume is not None
and entry_dollar_vol < strategy.slow_ignite_min_entry_dollar_volume
):
pass
elif (
strategy.slow_ignite_min_volume_ratio_14d is not None
and (volume_ratio_14d is None or volume_ratio_14d < strategy.slow_ignite_min_volume_ratio_14d)
):
pass
elif (
strategy.slow_ignite_min_ret_5d is not None
and (ret_5d is None or ret_5d < strategy.slow_ignite_min_ret_5d)
):
pass
elif (
strategy.slow_ignite_max_entropy_20d is not None
and (entropy_20d is None or entropy_20d > strategy.slow_ignite_max_entropy_20d)
):
pass
else:
slow_ignite_ok = True
liquid_largecap_ok = False
liquid_largecap_enabled = (
strategy.use_liquid_largecap_sleeve
or (getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0) > 0
)
if liquid_largecap_enabled and gap_min_ok and gap_max_ok and confirmation_ok:
liquid_largecap_entropy_cap = strategy.liquid_largecap_max_entropy_20d
if liquid_largecap_entropy_cap is None:
liquid_largecap_entropy_cap = strategy.max_entropy_20d
if (
strategy.liquid_largecap_min_gain_pct is not None
and gain_pct < strategy.liquid_largecap_min_gain_pct
):
pass
elif (
strategy.liquid_largecap_max_gain_pct is not None
and gain_pct > strategy.liquid_largecap_max_gain_pct
):
pass
elif (
strategy.liquid_largecap_min_confirmation_return_pct is not None
and confirmation_return < strategy.liquid_largecap_min_confirmation_return_pct
):
pass
elif (
strategy.liquid_largecap_min_entry_dollar_volume is not None
and entry_dollar_vol < strategy.liquid_largecap_min_entry_dollar_volume
):
pass
elif (
strategy.liquid_largecap_min_avg_dollar_vol_30d is not None
and (
avg_dollar_vol_30d is None
or avg_dollar_vol_30d < strategy.liquid_largecap_min_avg_dollar_vol_30d
)
):
pass
elif (
liquid_largecap_entropy_cap is not None
and (entropy_20d is None or entropy_20d > liquid_largecap_entropy_cap)
):
pass
else:
liquid_largecap_ok = True
gap_reclaim_ok = False
if strategy.use_gap_reclaim_sleeve:
if strategy.gap_reclaim_min_gap_pct is not None and (
gap_pct is None or gap_pct < strategy.gap_reclaim_min_gap_pct
):
pass
elif strategy.gap_reclaim_min_gain_pct is not None and gain_pct < strategy.gap_reclaim_min_gain_pct:
pass
elif strategy.gap_reclaim_max_gain_pct is not None and gain_pct > strategy.gap_reclaim_max_gain_pct:
pass
elif (
strategy.gap_reclaim_min_confirmation_return_pct is not None
and (
confirmation_return is None
or confirmation_return < strategy.gap_reclaim_min_confirmation_return_pct
)
):
pass
elif (
strategy.gap_reclaim_min_entry_dollar_volume is not None
and entry_dollar_vol < strategy.gap_reclaim_min_entry_dollar_volume
):
pass
elif (
strategy.gap_reclaim_min_recovery_from_opening_low_pct is not None
and (
recovery_from_opening_low_pct is None
or recovery_from_opening_low_pct < strategy.gap_reclaim_min_recovery_from_opening_low_pct
)
):
pass
else:
gap_reclaim_ok = True
if not regular_ok and not slow_ignite_ok and not liquid_largecap_ok and not gap_reclaim_ok:
continue
result[ticker] = { result[ticker] = {
"gain_pct": gain_pct, "gain_pct": gain_pct,
@ -254,6 +840,19 @@ def compute_morning_gains(
"entry_bar": entry_bar, "entry_bar": entry_bar,
"mkt_bars": mkt_bars, "mkt_bars": mkt_bars,
"entry_volume": entry_vol, "entry_volume": entry_vol,
"entry_dollar_volume": entry_dollar_vol,
"gap_pct": gap_pct,
"volume_ratio_14d": volume_ratio_14d,
"ret_5d": ret_5d,
"entropy_20d": entropy_20d,
"avg_dollar_vol_30d": avg_dollar_vol_30d,
"atr_14": atr_14,
"opening_range_width": opening_range_width,
"recovery_from_opening_low_pct": recovery_from_opening_low_pct,
"confirmation_return_pct": confirmation_return,
"is_slow_ignite": slow_ignite_ok,
"is_liquid_largecap": liquid_largecap_ok,
"is_gap_reclaim": gap_reclaim_ok,
} }
return result return result
@ -268,6 +867,10 @@ def simulate_day(
strategy: StrategyParams, strategy: StrategyParams,
blacklisted_tickers: set[str] | None = None, blacklisted_tickers: set[str] | None = None,
spy_bars: list[dict] | None = None, spy_bars: list[dict] | None = None,
daily_features_by_ticker: dict[str, dict] | None = None,
vix_value: float | None = None,
current_equity: float | None = None,
ticker_sectors: dict[str, str] | None = None,
) -> DayResult: ) -> DayResult:
"""Simulate one full trading day. """Simulate one full trading day.
@ -275,6 +878,12 @@ def simulate_day(
2. Rank by gain, pick top N. 2. Rank by gain, pick top N.
3. Simulate each trade with stop-loss / trailing stop. 3. Simulate each trade with stop-loss / trailing stop.
4. Compute daily P&L. 4. Compute daily P&L.
Args:
current_equity: Current portfolio equity for compound position sizing.
When strategy.compound_returns=True and this is provided,
position sizes scale with current equity. Otherwise uses
strategy.initial_capital (simple/단리 mode).
""" """
result = DayResult(date=date_str) result = DayResult(date=date_str)
@ -284,22 +893,39 @@ def simulate_day(
date_str, date_str,
blacklisted_tickers=blacklisted_tickers, blacklisted_tickers=blacklisted_tickers,
spy_bars=spy_bars, spy_bars=spy_bars,
daily_features_by_ticker=daily_features_by_ticker,
vix_value=vix_value,
) )
result.candidates_found = len(morning_gains) result.candidates_found = len(morning_gains)
if not morning_gains: if not morning_gains:
return result return result
# Pick top N by morning gain top_tickers = _select_momentum_sleeves(morning_gains, strategy, ticker_sectors=ticker_sectors)
top_tickers = sorted( if not top_tickers:
morning_gains.keys(), return result
key=lambda t: morning_gains[t]["gain_pct"], if len(top_tickers) < max(1, strategy.min_positions_to_trade):
reverse=True, return result
)[: strategy.top_n]
capital_per_trade = strategy.initial_capital / strategy.top_n if strategy.daily_budget_reset:
# Research mode: every day resets to initial_capital (ignore prior-day PnL).
sizing_capital = strategy.initial_capital
elif strategy.compound_returns and current_equity is not None:
sizing_capital = max(current_equity, 0.0)
elif current_equity is not None:
# Simple mode: fixed at initial_capital, but cannot exceed actual equity
# (can't invest money you don't have after drawdowns).
sizing_capital = min(strategy.initial_capital, max(current_equity, 0.0))
else:
sizing_capital = strategy.initial_capital
capital_budget = (
sizing_capital
* _vix_day_scaler(vix_value, strategy)
* _sparse_day_scaler(len(top_tickers), strategy)
)
capital_per_trade = capital_budget / len(top_tickers)
for ticker in top_tickers: for ticker, sleeve in top_tickers:
info = morning_gains[ticker] info = morning_gains[ticker]
entry_price_raw = info["entry_price_raw"] entry_price_raw = info["entry_price_raw"]
entry_bar = info["entry_bar"] entry_bar = info["entry_bar"]
@ -311,15 +937,18 @@ def simulate_day(
entry_price_raw, entry_price_raw,
strategy.exit_minutes_before_close, strategy.exit_minutes_before_close,
strategy.stop_loss_pct, strategy.stop_loss_pct,
strategy.trailing_stop_pct, _trade_trailing_stop_pct(info, strategy),
_trade_catastrophic_stop_price(info, strategy),
strategy.trailing_activation_gain_pct,
strategy.slippage_bps, strategy.slippage_bps,
date_str, date_str,
) )
entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps) entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps)
shares = capital_per_trade / entry_price_filled trade_capital = capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy)
shares = trade_capital / entry_price_filled
pnl_pct = (exit_price - entry_price_filled) / entry_price_filled pnl_pct = (exit_price - entry_price_filled) / entry_price_filled
pnl = pnl_pct * capital_per_trade pnl = pnl_pct * trade_capital
slippage_cost = ( slippage_cost = (
(entry_price_filled - entry_price_raw) + (entry_price_filled - entry_price_raw) +
@ -339,13 +968,15 @@ def simulate_day(
exit_reason=exit_reason, exit_reason=exit_reason,
morning_gain_pct=round(info["gain_pct"], 6), morning_gain_pct=round(info["gain_pct"], 6),
slippage_cost=round(slippage_cost, 4), slippage_cost=round(slippage_cost, 4),
trade_sleeve=sleeve,
) )
result.trades.append(trade) result.trades.append(trade)
result.daily_pnl += trade.pnl result.daily_pnl += trade.pnl
if result.trades: if result.trades:
total_deployed = capital_per_trade * len(result.trades) total_deployed = sum(t.shares * t.entry_price for t in result.trades)
result.daily_return_pct = result.daily_pnl / total_deployed if total_deployed > 0:
result.daily_return_pct = result.daily_pnl / total_deployed
return result return result
@ -357,6 +988,10 @@ def run_simulation(
all_intraday: dict[str, dict[str, list[dict]]], all_intraday: dict[str, dict[str, list[dict]]],
trading_days: list[str], trading_days: list[str],
strategy: StrategyParams, strategy: StrategyParams,
*,
daily_enrichment: dict[str, dict[str, dict]] | None = None,
vix_by_day: dict[str, float] | None = None,
ticker_sectors: dict[str, str] | None = None,
) -> list[DayResult]: ) -> list[DayResult]:
"""Run the full backtest simulation across all trading days. """Run the full backtest simulation across all trading days.
@ -374,16 +1009,21 @@ def run_simulation(
strategy: Strategy parameters. strategy: Strategy parameters.
Returns: Returns:
List of DayResult objects (one per day that had intraday data). List of DayResult objects (one per trading day; days without intraday data get a 0% return result).
""" """
results: list[DayResult] = [] results: list[DayResult] = []
# Ticker cooldown: map ticker -> last traded date # Ticker cooldown: map ticker -> last traded date
ticker_last_traded: dict[str, dt.date] = {} ticker_last_traded: dict[str, dt.date] = {}
# Compound return tracking: equity grows with each day's P&L
equity = strategy.initial_capital
for date_str in trading_days: for date_str in trading_days:
bars_by_ticker = all_intraday.get(date_str) bars_by_ticker = all_intraday.get(date_str)
if not bars_by_ticker: if not bars_by_ticker:
# No intraday data for this day — still record it (0% return, no trades)
results.append(DayResult(date=date_str))
continue continue
# Build blacklist from cooldown # Build blacklist from cooldown
@ -404,8 +1044,19 @@ def run_simulation(
strategy, strategy,
blacklisted_tickers=blacklisted if blacklisted else None, blacklisted_tickers=blacklisted if blacklisted else None,
spy_bars=spy_bars, spy_bars=spy_bars,
daily_features_by_ticker=(
{
ticker: daily_enrichment.get(ticker, {}).get(date_str, {})
for ticker in bars_by_ticker.keys()
}
if daily_enrichment else None
),
vix_value=(vix_by_day or {}).get(date_str),
current_equity=equity,
ticker_sectors=ticker_sectors,
) )
results.append(day_result) results.append(day_result)
equity += day_result.daily_pnl
# Update cooldown tracker # Update cooldown tracker
if strategy.ticker_cooldown_days > 0: if strategy.ticker_cooldown_days > 0:

Loading…
Cancel
Save