|
|
"""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:30–9:35 ET bar)
|
|
|
3. Filter by direction: bullish only (long-only V1)
|
|
|
4. Apply quality filters from enrichment: price, ATR, dollar_vol
|
|
|
5. Compute approximate RVOL; filter by min_rvol
|
|
|
6. Compute gap% from prev_close
|
|
|
7. Rank by composite score: RVOL × w + gap × w + dollar_vol × w
|
|
|
8. Return top max_candidates
|
|
|
|
|
|
Args:
|
|
|
bars_by_ticker: {ticker: [bar_dict, ...]} for today.
|
|
|
date_str: Today's date as 'YYYY-MM-DD'.
|
|
|
params: ORB strategy parameters.
|
|
|
enrichment: {ticker: {date: features}} from enrich_daily_bars().
|
|
|
blacklisted_tickers: Tickers in cooldown period.
|
|
|
spy_bars: SPY intraday bars for market regime filter.
|
|
|
|
|
|
Returns:
|
|
|
List of candidate dicts, sorted by composite score descending, capped at max_candidates.
|
|
|
Each dict: {ticker, orb_bar, direction, rvol, gap_pct, atr, first_bar_dollar_vol, score, mkt_bars}
|
|
|
"""
|
|
|
market_open = _market_open_ts(date_str)
|
|
|
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 in {"stocks_in_play_dual_regime", "gainers_leader"}:
|
|
|
score += norm_event[i] * params.weight_event_catalyst
|
|
|
if engine_family == "stocks_in_play_dual_regime":
|
|
|
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
|