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