"""Opening Range Breakout (ORB) simulation engine. Strategy: At 09:35 ET, identify the first 5-min candle direction. For bullish candles (long-only V1), place a stop-buy order at the candle's high. If filled before timeout (10:15 ET), manage position with ATR-based stops. Exit at 15:55 ET or on stop/trailing stop. Pure functions — no API calls, no disk I/O. run_orb_simulation() takes pre-loaded data and enrichment, returns DayResult list. Compatible with the existing metrics pipeline (compute_metrics, format_summary, etc.). """ from __future__ import annotations import datetime as dt from collections import deque from zoneinfo import ZoneInfo from libs.intraday.domain import DayResult, IntradayTrade, ORBStrategyParams from libs.intraday.features import compute_rvol_approx from libs.intraday.simulator import ( _apply_slippage_entry, _apply_slippage_exit, _market_open_ts, _parse_ts, filter_market_hours, ) _ET = ZoneInfo("America/New_York") _MARKET_OPEN = dt.time(9, 30) _MARKET_CLOSE = dt.time(16, 0) _MIN_BARS = 5 # minimum market-hours bars required # Doji threshold: if |close - open| / open < this, classify as doji _DOJI_THRESHOLD = 0.001 # ── Bar Aggregation ─────────────────────────────────────────────────────── def _aggregate_bars(bars: list[dict], group_size: int) -> list[dict]: """Aggregate consecutive bars into larger intervals (e.g. 6 × 5-min → 30-min). Each output bar has: timestamp (from LAST bar in group — when the candle completes), OHLCV aggregated. Incomplete trailing groups are still emitted. """ if group_size <= 1: return bars result: list[dict] = [] for i in range(0, len(bars), group_size): group = bars[i : i + group_size] result.append({ "timestamp": group[-1]["timestamp"], # end of bar: when trader sees completed candle "open": group[0]["open"], "high": max(b["high"] for b in group), "low": min(b["low"] for b in group), "close": group[-1]["close"], "volume": sum(b.get("volume", 0) for b in group), }) return result # ── ORB Candle Classification ────────────────────────────────────────────── def classify_orb_candle(orb_bar: dict) -> str: """Classify the ORB candle as 'bullish', 'bearish', or 'doji'. Args: orb_bar: The first 5-min bar dict with 'open' and 'close' keys. Returns: 'bullish' if close > open (by more than doji threshold), 'bearish' if close < open (by more than doji threshold), 'doji' if close ≈ open. """ o = orb_bar.get("open", 0) c = orb_bar.get("close", 0) if o <= 0: return "doji" diff_pct = (c - o) / o if diff_pct > _DOJI_THRESHOLD: return "bullish" if diff_pct < -_DOJI_THRESHOLD: return "bearish" return "doji" # ── Composite Ranking ────────────────────────────────────────────────────── def _normalize_scores(values: list[float]) -> list[float]: """Min-max normalize a list to [0, 1]. Returns zeros if all values equal.""" if not values: return [] mn, mx = min(values), max(values) if mx <= mn: return [0.5] * len(values) return [(v - mn) / (mx - mn) for v in values] def _compute_composite_score( rvol: float, gap_pct: float, first_bar_dollar_vol: float, params: ORBStrategyParams, rvol_list: list[float], gap_list: list[float], dolvol_list: list[float], idx: int, ) -> float: """Compute normalized composite ranking score for a single candidate. Uses pre-normalized lists (same index) to ensure cross-candidate normalization. """ # Clamp gap to positive (only care about gap-up for long-only) norm_rvol = rvol_list[idx] norm_gap = gap_list[idx] norm_dolvol = dolvol_list[idx] return ( norm_rvol * params.weight_rvol + norm_gap * params.weight_gap + norm_dolvol * params.weight_dollar_vol ) # ── ORB Candidate Selection ──────────────────────────────────────────────── def compute_orb_candidates( bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, ) -> list[dict]: """Identify and rank ORB candidates for a given trading day. Pipeline per ticker: 1. Get market-hours bars, require >= _MIN_BARS 2. Extract ORB candle (first bar = 9:30–9:35 ET bar) 3. Filter by direction: bullish only (long-only V1) 4. Apply quality filters from enrichment: price, ATR, dollar_vol 5. Compute approximate RVOL; filter by min_rvol 6. Compute gap% from prev_close 7. Rank by composite score: RVOL × w + gap × w + dollar_vol × w 8. Return top max_candidates Args: bars_by_ticker: {ticker: [bar_dict, ...]} for today. date_str: Today's date as 'YYYY-MM-DD'. params: ORB strategy parameters. enrichment: {ticker: {date: features}} from enrich_daily_bars(). blacklisted_tickers: Tickers in cooldown period. spy_bars: SPY intraday bars for market regime filter. Returns: List of candidate dicts, sorted by composite score descending, capped at max_candidates. Each dict: {ticker, orb_bar, direction, rvol, gap_pct, atr, first_bar_dollar_vol, score, mkt_bars} """ market_open = _market_open_ts(date_str) raw_candidates: list[dict] = [] # Filter stats — populated only when no candidates found (for diagnostics) _f_no_bars = _f_late = _f_price = _f_dir = _f_atr = _f_dolvol = _f_rvol = _f_gap = 0 for ticker, all_bars in bars_by_ticker.items(): if blacklisted_tickers and ticker in blacklisted_tickers: continue mkt_bars = filter_market_hours(all_bars) if len(mkt_bars) < _MIN_BARS: _f_no_bars += 1 continue # Verify first bar is near market open (allow data irregularities up to 10 min) first_bar_ts = _parse_ts(mkt_bars[0]["timestamp"]) if abs((first_bar_ts - market_open).total_seconds() / 60) > 10: _f_late += 1 continue # Build ORB candle: aggregate first N 5-min bars per orb_minutes setting. # e.g. orb_minutes=10 → merge bars 0 and 1 into a single 10-min ORB candle. n_orb_bars = max(1, params.orb_minutes // 5) if len(mkt_bars) < n_orb_bars + 1: _f_no_bars += 1 continue # not enough bars to have both ORB window and at least one trading bar orb_bars_raw = mkt_bars[:n_orb_bars] if n_orb_bars == 1: orb_bar = orb_bars_raw[0] else: orb_bar = { "timestamp": orb_bars_raw[-1]["timestamp"], # end of ORB window "open": orb_bars_raw[0]["open"], "high": max(b["high"] for b in orb_bars_raw), "low": min(b["low"] for b in orb_bars_raw), "close": orb_bars_raw[-1]["close"], "volume": sum(b.get("volume", 0) or 0 for b in orb_bars_raw), } # Price filter (use ORB candle open as current price) open_price = orb_bar.get("open", 0) if open_price < params.min_price: _f_price += 1 continue # Direction filter (uses aggregated ORB candle open/close) direction = classify_orb_candle(orb_bar) if params.entry_direction == "long_only" and direction != "bullish": _f_dir += 1 continue if direction == "doji": _f_dir += 1 continue # Enrichment features (all computed from PRIOR bars → no lookahead) ticker_enrich = enrichment.get(ticker, {}).get(date_str, {}) atr = ticker_enrich.get("atr_14") avg_dollar_vol = ticker_enrich.get("avg_dollar_vol_30d") avg_daily_vol = ticker_enrich.get("avg_daily_vol_14d") prev_close = ticker_enrich.get("prev_close") # ATR filter if atr is None or atr < params.min_atr_14: _f_atr += 1 continue # Dollar volume filter if avg_dollar_vol is None or avg_dollar_vol < params.min_avg_dollar_volume: _f_dolvol += 1 continue orb_vol = orb_bar.get("volume", 0) or 0 rvol = compute_rvol_approx(orb_vol, avg_daily_vol) if avg_daily_vol else None if rvol is None or rvol < params.min_rvol: _f_rvol += 1 continue # Gap % gap_pct = 0.0 if prev_close and prev_close > 0: gap_pct = (open_price - prev_close) / prev_close # Max gap filter: exclude over-extended stocks (gap-up > threshold) # Stocks that open >10% above prev_close are prone to mean-reversion, not continuation. if params.max_gap_pct is not None and gap_pct > params.max_gap_pct: _f_gap += 1 continue # First-bar dollar volume (ORB window total) first_bar_dollar_vol = orb_vol * open_price # ORB candle directional conviction: how decisively did the candle move? # For longs: (close - open) / range; for shorts: (open - close) / range. # Range clamped to avoid division by zero on flat candles. orb_range = orb_bar["high"] - orb_bar["low"] orb_close = orb_bar["close"] orb_open_price = orb_bar["open"] if orb_range > 0: if direction == "bullish": body_ratio = max((orb_close - orb_open_price) / orb_range, 0.0) else: body_ratio = max((orb_open_price - orb_close) / orb_range, 0.0) else: body_ratio = 0.0 # 5-day prior momentum in the direction of the breakout. # For longs: positive ret_5d = stock already trending up (momentum alignment). # For shorts: negative ret_5d = stock already trending down. ret_5d = ticker_enrich.get("ret_5d") if ret_5d is not None: momentum = ret_5d if direction == "bullish" else -ret_5d else: momentum = 0.0 raw_candidates.append({ "ticker": ticker, "orb_bar": orb_bar, "direction": direction, "rvol": rvol, "gap_pct": gap_pct, "atr": atr, "first_bar_dollar_vol": first_bar_dollar_vol, "body_ratio": body_ratio, "momentum": momentum, "mkt_bars": mkt_bars, }) if not raw_candidates: total = len(bars_by_ticker) import sys print( f" [{date_str}] 0 ORB candidates from {total} tickers — " f"bearish/doji:{_f_dir} atr:{_f_atr} dolvol:{_f_dolvol} " f"rvol:{_f_rvol} gap>{params.max_gap_pct and f'{params.max_gap_pct*100:.0f}%' or '?'}:{_f_gap} " f"bars:{_f_no_bars} late:{_f_late} price:{_f_price}", file=sys.stderr, ) return [] # Normalize and score rvol_vals = [c["rvol"] for c in raw_candidates] gap_vals = [max(c["gap_pct"], 0.0) for c in raw_candidates] # clip negative gaps dolvol_vals = [c["first_bar_dollar_vol"] for c in raw_candidates] body_vals = [c["body_ratio"] for c in raw_candidates] momentum_vals = [max(c["momentum"], 0.0) for c in raw_candidates] # only reward aligned momentum norm_rvol = _normalize_scores(rvol_vals) norm_gap = _normalize_scores(gap_vals) norm_dolvol = _normalize_scores(dolvol_vals) norm_body = _normalize_scores(body_vals) norm_momentum = _normalize_scores(momentum_vals) for i, cand in enumerate(raw_candidates): cand["score"] = ( norm_rvol[i] * params.weight_rvol + norm_gap[i] * params.weight_gap + norm_dolvol[i] * params.weight_dollar_vol + norm_body[i] * params.weight_body_ratio + norm_momentum[i] * params.weight_momentum ) # Sort by score descending, take top N raw_candidates.sort(key=lambda c: c["score"], reverse=True) return raw_candidates[: params.max_candidates] # ── Breakout Detection (for chronological ordering) ────────────────────── def _find_breakout_time( mkt_bars: list[dict], orb_bar: dict, direction: str, params: ORBStrategyParams, date_str: str, ) -> dt.datetime | None: """Find the breakout time for a candidate without running the full simulation. Returns the timestamp of the bar where breakout occurs, or None if no breakout before timeout. Used to sort candidates chronologically before allocating capital. """ group_size = max(1, params.sim_bar_minutes // 5) if group_size > 1: orb_ts_raw = _parse_ts(orb_bar["timestamp"]) post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw] post_bars = _aggregate_bars(post_bars, group_size) else: orb_ts_raw = _parse_ts(orb_bar["timestamp"]) post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw] market_open = _market_open_ts(date_str) timeout_ts = market_open + dt.timedelta(minutes=params.order_timeout_minutes) breakout_level = orb_bar["high"] if direction == "long" else orb_bar["low"] for b in post_bars: ts = _parse_ts(b["timestamp"]) if ts > timeout_ts: return None if direction == "long" and b["high"] >= breakout_level: return ts if direction == "short" and b["low"] <= breakout_level: return ts return None # ── Single Trade Simulation ──────────────────────────────────────────────── def simulate_orb_trade( mkt_bars: list[dict], orb_bar: dict, direction: str, atr: float, rvol: float, gap_pct: float, params: ORBStrategyParams, equity: float, date_str: str, ticker: str, available_cash: float | None = None, sizing_capital: float | None = None, ) -> IntradayTrade | None: """Simulate a single ORB trade with ATR-based stops. Entry: - breakout_level = orb_bar["high"] (long) or orb_bar["low"] (short) - Iterate bars after ORB bar until breakout or timeout - Fill at max(breakout_level, bar.open) — conservative: if bar gaps above breakout, pay open price (worse than breakout_level) - If no fill by order_timeout_minutes: return None Position sizing (risk-based): - risk_dollars = equity × risk_per_trade_pct - stop_distance = atr × atr_stop_multiplier - shares = risk_dollars / stop_distance - cap: shares × entry_price ≤ equity × max_position_pct Stop management (bar iteration after entry): - initial_stop = entry_raw - stop_distance (long) - At +1R (breakeven_at_r): move stop to entry_raw - At +2R (trailing_at_r): activate trailing stop using last 3 bar lows - Trailing: current_stop = max(current_stop, max of last 3 bar lows) If sim_bar_minutes > 5, post-ORB bars are aggregated (e.g. 30-min) before iteration. Returns: IntradayTrade or None if no breakout fill before timeout. """ if atr <= 0: return None stop_distance = atr * params.atr_stop_multiplier if stop_distance <= 0: return None # Aggregate bars if sim_bar_minutes > 5 (e.g. 30-min bars). # The ORB bar (first 5-min bar) is always kept as-is; only post-ORB bars # are aggregated. This keeps the ORB classification on the original 5-min # candle while using larger bars for breakout detection and stop management. group_size = max(1, params.sim_bar_minutes // 5) raw_post_bars: list[dict] = [] # original 5-min post-ORB bars (for entry fill price) if group_size > 1: orb_ts_raw = _parse_ts(orb_bar["timestamp"]) pre_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) <= orb_ts_raw] post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw] raw_post_bars = list(post_bars) # save before aggregation post_bars = _aggregate_bars(post_bars, group_size) mkt_bars = pre_bars + post_bars market_open = _market_open_ts(date_str) orb_ts = _parse_ts(orb_bar["timestamp"]) timeout_ts = market_open + dt.timedelta(minutes=params.order_timeout_minutes) # Exit time: market close - exit_minutes_before_close market_close = market_open.replace(hour=16, minute=0) exit_target = market_close - dt.timedelta(minutes=params.exit_minutes_before_close) slippage = params.slippage_bps # For long: breakout above ORB high; for short: below ORB low if direction == "long": breakout_level = orb_bar["high"] else: breakout_level = orb_bar["low"] # --- Phase 1: Wait for breakout --- entry_bar: dict | None = None entry_price_raw = 0.0 for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= orb_ts: continue # skip ORB bar and anything before it # Check timeout if ts > timeout_ts: return None # no fill before timeout # Check breakout if direction == "long" and b["high"] >= breakout_level: if group_size > 1: # Signal is only known at the END of the aggregated bar. # Fill at the first 5-min bar's open after the signal bar ends — # the aggregated bar's open (pre-signal) is unavailable to the trader. agg_ts = _parse_ts(b["timestamp"]) fill_raw = next( (r for r in raw_post_bars if _parse_ts(r["timestamp"]) > agg_ts), None ) if fill_raw is None: return None # near close — no next bar available to fill entry_price_raw = max(breakout_level, fill_raw["open"]) entry_bar = fill_raw # entry_ts and entry_time use the fill bar else: entry_price_raw = max(breakout_level, b["open"]) entry_bar = b break elif direction == "short" and b["low"] <= breakout_level: if group_size > 1: agg_ts = _parse_ts(b["timestamp"]) fill_raw = next( (r for r in raw_post_bars if _parse_ts(r["timestamp"]) > agg_ts), None ) if fill_raw is None: return None entry_price_raw = min(breakout_level, fill_raw["open"]) entry_bar = fill_raw else: entry_price_raw = min(breakout_level, b["open"]) entry_bar = b break if entry_bar is None: return None # no breakout fill # --- Position sizing (must happen before stop check so shares are known) --- initial_stop = ( entry_price_raw - stop_distance if direction == "long" else entry_price_raw + stop_distance ) # Use sizing_capital for position sizing (simple/compound mode). # sizing_capital = initial_capital when compound_returns=False, else current equity. cap = sizing_capital if sizing_capital is not None else equity risk_dollars = cap * params.risk_per_trade_pct shares_from_risk = risk_dollars / stop_distance max_shares_by_capital = (cap * params.max_position_pct) / entry_price_raw # GFV / cash account constraint: cannot deploy more than available settled cash. # Unsettled proceeds can buy but not same-day sell; since ORB always exits same day, # only settled cash is usable for new positions. if available_cash is not None: if available_cash <= 0: return None max_shares_by_cash = available_cash / entry_price_raw max_shares_by_capital = min(max_shares_by_capital, max_shares_by_cash) shares = int(min(shares_from_risk, max_shares_by_capital)) # whole shares only if shares <= 0: return None entry_price_filled = ( _apply_slippage_entry(entry_price_raw, slippage) if direction == "long" else _apply_slippage_exit(entry_price_raw, slippage) ) entry_ts = _parse_ts(entry_bar["timestamp"]) # Same-bar stop: breakout AND stop both triggered within the same bar. # Only apply for 5-min bars (group_size == 1). For 30-min (or larger) bars, # we skip same-bar stop detection — the user only checks every N minutes, # so the stop is evaluated at the NEXT bar's open, not within the entry bar. if group_size == 1 and direction == "long" and entry_bar["low"] <= initial_stop: exit_price_raw = initial_stop exit_price = _apply_slippage_exit(exit_price_raw, slippage) pnl_pct = (exit_price - entry_price_filled) / entry_price_filled pnl = pnl_pct * (shares * entry_price_filled) slippage_cost = ( abs(entry_price_filled - entry_price_raw) * shares + abs(exit_price - exit_price_raw) * shares ) return IntradayTrade( date=date_str, ticker=ticker, entry_price=round(entry_price_filled, 4), exit_price=round(exit_price, 4), entry_time=entry_bar["timestamp"], exit_time=entry_bar["timestamp"], shares=round(shares, 4), pnl=round(pnl, 4), pnl_pct=round(pnl_pct, 6), exit_reason="stop_loss", morning_gain_pct=round(gap_pct, 6), slippage_cost=round(slippage_cost, 4), orb_direction=direction, rvol=round(rvol, 3), atr_at_entry=round(atr, 4), r_multiple_at_exit=-1.0, ) if group_size == 1 and direction == "short" and entry_bar["high"] >= initial_stop: exit_price_raw = initial_stop exit_price = _apply_slippage_entry(exit_price_raw, slippage) pnl_pct = (entry_price_filled - exit_price) / entry_price_filled pnl = pnl_pct * (shares * entry_price_filled) slippage_cost = ( abs(entry_price_filled - entry_price_raw) * shares + abs(exit_price - exit_price_raw) * shares ) return IntradayTrade( date=date_str, ticker=ticker, entry_price=round(entry_price_filled, 4), exit_price=round(exit_price, 4), entry_time=entry_bar["timestamp"], exit_time=entry_bar["timestamp"], shares=round(shares, 4), pnl=round(pnl, 4), pnl_pct=round(pnl_pct, 6), exit_reason="stop_loss", morning_gain_pct=round(gap_pct, 6), slippage_cost=round(slippage_cost, 4), orb_direction=direction, rvol=round(rvol, 3), atr_at_entry=round(atr, 4), r_multiple_at_exit=-1.0, ) # --- Phase 2: Manage position --- current_stop = initial_stop trailing_active = False swing_low_window: deque[float] = deque(maxlen=3) peak_price = entry_price_raw # tracks running high (long) or low (short) for ATR trailing exit_price_raw = entry_price_raw exit_time_str = entry_bar["timestamp"] exit_reason = "close" final_r = 0.0 use_atr_trail = params.trailing_stop_atr_multiplier > 0 for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= entry_ts: continue bar_open = b["open"] bar_high = b["high"] bar_low = b["low"] bar_close = b["close"] if direction == "long": # ── Step 1: Stop check FIRST (broker stop order model) ── # Check against PREVIOUS bar's stop level. If bar_low touched # the stop at any point, the broker fills the stop order. if bar_low <= current_stop: # Gap-through: bar opened below stop → fill at bar_open (worse) # Normal: price crossed stop during bar → fill at stop level exit_price_raw = bar_open if bar_open <= current_stop else current_stop exit_time_str = b["timestamp"] exit_reason = "trailing_stop" if trailing_active else "stop_loss" final_r = (exit_price_raw - entry_price_raw) / stop_distance break # ── Step 2: Update peak using actual bar high ── peak_price = max(peak_price, bar_high) # ── Step 3: R-multiple from close (trader sees close to decide adjustments) ── current_r = (bar_close - entry_price_raw) / stop_distance # Move stop to breakeven at configured R-multiple if current_r >= params.breakeven_at_r and current_stop < entry_price_raw: current_stop = entry_price_raw # Activate trailing stop at configured R-multiple if current_r >= params.trailing_at_r: trailing_active = True # ── Step 4: Update trailing stop for NEXT bar ── if trailing_active: if use_atr_trail: candidate_stop = peak_price - atr * params.trailing_stop_atr_multiplier else: swing_low_window.append(bar_close) candidate_stop = max(swing_low_window) if candidate_stop > current_stop: current_stop = candidate_stop else: # short # ── Step 1: Stop check FIRST ── if bar_high >= current_stop: exit_price_raw = bar_open if bar_open >= current_stop else current_stop exit_time_str = b["timestamp"] exit_reason = "trailing_stop" if trailing_active else "stop_loss" final_r = (entry_price_raw - exit_price_raw) / stop_distance break # ── Step 2: Update trough using actual bar low ── peak_price = min(peak_price, bar_low) # ── Step 3: R-multiple from close ── current_r = (entry_price_raw - bar_close) / stop_distance if current_r >= params.breakeven_at_r and current_stop > entry_price_raw: current_stop = entry_price_raw if current_r >= params.trailing_at_r: trailing_active = True # ── Step 4: Update trailing stop for NEXT bar ── if trailing_active: if use_atr_trail: candidate_stop = peak_price + atr * params.trailing_stop_atr_multiplier else: swing_low_window.append(bar_close) candidate_stop = min(swing_low_window) if candidate_stop < current_stop: current_stop = candidate_stop # Time exit if ts >= exit_target: exit_price_raw = b["close"] exit_time_str = b["timestamp"] exit_reason = "close" if direction == "long": final_r = (exit_price_raw - entry_price_raw) / stop_distance else: final_r = (entry_price_raw - exit_price_raw) / stop_distance break # Running exit (last bar before exit time) exit_price_raw = b["close"] exit_time_str = b["timestamp"] if direction == "long": final_r = (exit_price_raw - entry_price_raw) / stop_distance else: final_r = (entry_price_raw - exit_price_raw) / stop_distance # Apply slippage to exit exit_price = ( _apply_slippage_exit(exit_price_raw, slippage) if direction == "long" else _apply_slippage_entry(exit_price_raw, slippage) ) if direction == "long": pnl_pct = (exit_price - entry_price_filled) / entry_price_filled else: pnl_pct = (entry_price_filled - exit_price) / entry_price_filled pnl = pnl_pct * (shares * entry_price_filled) entry_slippage = abs(entry_price_filled - entry_price_raw) * shares exit_slippage = abs(exit_price - exit_price_raw) * shares slippage_cost = entry_slippage + exit_slippage return IntradayTrade( date=date_str, ticker=ticker, entry_price=round(entry_price_filled, 4), exit_price=round(exit_price, 4), entry_time=entry_bar["timestamp"], exit_time=exit_time_str, shares=round(shares, 4), pnl=round(pnl, 4), pnl_pct=round(pnl_pct, 6), exit_reason=exit_reason, morning_gain_pct=round(gap_pct, 6), # reuse field for gap% slippage_cost=round(slippage_cost, 4), orb_direction=direction, rvol=round(rvol, 3), atr_at_entry=round(atr, 4), r_multiple_at_exit=round(final_r, 3), ) # ── Day Simulation ───────────────────────────────────────────────────────── def simulate_orb_day( bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], equity: float, blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, available_cash: float | None = None, sizing_capital: float | None = None, ) -> DayResult: """Simulate one full trading day using the ORB strategy. 1. SPY regime check (daily gap from enrichment) — skip bad market days 2. compute_orb_candidates — filter and rank candidates 3. If fewer than min_candidates_to_trade → skip day 4. For each candidate: simulate_orb_trade 5. Apply daily loss limit and max-stops kill switch Args: bars_by_ticker: {ticker: [bars]} for this day. date_str: Trading date 'YYYY-MM-DD'. params: ORB strategy parameters. enrichment: Pre-computed features from enrich_daily_bars(). equity: Current portfolio equity (for risk-based sizing). blacklisted_tickers: Tickers in cooldown. spy_bars: SPY bars (unused — regime check now uses enrichment). Returns: DayResult compatible with compute_metrics(). """ result = DayResult(date=date_str) # Market regime check: index ETF daily gap (lookahead-free via enrichment) # Uses prev_close and today_open from enrichment, which are computed from # prior daily bars only — no intraday data needed. if params.market_regime_spy_threshold is not None: regime_ticker = getattr(params, "market_regime_ticker", None) or "SPY" regime_enrich = enrichment.get(regime_ticker, {}).get(date_str, {}) regime_prev_close = regime_enrich.get("prev_close") regime_today_open = regime_enrich.get("today_open") if regime_prev_close and regime_today_open and regime_prev_close > 0: regime_gap = (regime_today_open - regime_prev_close) / regime_prev_close if regime_gap < params.market_regime_spy_threshold: return result # skip bearish-open days # Candidate breadth filter: skip day if too few intraday tickers gapped up. # More robust than single-ETF regime check — measures actual candidate pool sentiment. min_breadth = getattr(params, "min_candidate_breadth", None) if min_breadth is not None: pos_gap_count = 0 total_with_data = 0 for ticker in bars_by_ticker: t_enrich = enrichment.get(ticker, {}).get(date_str, {}) prev_c = t_enrich.get("prev_close") today_o = t_enrich.get("today_open") if prev_c and today_o and prev_c > 0: total_with_data += 1 if today_o > prev_c: pos_gap_count += 1 if total_with_data > 0 and (pos_gap_count / total_with_data) < min_breadth: return result # skip low-breadth day candidates = compute_orb_candidates( bars_by_ticker, date_str, params, enrichment, blacklisted_tickers=blacklisted_tickers, spy_bars=None, # handled above via enrichment ) result.candidates_found = len(candidates) if len(candidates) < params.min_candidates_to_trade: return result # ── Pass 1: Find breakout times for all candidates ── # Determines chronological order BEFORE allocating capital, so earlier breakouts # get capital first regardless of composite score ranking. timed_candidates: list[tuple[dt.datetime, dict, str]] = [] for cand in candidates: direction_str = ( "long" if cand["direction"] == "bullish" else "short" if cand["direction"] == "bearish" else cand["direction"] ) breakout_ts = _find_breakout_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if breakout_ts is not None: timed_candidates.append((breakout_ts, cand, direction_str)) # Sort by breakout time ascending (earliest fills first) timed_candidates.sort(key=lambda x: x[0]) # ── Pass 2: Simulate in chronological order with capital constraints ── sizing_cap = sizing_capital if sizing_capital is not None else equity daily_loss_limit = sizing_cap * params.daily_max_loss_pct remaining_cash = available_cash # None → no constraint (settlement_days=0) result.available_cash_start = available_cash if available_cash is not None else equity skipped_cash = 0 for idx, (breakout_ts, cand, direction_str) in enumerate(timed_candidates): # Kill switch: only count losses from trades that have ALREADY EXITED # before this breakout time (exit-time-aware accounting). # A 09:35 trade that exits at 15:55 for a loss must not block a 10:00 # breakout — the loss hasn't been realized yet when the 10:00 order fires. realized_loss = sum( abs(t.pnl) for t in result.trades if t.pnl < 0 and _parse_ts(t.exit_time) <= breakout_ts ) realized_stops = sum( 1 for t in result.trades if t.exit_reason == "stop_loss" and t.r_multiple_at_exit is not None and t.r_multiple_at_exit <= -0.8 and _parse_ts(t.exit_time) <= breakout_ts ) if realized_loss >= daily_loss_limit: break if realized_stops >= params.max_stops_per_day: break # Cash exhaustion if remaining_cash is not None and remaining_cash <= 0: skipped_cash += len(timed_candidates) - idx break trade = simulate_orb_trade( mkt_bars=cand["mkt_bars"], orb_bar=cand["orb_bar"], direction=direction_str, atr=cand["atr"], rvol=cand["rvol"], gap_pct=cand["gap_pct"], params=params, equity=equity, date_str=date_str, ticker=cand["ticker"], available_cash=remaining_cash, sizing_capital=sizing_capital, ) if trade is None: continue result.trades.append(trade) result.daily_pnl += trade.pnl # Deduct deployed capital from remaining settled cash if remaining_cash is not None: remaining_cash -= trade.shares * trade.entry_price result.skipped_insufficient_cash = skipped_cash result.capital_deployed = sum(t.shares * t.entry_price for t in result.trades) # Note: daily_return_pct is set by run_orb_simulation (portfolio-level: PnL/equity). # Default 0.0 is correct for no-trade days. return result # ── Full Backtest Simulation ─────────────────────────────────────────────── def run_orb_simulation( all_intraday: dict[str, dict[str, list[dict]]], trading_days: list[str], params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], ) -> list[DayResult]: """Run the full ORB backtest simulation across all trading days. Key differences from run_simulation() (momentum): - Uses compounding equity (position sizing depends on current equity) - ATR-based stop loss (dynamic, not fixed %) - Breakout entry (conditional, can miss) - RVOL + gap composite ranking Pure computation — no API calls, no disk I/O. Safe to call repeatedly with different params for sweep mode. Args: all_intraday: {date: {ticker: [bars]}} — pre-loaded intraday data. trading_days: Ordered list of dates to simulate. params: ORB strategy parameters. enrichment: {ticker: {date: features}} from enrich_daily_bars(). Returns: List of DayResult objects (one per day that had intraday data). Compatible with compute_metrics() and all report formatters. """ results: list[DayResult] = [] equity = params.initial_capital # Ticker cooldown tracker ticker_last_traded: dict[str, dt.date] = {} # GFV / settlement tracking (only active when settlement_days > 0) # settled_cash: funds available for new day-trade positions (GFV-safe) # pending_settlements: (settlement_date_str, amount) — proceeds awaiting settlement settlement_enabled = params.settlement_days > 0 settled_cash = params.initial_capital pending_settlements: list[tuple[str, float]] = [] for day_idx, date_str in enumerate(trading_days): bars_by_ticker = all_intraday.get(date_str) 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 # Step 1: Move proceeds that have reached their settlement date into settled_cash if settlement_enabled: still_pending = [] for settle_date, amount in pending_settlements: if settle_date <= date_str: settled_cash += amount else: still_pending.append((settle_date, amount)) pending_settlements = still_pending # Build blacklist from cooldown blacklisted: set[str] = set() if params.ticker_cooldown_days > 0: current_date = dt.date.fromisoformat(date_str) for ticker, last_dt in ticker_last_traded.items(): if (current_date - last_dt).days <= params.ticker_cooldown_days: blacklisted.add(ticker) # Extract SPY bars for regime filter spy_bars = ( bars_by_ticker.get("SPY") if params.market_regime_spy_threshold is not None else None ) available_cash = settled_cash if settlement_enabled else None # Simple vs compound sizing: fixed initial_capital vs growing equity sizing_capital = None if params.compound_returns else params.initial_capital day_result = simulate_orb_day( bars_by_ticker, date_str, params, enrichment, equity=equity, blacklisted_tickers=blacklisted if blacklisted else None, spy_bars=spy_bars, available_cash=available_cash, sizing_capital=sizing_capital, ) # Override daily_return_pct with portfolio-level return (PnL / equity at start of day). # simulate_orb_day uses deployed capital as denominator — that inflates returns. # Portfolio return properly reflects capital sitting idle on low-activity days. if equity > 0: day_result.daily_return_pct = day_result.daily_pnl / equity results.append(day_result) # Update equity equity += day_result.daily_pnl equity = max(equity, 1.0) # prevent zero/negative equity from crashing # Step 2: After the day, deduct deployed capital and schedule proceeds for settlement if settlement_enabled: deployed = day_result.capital_deployed settled_cash -= deployed # cash is now deployed (unsettled until proceeds settle) # Sale proceeds = cost basis + P&L; schedule settlement T+N trading days out proceeds = deployed + day_result.daily_pnl if proceeds > 0: settle_idx = day_idx + params.settlement_days if settle_idx < len(trading_days): pending_settlements.append((trading_days[settle_idx], proceeds)) else: # Settlement date falls beyond simulation window; credit immediately settled_cash += proceeds if params.ticker_cooldown_days > 0: current_date = dt.date.fromisoformat(date_str) for trade in day_result.trades: ticker_last_traded[trade.ticker] = current_date return results