You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1029 lines
40 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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