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.

2655 lines
117 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 dataclasses import dataclass, field
from typing import Callable
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")
_PREMARKET_OPEN = dt.time(4, 0)
_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
def _compute_running_vwap(bars: list[dict], up_to_ts: dt.datetime) -> float | None:
"""Compute running VWAP from market open up to (and including) the given timestamp.
Uses typical price = (high + low + close) / 3 for each bar.
Returns None if no bars with volume are found.
"""
cum_pv = 0.0
cum_vol = 0.0
for b in bars:
ts = _parse_ts(b["timestamp"])
if ts > up_to_ts:
break
vol = float(b.get("volume", 0) or 0)
if vol <= 0:
continue
typical = (float(b["high"]) + float(b["low"]) + float(b["close"])) / 3.0
cum_pv += typical * vol
cum_vol += vol
if cum_vol <= 0:
return None
return cum_pv / cum_vol
def _linear_scaler(
value: float | None,
low: float | None,
high: float | None,
floor: float = 1.0,
*,
invert: bool = False,
) -> float:
"""Linear interpolation scaler (same logic as simulator._linear_scaler)."""
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 _orb_vix_size_scaler(vix_value: float | None, params: ORBStrategyParams) -> float:
"""Position-size scaler based on VIX level for ORB strategy."""
return _linear_scaler(
vix_value,
params.vix_size_scale_low,
params.vix_size_scale_high,
params.vix_size_scale_min,
)
def _orb_entropy_size_scaler(entropy_20d: float | None, params: ORBStrategyParams) -> float:
"""Per-candidate size scaler based on entropy_20d (from momentum strategy).
Higher entropy → lower size. Disabled when entropy_size_scale_low is None."""
return _linear_scaler(
entropy_20d,
getattr(params, "entropy_size_scale_low", None),
getattr(params, "entropy_size_scale_high", None),
getattr(params, "entropy_size_scale_min", 0.6),
)
@dataclass
class ORBSimulationState:
"""Rolling ORB simulation state for chunked backtests."""
equity: float
ticker_last_traded: dict[str, str] = field(default_factory=dict)
settled_cash: float | None = None
pending_settlements: list[tuple[str, float]] = field(default_factory=list)
recent_daily_pnl: list[float] = field(default_factory=list)
"""Recent daily PnL history for cross-chunk rolling loss filter continuity."""
# ── 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
)
def _effective_orb_engine_family(params: ORBStrategyParams) -> str:
family = getattr(params, "engine_family", "quality_breakout") or "quality_breakout"
if family not in {
"classic_breakout",
"quality_breakout",
"compression_breakout",
"gainers_leader",
"leader_followthrough",
"stocks_in_play_dual_regime",
"orb_pullback_v1",
"vwap_reclaim_v1",
"hypergap_failure_v1",
}:
return "quality_breakout"
return family
def _compute_premarket_dollar_vol(all_bars: list[dict], date_str: str) -> float:
"""Premarket dollar volume proxy from 04:00-09:30 ET bars on the trade date."""
total = 0.0
for bar in all_bars:
ts = _parse_ts(bar["timestamp"]).astimezone(_ET)
if ts.date().isoformat() != date_str:
continue
if not (_PREMARKET_OPEN <= ts.time() < _MARKET_OPEN):
continue
price = bar.get("close") or bar.get("open") or 0.0
volume = bar.get("volume", 0) or 0.0
if price > 0 and volume > 0:
total += price * volume
return total
# ── 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,
ticker_sectors: dict[str, str] | None = None,
_stats_out: dict | None = None,
overlay_tickers: set[str] | 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:309: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)
engine_family = _effective_orb_engine_family(params)
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"])
_late_diff = abs((first_bar_ts - market_open).total_seconds() / 60)
if _late_diff > 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),
}
orb_vwap = None
orb_vwap_num = 0.0
orb_vwap_den = 0.0
for bar in orb_bars_raw:
bar_vwap = bar.get("vwap")
bar_volume = float(bar.get("volume", 0) or 0)
if bar_vwap is None or bar_volume <= 0:
continue
orb_vwap_num += float(bar_vwap) * bar_volume
orb_vwap_den += bar_volume
if orb_vwap_den > 0:
orb_vwap = orb_vwap_num / orb_vwap_den
# 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)
followthrough_engine = engine_family in {
"gainers_leader",
"leader_followthrough",
"stocks_in_play_dual_regime",
"orb_pullback_v1",
"hypergap_failure_v1",
}
allow_doji_breakout = (
followthrough_engine
and bool(getattr(params, "allow_doji_breakout", False))
)
allow_red_to_green_breakout = (
followthrough_engine
and bool(getattr(params, "allow_red_to_green_breakout", False))
)
if params.entry_direction == "long_only":
if direction == "bearish" and not allow_red_to_green_breakout:
_f_dir += 1
continue
if direction == "bearish" and allow_red_to_green_breakout:
direction = "bullish"
if direction == "doji" and not allow_doji_breakout:
_f_dir += 1
continue
if direction == "doji" and allow_doji_breakout:
direction = "bullish"
elif params.entry_direction == "short_only":
# Gap failure: only trade bearish ORB candles (gap held, then sold off in first bar)
if direction in ("bullish", "doji"):
_f_dir += 1
continue
elif 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")
premarket_dollar_vol = _compute_premarket_dollar_vol(all_bars, date_str)
# ATR filter
if atr is None or atr < params.min_atr_14:
_f_atr += 1
continue
if prev_close and prev_close > 0:
atr_ratio = atr / prev_close
if params.min_atr_pct is not None and atr_ratio < params.min_atr_pct:
_f_atr += 1
continue
if params.max_atr_pct is not None and atr_ratio > params.max_atr_pct:
_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
# Leader/liquid overlay tickers bypass gapper-specific filters (rvol, gap, premarket).
# They have their own quality gates applied upstream in the overlay function.
is_overlay = overlay_tickers is not None and ticker in overlay_tickers
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 not is_overlay and params.min_rvol is not None and (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
abs_gap_pct = abs(gap_pct)
used_small_gap_attention_override = False
if not is_overlay:
if params.min_abs_gap_pct is not None and abs_gap_pct < params.min_abs_gap_pct:
small_gap_attention_override = (
followthrough_engine
and getattr(params, "small_gap_attention_override_premarket_dollar_vol", None) is not None
and premarket_dollar_vol
>= float(getattr(params, "small_gap_attention_override_premarket_dollar_vol"))
)
if small_gap_attention_override:
small_gap_rvol_min = getattr(params, "small_gap_attention_override_rvol", None)
if small_gap_rvol_min is not None and (rvol is None or rvol < float(small_gap_rvol_min)):
_f_gap += 1
continue
used_small_gap_attention_override = True
else:
_f_gap += 1
continue
# Optional max gap filter. Useful for classical ORB continuation, but typically
# disabled in gainers/leader style engines that explicitly seek outsized movers.
if params.max_gap_pct is not None and gap_pct > params.max_gap_pct:
_f_gap += 1
continue
if (
params.min_premarket_dollar_vol is not None
and premarket_dollar_vol < params.min_premarket_dollar_vol
):
_f_dolvol += 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)
close_location = (orb_close - orb_bar["low"]) / orb_range
else:
body_ratio = 0.0
close_location = 0.5
# ORB range quality filter: skip if range is too narrow or too wide relative to ATR
if atr > 0 and orb_range > 0:
orb_range_atr_ratio = orb_range / atr
if params.orb_range_atr_min is not None and orb_range_atr_ratio < params.orb_range_atr_min:
_f_dir += 1
continue
if params.orb_range_atr_max is not None and orb_range_atr_ratio > params.orb_range_atr_max:
_f_dir += 1
continue
if engine_family != "classic_breakout" and body_ratio < getattr(params, "min_body_ratio", 0.0):
_f_dir += 1
continue
if engine_family == "leader_followthrough" and close_location < getattr(params, "min_close_location", 0.0):
_f_dir += 1
continue
# 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
entropy_20d = ticker_enrich.get("entropy_20d")
obv_slope_20 = ticker_enrich.get("obv_slope_20")
obv_slope_5 = ticker_enrich.get("obv_slope_5")
atr_ratio_10_60 = ticker_enrich.get("atr_ratio_10_60")
range_compression_10_60 = ticker_enrich.get("range_compression_10_60")
gap_zscore_20d = ticker_enrich.get("gap_zscore_20d")
event_flag = bool(ticker_enrich.get("event_flag"))
raw_event_types = ticker_enrich.get("event_types") or []
event_types = [str(v) for v in raw_event_types if str(v)]
event_score = float(ticker_enrich.get("event_score") or 0.0)
attention_wiki_spike_10d = ticker_enrich.get("attention_wiki_spike_10d")
attention_wiki_zscore_20d = ticker_enrich.get("attention_wiki_zscore_20d")
attention_article_count_3d = int(ticker_enrich.get("attention_article_count_3d") or 0)
attention_us_article_count_3d = int(ticker_enrich.get("attention_us_article_count_3d") or 0)
attention_resolver_confidence = float(ticker_enrich.get("attention_resolver_confidence") or 0.0)
allowed_event_types = {str(v).lower() for v in getattr(params, "allowed_event_types", []) if str(v)}
if allowed_event_types and event_flag:
if not any(str(event_type).lower() in allowed_event_types for event_type in event_types):
event_flag = False
event_score = 0.0
event_types = []
if engine_family == "compression_breakout":
min_entropy = getattr(params, "min_entropy", None)
max_entropy = getattr(params, "max_entropy", None)
compression_ratio_max = getattr(params, "compression_ratio_max", None)
if min_entropy is not None and (entropy_20d is None or entropy_20d < min_entropy):
_f_rvol += 1
continue
if max_entropy is not None and (entropy_20d is None or entropy_20d > max_entropy):
_f_rvol += 1
continue
if compression_ratio_max is not None and (
range_compression_10_60 is None or range_compression_10_60 > compression_ratio_max
):
_f_rvol += 1
continue
if engine_family == "gainers_leader":
max_gzs = getattr(params, "max_gap_zscore_20d", None)
if max_gzs is not None and (gap_zscore_20d is None or gap_zscore_20d > max_gzs):
_f_rvol += 1
continue
min_obs = getattr(params, "min_obv_slope_20d", None)
if min_obs is not None and (obv_slope_20 is None or obv_slope_20 < min_obs):
_f_rvol += 1
continue
if engine_family == "stocks_in_play_dual_regime":
if getattr(params, "require_event_flag", False) and not event_flag:
_f_gap += 1
continue
if (
getattr(params, "attention_min_wiki_spike_10d", None) is not None
and (
attention_wiki_spike_10d is None
or attention_wiki_spike_10d < float(getattr(params, "attention_min_wiki_spike_10d"))
)
):
_f_gap += 1
continue
if (
getattr(params, "attention_min_wiki_zscore_20d", None) is not None
and (
attention_wiki_zscore_20d is None
or attention_wiki_zscore_20d < float(getattr(params, "attention_min_wiki_zscore_20d"))
)
):
_f_gap += 1
continue
if (
getattr(params, "attention_min_article_count_3d", None) is not None
and attention_article_count_3d < int(getattr(params, "attention_min_article_count_3d"))
):
_f_gap += 1
continue
if (
getattr(params, "attention_min_us_article_count_3d", None) is not None
and attention_us_article_count_3d < int(getattr(params, "attention_min_us_article_count_3d"))
):
_f_gap += 1
continue
if (
getattr(params, "attention_min_resolver_confidence", None) is not None
and attention_resolver_confidence < float(getattr(params, "attention_min_resolver_confidence"))
):
_f_gap += 1
continue
if direction == "bullish":
if close_location < getattr(params, "min_close_location", 0.0):
_f_dir += 1
continue
if (
getattr(params, "require_vwap_confirmation", False)
and orb_vwap is not None
and orb_close < orb_vwap
):
_f_dir += 1
continue
elif direction == "bearish":
if not getattr(params, "allow_failed_orb_short", False):
_f_dir += 1
continue
if gap_pct <= 0:
_f_gap += 1
continue
if close_location > getattr(params, "max_close_location_short", 1.0):
_f_dir += 1
continue
if (
getattr(params, "require_vwap_confirmation", False)
and orb_vwap is not None
and orb_close > orb_vwap
):
_f_dir += 1
continue
else:
_f_dir += 1
continue
raw_candidates.append({
"ticker": ticker,
"sector": (ticker_sectors or {}).get(ticker, "UNKNOWN"),
"orb_bar": orb_bar,
"direction": direction,
"rvol": rvol,
"gap_pct": gap_pct,
"abs_gap_pct": abs_gap_pct,
"atr": atr,
"first_bar_dollar_vol": first_bar_dollar_vol,
"premarket_dollar_vol": premarket_dollar_vol,
"body_ratio": body_ratio,
"close_location": close_location,
"momentum": momentum,
"entropy_20d": entropy_20d or 0.0,
"obv_slope_20": obv_slope_20 if obv_slope_20 is not None else 0.0,
"obv_slope_5": obv_slope_5 if obv_slope_5 is not None else 0.0,
"atr_ratio_10_60": atr_ratio_10_60 or 0.0,
"range_compression_10_60": range_compression_10_60,
"gap_zscore_20d": gap_zscore_20d or 0.0,
"event_flag": event_flag,
"event_types": event_types,
"event_score": event_score,
"attention_wiki_spike_10d": attention_wiki_spike_10d or 0.0,
"attention_article_count_3d": attention_article_count_3d,
"attention_us_article_count_3d": attention_us_article_count_3d,
"attention_resolver_confidence": attention_resolver_confidence,
"orb_vwap": orb_vwap,
"used_small_gap_attention_override": used_small_gap_attention_override,
"orb_return": ((orb_close - orb_open_price) / orb_open_price) if orb_open_price > 0 else 0.0,
"mkt_bars": mkt_bars,
})
_filter_stats = {
"gap": _f_gap, "rvol": _f_rvol, "atr": _f_atr, "dolvol": _f_dolvol,
"dir": _f_dir, "no_bars": _f_no_bars, "late": _f_late, "price": _f_price,
}
if _stats_out is not None:
_stats_out.update(_filter_stats)
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 []
if engine_family == "stocks_in_play_dual_regime":
sector_returns: dict[tuple[str, str], list[float]] = {}
for cand in raw_candidates:
key = (str(cand.get("sector") or "UNKNOWN"), str(cand["direction"]))
sector_returns.setdefault(key, []).append(float(cand.get("orb_return") or 0.0))
filtered_candidates: list[dict] = []
for cand in raw_candidates:
key = (str(cand.get("sector") or "UNKNOWN"), str(cand["direction"]))
sector_avg = (
sum(sector_returns.get(key, [0.0])) / len(sector_returns.get(key, [0.0]))
if sector_returns.get(key)
else 0.0
)
sector_relative_strength = float(cand.get("orb_return") or 0.0) - sector_avg
cand["sector_relative_strength"] = sector_relative_strength
if (
cand["direction"] == "bullish"
and getattr(params, "min_sector_relative_strength", None) is not None
and sector_relative_strength < float(getattr(params, "min_sector_relative_strength"))
):
continue
filtered_candidates.append(cand)
raw_candidates = filtered_candidates
if not raw_candidates:
return []
# Normalize and score
rvol_vals = [c["rvol"] for c in raw_candidates]
if engine_family in {"gainers_leader", "leader_followthrough", "hypergap_failure_v1"}:
gap_vals = [c["abs_gap_pct"] for c in raw_candidates]
else:
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]
premarket_dolvol_vals = [c["premarket_dollar_vol"] for c in raw_candidates]
body_vals = [c["body_ratio"] for c in raw_candidates]
close_location_vals = [c["close_location"] for c in raw_candidates]
momentum_vals = [max(c["momentum"], 0.0) for c in raw_candidates] # only reward aligned momentum
event_vals = [c["event_score"] for c in raw_candidates]
attention_wiki_vals = [c["attention_wiki_spike_10d"] for c in raw_candidates]
attention_news_vals = [
max(c["attention_article_count_3d"], c["attention_us_article_count_3d"])
for c in raw_candidates
]
entropy_vals = [c["entropy_20d"] for c in raw_candidates]
obv_slope_vals = [c["obv_slope_20"] for c in raw_candidates]
obv_slope5_vals = [c["obv_slope_5"] for c in raw_candidates]
atr_ratio_vals = [c["atr_ratio_10_60"] for c in raw_candidates]
gap_zscore_vals = [c["gap_zscore_20d"] for c in raw_candidates]
structure_vals = [
c["close_location"] if c["direction"] == "bullish" else 1.0 - c["close_location"]
for c in raw_candidates
]
norm_rvol = _normalize_scores(rvol_vals)
norm_gap = _normalize_scores(gap_vals)
norm_dolvol = _normalize_scores(dolvol_vals)
norm_premarket_dolvol = _normalize_scores(premarket_dolvol_vals)
norm_body = _normalize_scores(body_vals)
norm_close_location = _normalize_scores(close_location_vals)
norm_structure = _normalize_scores(structure_vals)
norm_momentum = _normalize_scores(momentum_vals)
norm_event = _normalize_scores(event_vals)
norm_attention_wiki = _normalize_scores(attention_wiki_vals)
norm_attention_news = _normalize_scores(attention_news_vals)
norm_entropy = _normalize_scores(entropy_vals)
norm_obv_slope = _normalize_scores(obv_slope_vals)
norm_obv_slope5 = _normalize_scores(obv_slope5_vals)
norm_atr_ratio = _normalize_scores(atr_ratio_vals)
norm_gap_zscore = _normalize_scores(gap_zscore_vals)
for i, cand in enumerate(raw_candidates):
score = (
norm_rvol[i] * params.weight_rvol
+ norm_gap[i] * params.weight_gap
+ norm_dolvol[i] * params.weight_dollar_vol
+ norm_premarket_dolvol[i] * params.weight_premarket_dollar_vol
)
if engine_family != "classic_breakout":
score += norm_body[i] * params.weight_body_ratio
score += norm_momentum[i] * params.weight_momentum
if engine_family in {
"gainers_leader", "leader_followthrough", "stocks_in_play_dual_regime",
"hypergap_failure_v1",
}:
score += norm_structure[i] * params.weight_close_location
score += norm_gap_zscore[i] * params.weight_gap_zscore
if engine_family == "stocks_in_play_dual_regime":
score += norm_event[i] * params.weight_event_catalyst
score += norm_attention_wiki[i] * params.weight_attention_wiki
score += norm_attention_news[i] * params.weight_attention_news
if engine_family in {
"compression_breakout", "gainers_leader", "leader_followthrough",
"stocks_in_play_dual_regime", "hypergap_failure_v1",
}:
score += norm_entropy[i] * params.weight_entropy
score += norm_obv_slope[i] * params.weight_obv_slope
score += norm_obv_slope5[i] * params.weight_obv_slope_5
score += norm_atr_ratio[i] * params.weight_atr_ratio
if engine_family == "compression_breakout":
# gap_zscore only added here for compression_breakout;
# gainers_leader/leader_followthrough already add it above
score += norm_gap_zscore[i] * params.weight_gap_zscore
cand["score"] = score
# Sort by score descending, take top N
raw_candidates.sort(key=lambda c: c["score"], reverse=True)
max_per_sector = getattr(params, "max_candidates_per_sector", None)
max_small_gap_attention = getattr(params, "max_small_gap_attention_candidates", None)
if (
(max_per_sector is not None and max_per_sector > 0)
or (max_small_gap_attention is not None and max_small_gap_attention >= 0)
):
selected: list[dict] = []
sector_counts: dict[str, int] = {}
small_gap_attention_count = 0
for cand in raw_candidates:
sector = str(cand.get("sector") or "UNKNOWN")
if max_per_sector is not None and max_per_sector > 0:
if sector_counts.get(sector, 0) >= max_per_sector:
continue
if (
max_small_gap_attention is not None
and max_small_gap_attention >= 0
and cand.get("used_small_gap_attention_override")
):
if small_gap_attention_count >= max_small_gap_attention:
continue
selected.append(cand)
if max_per_sector is not None and max_per_sector > 0:
sector_counts[sector] = sector_counts.get(sector, 0) + 1
if cand.get("used_small_gap_attention_override"):
small_gap_attention_count += 1
if len(selected) >= params.max_candidates:
break
return selected
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
def _find_momentum_confirm_time(
mkt_bars: list[dict],
orb_bar: dict,
params: ORBStrategyParams,
) -> tuple[dt.datetime, float] | None:
"""Find the momentum confirmation entry time for a candidate (hybrid dual-trigger).
Confirmation logic:
- Evaluate the first two post-ORB bars (09:40 and 09:45 close for 5-min bars).
- Morning gain: (close_0945 - open) / open must be in [momo_min_morning_gain_pct, momo_max_morning_gain_pct].
- Confirmation return: (close_0945 - close_0940) / close_0940 >= momo_min_confirmation_return_pct.
- Window: confirmation bar must end within momo_confirm_window_minutes after ORB end (09:35).
Returns (entry_timestamp, entry_price) or None if conditions not met.
Only active when params.dual_trigger_enabled is True.
"""
if not getattr(params, "dual_trigger_enabled", False):
return None
if not mkt_bars:
return None
open_price = mkt_bars[0]["open"]
if not open_price:
return None
orb_ts_raw = _parse_ts(orb_bar["timestamp"])
window_end = orb_ts_raw + dt.timedelta(minutes=params.momo_confirm_window_minutes)
post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw]
if len(post_bars) < 2:
return None
bar_0940 = post_bars[0]
bar_0945 = post_bars[1]
confirm_ts = _parse_ts(bar_0945["timestamp"])
if confirm_ts > window_end:
return None
close_0940 = bar_0940.get("close") or 0.0
close_0945 = bar_0945.get("close") or 0.0
if close_0940 <= 0 or close_0945 <= 0:
return None
morning_gain = (close_0945 - open_price) / open_price
if morning_gain < params.momo_min_morning_gain_pct:
return None
if morning_gain > params.momo_max_morning_gain_pct:
return None
confirm_return = (close_0945 - close_0940) / close_0940
if confirm_return < params.momo_min_confirmation_return_pct:
return None
return (confirm_ts, float(close_0945))
# ── 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,
score_rank_pct: float = 0.0,
prev_close: float | None = None,
entry_after_ts: dt.datetime | None = None,
spy_bars: list[dict] | None = None,
is_soft_day: bool = False,
trigger_type: str = "orb",
forced_entry_price: float | None = None,
forced_entry_bar: dict | 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
- trigger_type="momentum_confirm": skip breakout loop; enter at forced_entry_price/bar
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
effective_atr_stop_mult = (
params.atr_stop_multiplier_weak
if (is_soft_day and params.atr_stop_multiplier_weak is not None)
else params.atr_stop_multiplier
)
effective_breakeven_at_r = (
params.breakeven_at_r_weak
if (is_soft_day and params.breakeven_at_r_weak is not None)
else params.breakeven_at_r
)
stop_distance = atr * effective_atr_stop_mult
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 (or use forced momentum-confirm entry) ---
entry_bar: dict | None = None
entry_price_raw = 0.0
_sim_engine_family = _effective_orb_engine_family(params)
if trigger_type == "momentum_confirm" and forced_entry_price is not None and forced_entry_bar is not None:
# Momentum-confirmation path: entry price and bar pre-determined by the caller.
# Skip the breakout loop entirely. Stop/trail logic is unchanged.
entry_price_raw = forced_entry_price
entry_bar = forced_entry_bar
elif _sim_engine_family == "vwap_reclaim_v1":
# VWAP Reclaim path: skip ORB breakout, scan for first bar closing above session VWAP
# in the late-morning window [window_start_min, window_end_min] from market open.
_vr_start = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_start_min)
_vr_end = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_end_min)
# Optional: require prior dip below VWAP (true reclaim, not a drift-above entry)
if params.vwap_reclaim_require_prior_dip:
_had_dip = False
for b in mkt_bars:
ts = _parse_ts(b["timestamp"])
if ts >= _vr_start:
break
_vwap_pre = _compute_running_vwap(mkt_bars, ts)
if _vwap_pre is None:
continue
if direction == "long" and b["close"] < _vwap_pre:
_had_dip = True
break
elif direction == "short" and b["close"] > _vwap_pre:
_had_dip = True
break
if not _had_dip:
return None # no prior dip — not a true VWAP reclaim setup
for b in mkt_bars:
ts = _parse_ts(b["timestamp"])
if ts < _vr_start:
continue
if ts >= _vr_end or ts >= exit_target:
break
_vwap = _compute_running_vwap(mkt_bars, ts)
if _vwap is None:
continue
_clearance = params.vwap_reclaim_min_clearance_pct
if direction == "long" and b["close"] > _vwap * (1 + _clearance):
entry_price_raw = b["close"]
entry_bar = b
break
elif direction == "short" and b["close"] < _vwap * (1 - _clearance):
entry_price_raw = b["close"]
entry_bar = b
break
else:
for b in mkt_bars:
ts = _parse_ts(b["timestamp"])
if ts <= orb_ts:
continue # skip ORB bar and anything before it
# Re-entry mode: skip bars before the previous exit
if entry_after_ts is not None and ts <= entry_after_ts:
continue
# Check timeout (disabled for re-entries — they happen later in the day)
if entry_after_ts is None and ts > timeout_ts:
return None # no fill before timeout
# Check breakout
# entry_on_bar_close: require bar CLOSE above/below level (filters wick-only touches)
use_bar_close_entry = params.entry_on_bar_close
if direction == "long":
bar_triggered = (
b["close"] >= breakout_level if use_bar_close_entry
else b["high"] >= breakout_level
)
else:
bar_triggered = (
b["close"] <= breakout_level if use_bar_close_entry
else b["low"] <= breakout_level
)
if bar_triggered and direction == "long":
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
elif use_bar_close_entry:
# Enter at bar close — trader waits for bar to complete
entry_price_raw = b["close"]
entry_bar = b
else:
entry_price_raw = max(breakout_level, b["open"])
entry_bar = b
break
elif bar_triggered and direction == "short":
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
elif use_bar_close_entry:
entry_price_raw = b["close"]
entry_bar = b
else:
entry_price_raw = min(breakout_level, b["open"])
entry_bar = b
break
if entry_bar is None:
return None # no breakout fill
# --- Pullback continuation entry ---
# Instead of entering on the breakout, wait for a pullback and continuation.
# 1. Record the breakout, then look for a bar that retraces from the post-breakout peak
# 2. After the pullback, look for continuation (new bar making progress)
# 3. Enter at the continuation bar close with stop at pullback extreme
if params.pullback_entry:
initial_breakout_bar = entry_bar
initial_breakout_ts = _parse_ts(initial_breakout_bar["timestamp"])
# Reset entry — we'll find a better one after pullback
entry_bar = None
entry_price_raw = 0.0
post_breakout_peak = breakout_level
pullback_extreme = breakout_level # lowest point during pullback (long)
pullback_found = False
bars_after_breakout = 0
# orb_pullback_v1: volume tracking for contraction check
_impulse_vols: list[float] = []
_pullback_vols: list[float] = []
# orb_pullback_v1: impulse window cutoff — peak must form by X min from open
_impulse_window_cutoff: dt.datetime | None = None
if params.pullback_impulse_window_end_min is not None:
_tdate = initial_breakout_ts.astimezone(_ET).date()
_impulse_window_cutoff = dt.datetime(
_tdate.year, _tdate.month, _tdate.day, 9, 30, tzinfo=_ET
) + dt.timedelta(minutes=params.pullback_impulse_window_end_min)
for b in mkt_bars:
ts = _parse_ts(b["timestamp"])
if ts <= initial_breakout_ts:
continue
if ts >= exit_target:
break # too late in the day
bars_after_breakout += 1
if bars_after_breakout > params.pullback_max_bars:
break
if direction == "long":
post_breakout_peak = max(post_breakout_peak, b["high"])
move_from_breakout = post_breakout_peak - breakout_level
if not pullback_found:
_impulse_vols.append(float(b.get("volume", 0) or 0))
# Impulse window expired — abort if peak not yet confirmed
if _impulse_window_cutoff is not None and ts > _impulse_window_cutoff:
break
# Look for pullback: price retraces from peak
if move_from_breakout > 0:
# Minimum impulse size gate
if (
params.pullback_impulse_min_move_atr is not None
and move_from_breakout < params.pullback_impulse_min_move_atr * atr
):
continue
retracement = (post_breakout_peak - b["low"]) / move_from_breakout
depth_ok = retracement >= params.pullback_min_retracement_pct
if depth_ok and params.pullback_depth_max_pct is not None:
depth_ok = retracement <= params.pullback_depth_max_pct
if depth_ok:
pullback_found = True
pullback_extreme = b["low"]
continue
# Pullback found — track the low and look for continuation
_pullback_vols.append(float(b.get("volume", 0) or 0))
pullback_extreme = min(pullback_extreme, b["low"])
# VWAP floor: abort if pullback breaches VWAP too deeply
if params.pullback_vwap_floor:
_running_vwap = _compute_running_vwap(mkt_bars, ts)
if _running_vwap is not None:
if b["low"] < _running_vwap * (1 - params.pullback_vwap_floor_tolerance_pct):
break
# Continuation: bar closes green and above pullback extreme
if b["close"] > b["open"] and b["close"] > pullback_extreme:
# Volume contraction gate
if params.pullback_volume_contraction_ratio is not None:
if _impulse_vols and _pullback_vols:
avg_imp = sum(_impulse_vols) / len(_impulse_vols)
avg_pb = sum(_pullback_vols) / len(_pullback_vols)
if avg_pb >= avg_imp * params.pullback_volume_contraction_ratio:
break # no volume contraction — skip setup
# Reclaim rel-vol confirmation
if params.pullback_reclaim_confirm_rel_vol is not None:
_post_orb_avg = (
sum(_impulse_vols + _pullback_vols) / len(_impulse_vols + _pullback_vols)
if (_impulse_vols or _pullback_vols)
else 0.0
)
_bar_vol = float(b.get("volume", 0) or 0)
_reclaim_rvol = _bar_vol / _post_orb_avg if _post_orb_avg > 0 else 0.0
if _reclaim_rvol < params.pullback_reclaim_confirm_rel_vol:
break
entry_price_raw = b["close"]
entry_bar = b
# Stop assignment: legacy pullback_stop_at_low
if params.pullback_stop_at_low and pullback_extreme < entry_price_raw:
stop_distance = entry_price_raw - pullback_extreme
# Override: vwap_lower stop mode
if params.pullback_stop_mode == "vwap_lower":
_sv = _compute_running_vwap(mkt_bars, ts)
if _sv is not None and _sv < entry_price_raw:
_computed_sd = entry_price_raw - _sv * (1 - params.pullback_stop_vwap_buffer_pct)
if _computed_sd > 0:
stop_distance = _computed_sd
elif params.pullback_stop_mode == "pullback_low" and pullback_extreme < entry_price_raw:
stop_distance = entry_price_raw - pullback_extreme
break
else: # short
post_breakout_peak = min(post_breakout_peak, b["low"]) # trough
move_from_breakout = breakout_level - post_breakout_peak
if not pullback_found:
_impulse_vols.append(float(b.get("volume", 0) or 0))
if _impulse_window_cutoff is not None and ts > _impulse_window_cutoff:
break
if move_from_breakout > 0:
if (
params.pullback_impulse_min_move_atr is not None
and move_from_breakout < params.pullback_impulse_min_move_atr * atr
):
continue
retracement = (b["high"] - post_breakout_peak) / move_from_breakout
depth_ok = retracement >= params.pullback_min_retracement_pct
if depth_ok and params.pullback_depth_max_pct is not None:
depth_ok = retracement <= params.pullback_depth_max_pct
if depth_ok:
pullback_found = True
pullback_extreme = b["high"]
continue
_pullback_vols.append(float(b.get("volume", 0) or 0))
pullback_extreme = max(pullback_extreme, b["high"])
if params.pullback_vwap_floor:
_running_vwap = _compute_running_vwap(mkt_bars, ts)
if _running_vwap is not None:
if b["high"] > _running_vwap * (1 + params.pullback_vwap_floor_tolerance_pct):
break
if b["close"] < b["open"] and b["close"] < pullback_extreme:
if params.pullback_volume_contraction_ratio is not None:
if _impulse_vols and _pullback_vols:
avg_imp = sum(_impulse_vols) / len(_impulse_vols)
avg_pb = sum(_pullback_vols) / len(_pullback_vols)
if avg_pb >= avg_imp * params.pullback_volume_contraction_ratio:
break
entry_price_raw = b["close"]
entry_bar = b
if params.pullback_stop_at_low and pullback_extreme > entry_price_raw:
stop_distance = pullback_extreme - entry_price_raw
if params.pullback_stop_mode == "vwap_lower":
_sv = _compute_running_vwap(mkt_bars, ts)
if _sv is not None and _sv > entry_price_raw:
_computed_sd = _sv * (1 + params.pullback_stop_vwap_buffer_pct) - entry_price_raw
if _computed_sd > 0:
stop_distance = _computed_sd
elif params.pullback_stop_mode == "pullback_low" and pullback_extreme > entry_price_raw:
stop_distance = pullback_extreme - entry_price_raw
break
if entry_bar is None:
return None # no pullback-continuation pattern found
# --- VWAP-based stop override for vwap_reclaim_v1 ---
# Tighter structural stop: distance from entry to VWAP floor instead of ATR multiple.
# More shares per unit risk on high-gap stocks where VWAP is naturally a support floor.
if _sim_engine_family == "vwap_reclaim_v1" and params.vwap_reclaim_stop_mode == "vwap":
_entry_ts = _parse_ts(entry_bar["timestamp"])
_sv = _compute_running_vwap(mkt_bars, _entry_ts)
if _sv is not None and direction == "long" and _sv < entry_price_raw:
_buf = params.vwap_reclaim_stop_vwap_buffer_pct
_vwap_sd = entry_price_raw - _sv * (1 - _buf)
if _vwap_sd > 0:
stop_distance = _vwap_sd
elif _sv is not None and direction == "short" and _sv > entry_price_raw:
_buf = params.vwap_reclaim_stop_vwap_buffer_pct
_vwap_sd = _sv * (1 + _buf) - entry_price_raw
if _vwap_sd > 0:
stop_distance = _vwap_sd
# --- Breakout volume confirmation ---
# Reject breakouts on thin volume (low conviction, likely to fail).
# Skip for momentum_confirm trigger — volume confirmation is already embedded in
# the morning_gain + confirmation_return gates of _find_momentum_confirm_time.
if trigger_type == "orb" and params.min_breakout_rel_vol is not None:
entry_vol = entry_bar.get("volume", 0) or 0
# Average volume of all post-ORB bars (excluding ORB bar itself)
post_orb_vols = [
b.get("volume", 0) or 0
for b in mkt_bars
if _parse_ts(b["timestamp"]) > orb_ts
]
avg_bar_vol = sum(post_orb_vols) / len(post_orb_vols) if post_orb_vols else 0
if avg_bar_vol > 0 and entry_vol < avg_bar_vol * params.min_breakout_rel_vol:
return None # breakout bar volume too low
# --- 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
)
# --- Confirmation bar requirement (lookahead-free) ---
# After breakout, wait one bar. If confirmation bar closes in the right direction,
# enter at the confirmation bar's close (the price available AFTER seeing confirmation).
# This avoids retroactive cancellation bias — unconfirmed trades simply don't enter.
if params.require_confirmation_bar:
confirm_bar = None
for b in mkt_bars:
ts = _parse_ts(b["timestamp"])
if ts <= _parse_ts(entry_bar["timestamp"]):
continue
confirm_bar = b
break
if confirm_bar is None:
return None # no bar after entry (near close)
if direction == "long" and confirm_bar["close"] < entry_price_raw:
return None # confirmation failed — don't enter
elif direction == "short" and confirm_bar["close"] > entry_price_raw:
return None # confirmation failed — don't enter
# Confirmation passed — shift entry to confirmation bar's close
# (the price available to the trader AFTER observing the confirmation)
entry_price_raw = confirm_bar["close"]
entry_bar = confirm_bar
entry_ts = _parse_ts(confirm_bar["timestamp"])
# Recalculate stop with new entry price
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
# Score-based position sizing: top-ranked candidates get larger positions
if params.score_sizing_multiplier is not None and params.score_sizing_multiplier > 1.0:
# score_rank_pct: 1.0 = top rank, 0.0 = bottom rank
sizing_mult = 1.0 + score_rank_pct * (params.score_sizing_multiplier - 1.0)
else:
sizing_mult = 1.0
risk_dollars = cap * params.risk_per_trade_pct * sizing_mult
# When fixed dollar stop is set, use it as the per-share risk for sizing
effective_stop_for_sizing = params.fixed_loss_dollars if params.fixed_loss_dollars is not None else stop_distance
shares_from_risk = risk_dollars / effective_stop_for_sizing
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.
# Also skip when entry_on_bar_close — trader enters at bar close, not exposed to intra-bar action.
if group_size == 1 and not params.entry_on_bar_close 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,
stop_level_at_exit="initial",
trigger_type=trigger_type,
)
if group_size == 1 and not params.entry_on_bar_close 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,
stop_level_at_exit="initial",
trigger_type=trigger_type,
)
# --- Phase 2: Manage position ---
current_stop = initial_stop
trailing_active = False
# Fixed dollar exit price levels — per share (e.g. +$2/share profit, -$1/share stop)
fixed_pt_price: float | None = None
fixed_sl_price: float | None = None
if direction == "long":
if params.fixed_profit_dollars is not None:
fixed_pt_price = entry_price_raw + params.fixed_profit_dollars
if params.fixed_loss_dollars is not None:
fixed_sl_price = entry_price_raw - params.fixed_loss_dollars
else:
if params.fixed_profit_dollars is not None:
fixed_pt_price = entry_price_raw - params.fixed_profit_dollars
if params.fixed_loss_dollars is not None:
fixed_sl_price = entry_price_raw + params.fixed_loss_dollars
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
stop_level = "initial" # 'initial' | 'breakeven' | 'trailing' — for diagnostics
use_atr_trail = params.trailing_stop_atr_multiplier > 0
# VWAP exit setup
use_vwap_exit = params.vwap_exit_mode in ("exit", "floor")
vwap_exit_buffer = atr * params.vwap_exit_buffer_atr
# Max hold time exit
max_hold_exit_ts: dt.datetime | None = None
if params.max_hold_minutes is not None:
max_hold_exit_ts = entry_ts + dt.timedelta(minutes=params.max_hold_minutes)
# Gap fill: track previous close for emergency exit
use_gap_fill_exit = params.exit_on_gap_fill and prev_close is not None and prev_close > 0
# Track peak R for VWAP activation threshold
peak_r = 0.0
# Time-decay trailing: precompute decay schedule
use_time_decay = (
params.time_decay_start_minutes is not None
and use_atr_trail
)
if use_time_decay:
decay_start_ts = market_open + dt.timedelta(minutes=params.time_decay_start_minutes)
decay_end_ts = exit_target # decay completes at exit time
decay_span = (decay_end_ts - decay_start_ts).total_seconds()
else:
decay_start_ts = decay_end_ts = None
decay_span = 0.0
# SPY intraday guard: precompute SPY open price for intraday comparison
spy_guard_active = (
params.spy_intraday_guard_pct is not None
and spy_bars is not None
and len(spy_bars) > 0
)
spy_open = 0.0
if spy_guard_active:
spy_open = spy_bars[0].get("open", 0.0) if spy_bars else 0.0
# Partial exit state (Option B: blended single trade result)
original_shares = shares
remaining_shares = shares
partial_exited = False
partial_pnl = 0.0
partial_exit_r_val: float | None = None
partial_exit_slippage = 0.0
# Pyramid state: track add-on legs separately for PnL
pyramid_count = 0
pyramid_legs: list[tuple[int, float, float]] = [] # (shares, entry_filled, entry_raw)
pyramid_total_shares = 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 0: Fixed dollar exits (override ATR when set) ──
if fixed_sl_price is not None and bar_low <= fixed_sl_price:
exit_price_raw = bar_open if bar_open <= fixed_sl_price else fixed_sl_price
exit_time_str = b["timestamp"]
exit_reason = "stop_loss"
final_r = (exit_price_raw - entry_price_raw) / stop_distance
break
if fixed_pt_price is not None and bar_high >= fixed_pt_price:
exit_price_raw = fixed_pt_price
exit_time_str = b["timestamp"]
exit_reason = "profit_target"
final_r = (exit_price_raw - entry_price_raw) / stop_distance
break
# ── 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 fixed_sl_price is None and 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 1b: Gap fill protection ──
if use_gap_fill_exit and bar_close < prev_close:
exit_price_raw = bar_close
exit_time_str = b["timestamp"]
exit_reason = "gap_fill"
final_r = (exit_price_raw - entry_price_raw) / stop_distance
break
# ── Step 1c: Max hold time exit ──
if max_hold_exit_ts is not None and ts >= max_hold_exit_ts:
exit_price_raw = bar_close
exit_time_str = b["timestamp"]
exit_reason = "max_hold"
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
peak_r = max(peak_r, current_r)
# ── Step 3b: VWAP exit check ──
if use_vwap_exit and peak_r >= params.vwap_exit_after_r:
running_vwap = _compute_running_vwap(mkt_bars, ts)
if running_vwap is not None:
vwap_level = running_vwap - vwap_exit_buffer
if params.vwap_exit_mode == "exit" and bar_close < vwap_level:
exit_price_raw = bar_close
exit_time_str = b["timestamp"]
exit_reason = "vwap_exit"
final_r = current_r
break
elif params.vwap_exit_mode == "floor" and trailing_active:
# VWAP as trailing stop floor
if vwap_level > current_stop:
current_stop = vwap_level
# ── Step 3c: Profit target exit ──
if params.profit_target_r is not None and current_r >= params.profit_target_r:
exit_price_raw = bar_close
exit_time_str = b["timestamp"]
exit_reason = "profit_target"
final_r = current_r
break
# Partial exit: lock in profits at configured R-multiple
if (
params.partial_exit_at_r is not None
and not partial_exited
and current_r >= params.partial_exit_at_r
):
p_shares = int(original_shares * params.partial_exit_pct)
if p_shares > 0 and p_shares < remaining_shares:
p_exit_raw = bar_close
p_exit = _apply_slippage_exit(p_exit_raw, slippage)
partial_pnl = (p_exit - entry_price_filled) * p_shares
partial_exit_slippage = abs(p_exit - p_exit_raw) * p_shares
remaining_shares -= p_shares
partial_exited = True
partial_exit_r_val = current_r
# Protect remainder: move stop to breakeven if not already
if current_stop < entry_price_raw:
current_stop = entry_price_raw
stop_level = "breakeven"
# Pyramiding: add to winning position at configured R-multiple
if (
params.pyramid_at_r is not None
and pyramid_count < params.pyramid_max_adds
and current_r >= params.pyramid_at_r * (1 + pyramid_count)
):
add_shares = int(original_shares * params.pyramid_add_pct)
if add_shares > 0:
p_entry_raw = bar_close
p_entry_filled = _apply_slippage_entry(p_entry_raw, slippage)
pyramid_legs.append((add_shares, p_entry_filled, p_entry_raw))
pyramid_total_shares += add_shares
pyramid_count += 1
# Move stop to breakeven at configured R-multiple
if current_r >= effective_breakeven_at_r and current_stop < entry_price_raw:
current_stop = entry_price_raw
stop_level = "breakeven"
# Activate trailing stop at configured R-multiple
if current_r >= params.trailing_at_r:
trailing_active = True
stop_level = "trailing"
# ── Step 4: Update trailing stop for NEXT bar ──
if trailing_active:
if use_atr_trail:
# Two-stage trailing: wider trail initially, tightens at a higher R
atr_mult = params.trailing_stop_atr_multiplier
# Gap-adaptive trailing: override base multiplier based on gap size
if params.gap_trail_wide_threshold is not None:
if abs(gap_pct) > params.gap_trail_wide_threshold:
atr_mult = params.gap_trail_wide_atr_multiplier
elif params.gap_trail_tight_atr_multiplier is not None:
atr_mult = params.gap_trail_tight_atr_multiplier
if (
params.trailing_tighten_at_r is not None
and current_r >= params.trailing_tighten_at_r
and params.trailing_stop_atr_multiplier_tight > 0
):
atr_mult = params.trailing_stop_atr_multiplier_tight
# Time-decay: linearly shrink trail width toward close
if use_time_decay and ts >= decay_start_ts and decay_span > 0:
elapsed = min((ts - decay_start_ts).total_seconds(), decay_span)
decay_pct = elapsed / decay_span # 0 → 1
atr_mult *= 1.0 - decay_pct * (1.0 - params.time_decay_factor)
# SPY intraday guard: tighten trail when SPY drops from open
if spy_guard_active and spy_bars:
spy_bar = next(
(sb for sb in spy_bars if sb.get("timestamp") == b.get("timestamp")),
None,
)
if spy_bar is not None and spy_open > 0:
spy_change = (spy_bar["close"] - spy_open) / spy_open
if spy_change < params.spy_intraday_guard_pct:
atr_mult *= params.spy_intraday_guard_tighten
candidate_stop = peak_price - atr * atr_mult
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 0: Fixed dollar exits (short) ──
if fixed_sl_price is not None and bar_high >= fixed_sl_price:
exit_price_raw = bar_open if bar_open >= fixed_sl_price else fixed_sl_price
exit_time_str = b["timestamp"]
exit_reason = "stop_loss"
final_r = (entry_price_raw - exit_price_raw) / stop_distance
break
if fixed_pt_price is not None and bar_low <= fixed_pt_price:
exit_price_raw = fixed_pt_price
exit_time_str = b["timestamp"]
exit_reason = "profit_target"
final_r = (entry_price_raw - exit_price_raw) / stop_distance
break
# ── Step 1: Stop check FIRST ──
if fixed_sl_price is None and 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 1b: Gap fill protection (short: price rises above prev_close) ──
if use_gap_fill_exit and bar_close > prev_close:
exit_price_raw = bar_close
exit_time_str = b["timestamp"]
exit_reason = "gap_fill"
final_r = (entry_price_raw - exit_price_raw) / stop_distance
break
# ── Step 1c: Max hold time exit ──
if max_hold_exit_ts is not None and ts >= max_hold_exit_ts:
exit_price_raw = bar_close
exit_time_str = b["timestamp"]
exit_reason = "max_hold"
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
peak_r = max(peak_r, current_r)
# ── Step 3b: VWAP exit check (short) ──
if use_vwap_exit and peak_r >= params.vwap_exit_after_r:
running_vwap = _compute_running_vwap(mkt_bars, ts)
if running_vwap is not None:
vwap_level = running_vwap + vwap_exit_buffer
if params.vwap_exit_mode == "exit" and bar_close > vwap_level:
exit_price_raw = bar_close
exit_time_str = b["timestamp"]
exit_reason = "vwap_exit"
final_r = current_r
break
elif params.vwap_exit_mode == "floor" and trailing_active:
if vwap_level < current_stop:
current_stop = vwap_level
# ── Step 3c: Profit target exit (short) ──
if params.profit_target_r is not None and current_r >= params.profit_target_r:
exit_price_raw = bar_close
exit_time_str = b["timestamp"]
exit_reason = "profit_target"
final_r = current_r
break
# Partial exit (short)
if (
params.partial_exit_at_r is not None
and not partial_exited
and current_r >= params.partial_exit_at_r
):
p_shares = int(original_shares * params.partial_exit_pct)
if p_shares > 0 and p_shares < remaining_shares:
p_exit_raw = bar_close
p_exit = _apply_slippage_entry(p_exit_raw, slippage)
partial_pnl = (entry_price_filled - p_exit) * p_shares
partial_exit_slippage = abs(p_exit - p_exit_raw) * p_shares
remaining_shares -= p_shares
partial_exited = True
partial_exit_r_val = current_r
if current_stop > entry_price_raw:
current_stop = entry_price_raw
stop_level = "breakeven"
# Pyramiding (short): add to winning position
if (
params.pyramid_at_r is not None
and pyramid_count < params.pyramid_max_adds
and current_r >= params.pyramid_at_r * (1 + pyramid_count)
):
add_shares = int(original_shares * params.pyramid_add_pct)
if add_shares > 0:
p_entry_raw = bar_close
p_entry_filled = _apply_slippage_exit(p_entry_raw, slippage)
pyramid_legs.append((add_shares, p_entry_filled, p_entry_raw))
pyramid_total_shares += add_shares
pyramid_count += 1
if current_r >= effective_breakeven_at_r and current_stop > entry_price_raw:
current_stop = entry_price_raw
stop_level = "breakeven"
if current_r >= params.trailing_at_r:
trailing_active = True
stop_level = "trailing"
# ── Step 4: Update trailing stop for NEXT bar ──
if trailing_active:
if use_atr_trail:
atr_mult = params.trailing_stop_atr_multiplier
# Gap-adaptive trailing: override base multiplier based on gap size
if params.gap_trail_wide_threshold is not None:
if abs(gap_pct) > params.gap_trail_wide_threshold:
atr_mult = params.gap_trail_wide_atr_multiplier
elif params.gap_trail_tight_atr_multiplier is not None:
atr_mult = params.gap_trail_tight_atr_multiplier
if (
params.trailing_tighten_at_r is not None
and current_r >= params.trailing_tighten_at_r
and params.trailing_stop_atr_multiplier_tight > 0
):
atr_mult = params.trailing_stop_atr_multiplier_tight
# Time-decay: linearly shrink trail width toward close
if use_time_decay and ts >= decay_start_ts and decay_span > 0:
elapsed = min((ts - decay_start_ts).total_seconds(), decay_span)
decay_pct = elapsed / decay_span
atr_mult *= 1.0 - decay_pct * (1.0 - params.time_decay_factor)
# SPY intraday guard (short): tighten trail when SPY rallies from open
if spy_guard_active and spy_bars:
spy_bar = next(
(sb for sb in spy_bars if sb.get("timestamp") == b.get("timestamp")),
None,
)
if spy_bar is not None and spy_open > 0:
spy_change = (spy_bar["close"] - spy_open) / spy_open
if spy_change > abs(params.spy_intraday_guard_pct):
atr_mult *= params.spy_intraday_guard_tighten
candidate_stop = peak_price + atr * atr_mult
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 (on remaining original shares + pyramid shares)
exit_price = (
_apply_slippage_exit(exit_price_raw, slippage) if direction == "long"
else _apply_slippage_entry(exit_price_raw, slippage)
)
if direction == "long":
remainder_pnl_pct = (exit_price - entry_price_filled) / entry_price_filled
else:
remainder_pnl_pct = (entry_price_filled - exit_price) / entry_price_filled
# Blended PnL: partial exit + final exit on remaining original shares
remainder_pnl = remainder_pnl_pct * (remaining_shares * entry_price_filled)
pnl = remainder_pnl + partial_pnl
# Pyramid PnL: add-on legs exit at the same price as the main position
pyr_pnl = 0.0
pyr_entry_slippage = 0.0
if pyramid_legs:
for p_shares, p_entry_filled, p_entry_raw in pyramid_legs:
if direction == "long":
pyr_pnl += (exit_price - p_entry_filled) * p_shares
else:
pyr_pnl += (p_entry_filled - exit_price) * p_shares
pyr_entry_slippage += abs(p_entry_filled - p_entry_raw) * p_shares
pnl += pyr_pnl
# pnl_pct as return on total deployed capital (original + pyramid)
total_deployed_cost = original_shares * entry_price_filled + sum(
s * e for s, e, _ in pyramid_legs
)
pnl_pct = pnl / total_deployed_cost if total_deployed_cost > 0 else 0.0
entry_slippage = abs(entry_price_filled - entry_price_raw) * original_shares
exit_slippage = abs(exit_price - exit_price_raw) * (remaining_shares + pyramid_total_shares)
slippage_cost = entry_slippage + exit_slippage + partial_exit_slippage + pyr_entry_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(original_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),
stop_level_at_exit=stop_level,
partial_exit_r=round(partial_exit_r_val, 3) if partial_exit_r_val is not None else None,
pyramid_adds=pyramid_count,
pyramid_pnl=round(pyr_pnl, 4),
total_capital_deployed=round(total_deployed_cost, 4),
trigger_type=trigger_type,
)
# ── 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,
ticker_sectors: dict[str, str] | None = None,
available_cash: float | None = None,
sizing_capital: float | None = None,
vix_value: float | None = None,
overlay_tickers: set[str] | None = None,
) -> DayResult:
"""Simulate one full trading day using the ORB strategy.
1. VIX regime check — skip high-VIX days (max_vix)
2. SPY regime check (daily gap from enrichment) — skip bad market days
3. compute_orb_candidates — filter and rank candidates
4. If fewer than min_candidates_to_trade → skip day
5. For each candidate: simulate_orb_trade (with VIX size scaling)
6. 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).
vix_value: Previous close VIX for this trading day (None if unavailable).
Returns:
DayResult compatible with compute_metrics().
"""
result = DayResult(date=date_str)
# VIX regime check: skip the entire day if VIX is too high.
# vix_value is the prior close VIX (lookahead-free).
if params.max_vix is not None and vix_value is not None:
if vix_value > params.max_vix:
result.skip_reason = "vix_gate"
return result # skip high-VIX days
# VIX position size scaler (applied to sizing_capital later)
vix_scaler = _orb_vix_size_scaler(vix_value, params)
# Market regime check: index ETF daily gap (lookahead-free via enrichment)
regime_scaler = 1.0
if params.market_regime_spy_threshold is not None or params.regime_size_scale_low 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
# Hard skip floor (V20 param or V19 legacy threshold)
if params.regime_skip_below is not None and regime_gap < params.regime_skip_below:
result.skip_reason = "market_regime"
return result
if (params.regime_size_scale_low is None
and params.market_regime_spy_threshold is not None
and regime_gap < params.market_regime_spy_threshold):
result.skip_reason = "market_regime"
return result
# Soft scaler (V20 path)
if params.regime_size_scale_low is not None and params.regime_size_scale_high is not None:
regime_scaler = _linear_scaler(
regime_gap,
params.regime_size_scale_low,
params.regime_size_scale_high,
params.regime_size_scale_min,
invert=True,
)
# Candidate breadth filter
breadth_scaler = 1.0
min_breadth = getattr(params, "min_candidate_breadth", None)
if min_breadth is not None or params.breadth_size_scale_low 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:
breadth_ratio = pos_gap_count / total_with_data
if params.breadth_skip_below is not None and breadth_ratio < params.breadth_skip_below:
result.skip_reason = "breadth"
return result
if (params.breadth_size_scale_low is None
and min_breadth is not None
and breadth_ratio < min_breadth):
result.skip_reason = "breadth"
return result
if params.breadth_size_scale_low is not None and params.breadth_size_scale_high is not None:
breadth_scaler = _linear_scaler(
breadth_ratio,
params.breadth_size_scale_low,
params.breadth_size_scale_high,
params.breadth_size_scale_min,
invert=True,
)
combined_scaler = regime_scaler * breadth_scaler
is_soft_day = combined_scaler < params.soft_day_scaler_threshold
result.regime_scaler = regime_scaler
result.breadth_scaler = breadth_scaler
result.is_soft_day = is_soft_day
_cand_stats: dict = {}
candidates = compute_orb_candidates(
bars_by_ticker,
date_str,
params,
enrichment,
blacklisted_tickers=blacklisted_tickers,
spy_bars=None, # handled above via enrichment
ticker_sectors=ticker_sectors,
_stats_out=_cand_stats,
overlay_tickers=overlay_tickers,
)
result.candidates_found = len(candidates)
result.candidate_filter_stats = _cand_stats if _cand_stats else None
if len(candidates) < params.min_candidates_to_trade:
result.skip_reason = "no_candidates" if len(candidates) == 0 else "below_min_candidates"
return result
# ── Pass 1: Find entry times for all candidates ──
# Determines chronological order BEFORE allocating capital, so earlier entries
# get capital first regardless of composite score ranking.
# Each item: (entry_ts, cand, direction_str, trigger_type, forced_entry_price | None)
timed_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = []
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
)
momo_result = (
_find_momentum_confirm_time(cand["mkt_bars"], cand["orb_bar"], params)
if getattr(params, "dual_trigger_enabled", False) else None
)
if breakout_ts is not None and (momo_result is None or breakout_ts <= momo_result[0]):
timed_candidates.append((breakout_ts, cand, direction_str, "orb", None))
elif momo_result is not None:
timed_candidates.append((momo_result[0], cand, direction_str, "momentum_confirm", momo_result[1]))
# Sort by entry 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
# Apply VIX + regime/breadth scalers to sizing capital
combined_size_mult = vix_scaler * combined_scaler
adjusted_sizing = sizing_cap * combined_size_mult if combined_size_mult < 1.0 else sizing_cap
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
entries_at_ts: dict[str, int] = {} # timestamp_str → entries taken at that bar
total_deployed = 0.0 # cumulative deployed capital for deployment cap
max_deploy = (
sizing_cap * params.max_total_deployment_pct
if params.max_total_deployment_pct is not None
else None
)
for idx, (entry_ts_pass2, cand, direction_str, trigger_type_pass2, forced_price_pass2) in enumerate(timed_candidates):
# Kill switch: only count losses from trades that have ALREADY EXITED
# before this entry time (exit-time-aware accounting).
realized_loss = sum(
abs(t.pnl)
for t in result.trades
if t.pnl < 0 and _parse_ts(t.exit_time) <= entry_ts_pass2
)
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) <= entry_ts_pass2
)
if realized_loss >= daily_loss_limit:
break
if realized_stops >= params.max_stops_per_day:
break
# Simultaneous-entry cap: limit correlated risk when all candidates enter
# on the same bar. Top-ranked candidates are taken first because timed_candidates
# is sorted by entry_ts (ties preserve ranking order).
if params.max_simultaneous_entries is not None:
ts_key = entry_ts_pass2.isoformat()
if entries_at_ts.get(ts_key, 0) >= params.max_simultaneous_entries:
continue
# Cash exhaustion
if remaining_cash is not None and remaining_cash <= 0:
skipped_cash += len(timed_candidates) - idx
break
# Portfolio deployment cap
if max_deploy is not None and total_deployed >= max_deploy:
continue
# Score rank percentage: 1.0 = top ranked, 0.0 = bottom ranked
n_cands = len(candidates)
cand_rank = next(
(i for i, c in enumerate(candidates) if c["ticker"] == cand["ticker"]),
n_cands - 1,
)
score_rank_pct = 1.0 - (cand_rank / max(n_cands - 1, 1))
# Soft-day selection gates
if is_soft_day:
if params.soft_day_max_trades is not None and len(result.trades) >= params.soft_day_max_trades:
continue
if params.soft_day_min_score_pct is not None and score_rank_pct < params.soft_day_min_score_pct:
continue
# Previous close + entropy for gap fill protection and per-candidate size scaling
ticker_enrich = enrichment.get(cand["ticker"], {}).get(date_str, {})
cand_prev_close = ticker_enrich.get("prev_close")
# Per-candidate entropy size scaler (from momentum strategy)
entropy_20d = ticker_enrich.get("entropy_20d")
entropy_scaler = (
_orb_entropy_size_scaler(entropy_20d, params)
if getattr(params, "entropy_size_scale_low", None) is not None else 1.0
)
base_sizing = adjusted_sizing if combined_size_mult < 1.0 else sizing_capital
cand_sizing = (base_sizing * entropy_scaler) if entropy_scaler < 1.0 else base_sizing
# Forced entry bar for momentum_confirm trigger (09:45 bar)
forced_entry_bar_pass2: dict | None = None
if trigger_type_pass2 == "momentum_confirm" and forced_price_pass2 is not None:
orb_ts_raw = _parse_ts(cand["orb_bar"]["timestamp"])
post_bars = [b for b in cand["mkt_bars"] if _parse_ts(b["timestamp"]) > orb_ts_raw]
forced_entry_bar_pass2 = post_bars[1] if len(post_bars) >= 2 else None
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=cand_sizing,
score_rank_pct=score_rank_pct,
prev_close=cand_prev_close,
spy_bars=spy_bars,
is_soft_day=is_soft_day,
trigger_type=trigger_type_pass2,
forced_entry_price=forced_price_pass2,
forced_entry_bar=forced_entry_bar_pass2,
)
if trade is None:
continue
result.trades.append(trade)
result.daily_pnl += trade.pnl
# Track simultaneous entries count
ts_key = entry_ts_pass2.isoformat()
entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1
# Track total deployed capital (original + pyramid)
trade_deployed = trade.total_capital_deployed or (trade.shares * trade.entry_price)
total_deployed += trade_deployed
# Deduct deployed capital from remaining settled cash
if remaining_cash is not None:
remaining_cash -= trade_deployed
# ── Pass 3 (optional): Re-entry after stop-out ──
if params.reentry_after_stop:
stopped_trades = [
t for t in result.trades
if t.exit_reason == "stop_loss" and not t.is_reentry
]
for stopped_trade in stopped_trades:
# Check re-entry count for this ticker
reentries_done = sum(
1 for t in result.trades if t.ticker == stopped_trade.ticker and t.is_reentry
)
if reentries_done >= params.reentry_max_per_ticker:
continue
# Deployment cap check
if max_deploy is not None and total_deployed >= max_deploy:
break
# Find original candidate data
cand_match = next(
((c, d) for _, c, d, *_ in timed_candidates if c["ticker"] == stopped_trade.ticker),
None,
)
if cand_match is None:
continue
cand, direction_str = cand_match
# Score rank for re-entry (same as original)
n_cands = len(candidates)
cand_rank = next(
(i for i, c in enumerate(candidates) if c["ticker"] == cand["ticker"]),
n_cands - 1,
)
score_rank_pct = 1.0 - (cand_rank / max(n_cands - 1, 1))
ticker_enrich = enrichment.get(cand["ticker"], {}).get(date_str, {})
cand_prev_close = ticker_enrich.get("prev_close")
exit_ts = _parse_ts(stopped_trade.exit_time)
reentry_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=adjusted_sizing if combined_size_mult < 1.0 else sizing_capital,
score_rank_pct=score_rank_pct,
prev_close=cand_prev_close,
entry_after_ts=exit_ts,
spy_bars=spy_bars,
is_soft_day=is_soft_day,
)
if reentry_trade is not None:
reentry_trade.is_reentry = True
result.trades.append(reentry_trade)
result.daily_pnl += reentry_trade.pnl
re_deployed = reentry_trade.total_capital_deployed or (
reentry_trade.shares * reentry_trade.entry_price
)
total_deployed += re_deployed
if remaining_cash is not None:
remaining_cash -= re_deployed
result.skipped_insufficient_cash = skipped_cash
result.capital_deployed = sum(
t.total_capital_deployed or (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_with_state(
all_intraday: dict[str, dict[str, list[dict]]],
trading_days: list[str],
params: ORBStrategyParams,
enrichment: dict[str, dict[str, dict]],
ticker_sectors: dict[str, str] | None = None,
state: ORBSimulationState | None = None,
progress_callback: Callable[[int, int], None] | None = None,
vix_by_day: dict[str, float] | None = None,
overlay_tickers_per_day: dict[str, set[str]] | None = None,
) -> tuple[list[DayResult], ORBSimulationState]:
"""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:
(day_results, next_state) where next_state can be fed into the next chunk.
"""
results: list[DayResult] = []
equity = state.equity if state is not None else params.initial_capital
# Ticker cooldown tracker
ticker_last_traded: dict[str, dt.date] = (
{ticker: dt.date.fromisoformat(last_date) for ticker, last_date in state.ticker_last_traded.items()}
if state is not None else {}
)
# 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 = (
state.settled_cash
if state is not None and state.settled_cash is not None
else params.initial_capital
)
pending_settlements: list[tuple[str, float]] = (
list(state.pending_settlements) if state is not None else []
)
# Rolling PnL window: persists cross-chunk daily PnL history for rolling loss filter.
# Trimmed to rolling_loss_days length so memory stays bounded.
rolling_pnl_window: list[float] = list(state.recent_daily_pnl) if state is not None else []
# Drawdown governor: track peak equity to detect drawdowns
peak_equity = equity
# Streak sizing: track recent trade outcomes for streak-based sizing
streak_outcomes: list[bool] = [] # True = win, False = loss (from recent trades)
total_days = len(trading_days)
for day_idx, date_str in enumerate(trading_days):
if progress_callback:
progress_callback(day_idx + 1, total_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))
rolling_pnl_window.append(0.0)
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
)
# Rolling strategy loss filter: pause trading after sustained self-drawdown.
# Uses rolling_pnl_window which persists across chunk boundaries (unlike results[]).
if (
params.rolling_loss_days is not None
and params.rolling_loss_threshold is not None
and len(rolling_pnl_window) >= params.rolling_loss_days
):
n_roll = params.rolling_loss_days
rolling_pnl = sum(rolling_pnl_window[-n_roll:])
if params.daily_budget_reset or not params.compound_returns:
sizing_capital_for_check = params.initial_capital
else:
sizing_capital_for_check = equity
if sizing_capital_for_check > 0:
rolling_return = rolling_pnl / sizing_capital_for_check
if rolling_return < params.rolling_loss_threshold:
results.append(DayResult(date=date_str, skip_reason="rolling_loss"))
rolling_pnl_window.append(0.0)
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
continue
# Multi-day SPY trend filter: skip if SPY is in a sustained downtrend
# Uses enrichment[spy_ticker][date]["prev_close"] for N-day cumulative return.
# enrichment[D]["prev_close"] = close of trading day before D.
# N-day return = (close_yesterday - close_N_days_ago) / close_N_days_ago
# = (enrich[today]["prev_close"] - enrich[trading_days[day_idx-N+1]]["prev_close"])
# / enrich[trading_days[day_idx-N+1]]["prev_close"]
if (
params.market_regime_spy_trend_days is not None
and params.market_regime_spy_trend_threshold is not None
and day_idx >= params.market_regime_spy_trend_days
):
spy_trend_ticker = getattr(params, "market_regime_ticker", None) or "SPY"
spy_enrich = enrichment.get(spy_trend_ticker, {})
close_yesterday = spy_enrich.get(date_str, {}).get("prev_close")
n_days_back = params.market_regime_spy_trend_days
look_back_date = trading_days[day_idx - n_days_back + 1]
close_n_ago = spy_enrich.get(look_back_date, {}).get("prev_close")
if close_yesterday and close_n_ago and close_n_ago > 0:
spy_trend_return = (close_yesterday - close_n_ago) / close_n_ago
if spy_trend_return < params.market_regime_spy_trend_threshold:
results.append(DayResult(date=date_str, skip_reason="spy_trend"))
rolling_pnl_window.append(0.0)
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
continue
available_cash = settled_cash if settlement_enabled else None
# Sizing mode resolution:
# - daily_budget_reset: research mode, always initial_capital (ignores path)
# - compound_returns: sizing_capital=None → simulate_orb_trade uses equity
# - simple: fixed initial_capital
if params.daily_budget_reset:
sizing_capital = params.initial_capital
elif params.compound_returns:
sizing_capital = None
else:
sizing_capital = params.initial_capital
# Drawdown governor: scale down sizing when equity drops below peak
if params.drawdown_governor_threshold is not None and peak_equity > 0:
dd_pct = (peak_equity - equity) / peak_equity # 0.0 = at peak, 0.05 = 5% DD
if dd_pct > params.drawdown_governor_threshold:
# Linear ramp from 1.0 at threshold to min_scale at 2× threshold
dd_range = params.drawdown_governor_threshold # same width for the ramp
dd_excess = dd_pct - params.drawdown_governor_threshold
governor_scale = max(
params.drawdown_governor_min_scale,
1.0 - (1.0 - params.drawdown_governor_min_scale) * min(dd_excess / dd_range, 1.0),
)
if sizing_capital is not None:
sizing_capital = sizing_capital * governor_scale
else:
# compound mode: scale equity for sizing
sizing_capital = equity * governor_scale
# Streak sizing: apply win/loss streak multiplier after governor
if (params.streak_sizing_win_bonus is not None or params.streak_sizing_loss_penalty is not None) and streak_outcomes:
# Count consecutive wins or losses from the END of the list
streak_len = 0
is_winning = streak_outcomes[-1]
for outcome in reversed(streak_outcomes):
if outcome == is_winning:
streak_len += 1
else:
break
streak_mult = 1.0
if is_winning and params.streak_sizing_win_bonus is not None:
streak_mult = 1.0 + streak_len * params.streak_sizing_win_bonus
elif not is_winning and params.streak_sizing_loss_penalty is not None:
streak_mult = 1.0 - streak_len * params.streak_sizing_loss_penalty
streak_mult = max(params.streak_sizing_min, min(params.streak_sizing_max, streak_mult))
if sizing_capital is not None:
sizing_capital = sizing_capital * streak_mult
else:
sizing_capital = equity * streak_mult
# Rolling WR sizing: apply bonus/penalty based on recent win rate
if params.rolling_wr_sizing_window is not None and len(streak_outcomes) >= params.rolling_wr_sizing_window:
recent = streak_outcomes[-params.rolling_wr_sizing_window:]
rolling_wr = sum(recent) / len(recent)
wr_mult = 1.0
if rolling_wr > params.rolling_wr_sizing_threshold:
wr_mult = 1.0 + params.rolling_wr_sizing_bonus
elif params.rolling_wr_sizing_penalty_threshold is not None and rolling_wr < params.rolling_wr_sizing_penalty_threshold:
wr_mult = 1.0 - params.rolling_wr_sizing_penalty
if wr_mult != 1.0:
if sizing_capital is not None:
sizing_capital = sizing_capital * wr_mult
else:
sizing_capital = equity * wr_mult
# Single-trade loss cap: after all boosts, clamp sizing_capital so that
# a single -1R trade cannot lose more than single_trade_loss_cap_pct × initial_capital.
# Fixes streak_sizing_max amplifying losses beyond daily_max_loss_pct intent.
if (
params.single_trade_loss_cap_pct is not None
and params.risk_per_trade_pct > 0
and params.initial_capital > 0
):
max_risk = params.single_trade_loss_cap_pct * params.initial_capital
max_sizing = max_risk / params.risk_per_trade_pct
if sizing_capital is not None:
sizing_capital = min(sizing_capital, max_sizing)
else:
sizing_capital = min(equity, max_sizing)
day_vix = vix_by_day.get(date_str) if vix_by_day else None
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,
ticker_sectors=ticker_sectors,
available_cash=available_cash,
sizing_capital=sizing_capital,
vix_value=day_vix,
overlay_tickers=overlay_tickers_per_day.get(date_str) if overlay_tickers_per_day else None,
)
# 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)
rolling_pnl_window.append(day_result.daily_pnl)
# Update streak outcomes from today's trades
for trade in day_result.trades:
streak_outcomes.append(trade.pnl > 0)
# Keep only last 20 outcomes to bound memory
if len(streak_outcomes) > 20:
streak_outcomes = streak_outcomes[-20:]
# Update equity
equity += day_result.daily_pnl
equity = max(equity, 1.0) # prevent zero/negative equity from crashing
peak_equity = max(peak_equity, equity)
# 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
max_roll = params.rolling_loss_days or 0
next_state = ORBSimulationState(
equity=equity,
ticker_last_traded={
ticker: last_dt.isoformat() for ticker, last_dt in ticker_last_traded.items()
},
settled_cash=settled_cash if settlement_enabled else None,
pending_settlements=list(pending_settlements),
recent_daily_pnl=rolling_pnl_window[-max_roll:] if max_roll > 0 else [],
)
return results, next_state
def run_orb_simulation(
all_intraday: dict[str, dict[str, list[dict]]],
trading_days: list[str],
params: ORBStrategyParams,
enrichment: dict[str, dict[str, dict]],
ticker_sectors: dict[str, str] | None = None,
vix_by_day: dict[str, float] | None = None,
) -> list[DayResult]:
"""Run the full ORB backtest simulation across all trading days."""
results, _ = run_orb_simulation_with_state(
all_intraday,
trading_days,
params,
enrichment,
ticker_sectors=ticker_sectors,
vix_by_day=vix_by_day,
)
return results