"""Core intraday simulation engine. DST-aware (uses zoneinfo America/New_York throughout). Pure functions — no API calls, no disk I/O. run_simulation() takes pre-loaded data and returns DayResult list, making sweep mode trivial (call once per parameter combination). """ from __future__ import annotations import datetime as dt from zoneinfo import ZoneInfo from libs.intraday.domain import DayResult, IntradayTrade, StrategyParams _ET = ZoneInfo("America/New_York") _MARKET_OPEN = dt.time(9, 30) # ET _MARKET_CLOSE = dt.time(16, 0) # ET _MIN_BARS = 5 # minimum market-hours bars required to simulate a stock # ── Timestamp Parsing ────────────────────────────────────────────────────── def _parse_ts(ts_str: str) -> dt.datetime: """Parse Alpaca ISO 8601 timestamp to timezone-aware ET datetime.""" s = ts_str.replace("Z", "+00:00") return dt.datetime.fromisoformat(s).astimezone(_ET) # ── Market Hours Filtering ───────────────────────────────────────────────── def filter_market_hours(bars: list[dict]) -> list[dict]: """Return only bars that fall within regular trading hours (9:30-16:00 ET). Handles DST transitions correctly via zoneinfo. """ result = [] for b in bars: ts = _parse_ts(b["timestamp"]) t = ts.time() if _MARKET_OPEN <= t < _MARKET_CLOSE: result.append(b) return result def _bar_at_offset( bars: list[dict], market_open_ts: dt.datetime, offset_minutes: int, tolerance_minutes: int = 7, ) -> dict | None: """Find the bar closest to (market_open + offset_minutes). Returns None if no bar is within tolerance_minutes of the target. """ target = market_open_ts + dt.timedelta(minutes=offset_minutes) best: dict | None = None best_diff = float("inf") for b in bars: ts = _parse_ts(b["timestamp"]) diff = abs((ts - target).total_seconds()) if diff < best_diff and diff <= tolerance_minutes * 60: best = b best_diff = diff return best def _market_open_ts(date_str: str) -> dt.datetime: """Return 9:30 AM ET datetime for the given date string.""" d = dt.date.fromisoformat(date_str) naive = dt.datetime.combine(d, _MARKET_OPEN) return naive.replace(tzinfo=_ET) def _volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float: """Sum volume of all bars up to and including entry_ts.""" total = 0.0 for b in bars: ts = _parse_ts(b["timestamp"]) if ts <= entry_ts: total += b.get("volume", 0) or 0 return total def _dollar_volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float: """Sum approximate dollar volume of all bars up to and including entry_ts.""" total = 0.0 for b in bars: ts = _parse_ts(b["timestamp"]) if ts <= entry_ts: close = b.get("close") or 0.0 volume = b.get("volume") or 0.0 total += float(close) * float(volume) return total def _linear_scaler( value: float | None, low: float | None, high: float | None, floor: float, *, invert: bool = False, ) -> float: """Piecewise-linear scaler bounded to [floor, 1.0]. When invert=False, values <= low map to 1.0 and values >= high map to floor. When invert=True, values <= low map to floor and values >= high map to 1.0. """ 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 _vix_day_scaler(vix_value: float | None, strategy: StrategyParams) -> float: return _linear_scaler( vix_value, strategy.vix_size_scale_low, strategy.vix_size_scale_high, strategy.vix_size_scale_min, ) def _entropy_trade_scaler(entropy_20d: float | None, strategy: StrategyParams) -> float: return _linear_scaler( entropy_20d, strategy.entropy_size_scale_low, strategy.entropy_size_scale_high, strategy.entropy_size_scale_min, ) def _sparse_day_scaler(selected_count: int, strategy: StrategyParams) -> float: threshold = strategy.full_size_positions_threshold if threshold is None or threshold <= 0: return 1.0 floor = max(0.0, min(1.0, strategy.sparse_day_size_floor)) if selected_count >= threshold: return 1.0 ratio = selected_count / threshold return max(floor, min(1.0, ratio)) def _safe_value(value: float | None, *, default: float = 0.0) -> float: return default if value is None else float(value) def _trade_trailing_stop_pct(info: dict, strategy: StrategyParams) -> float | None: """Return the per-trade trailing stop, tightening only overextended leaders.""" trailing_stop_pct = strategy.trailing_stop_pct if ( trailing_stop_pct is None or strategy.overextended_trailing_gain_pct is None or strategy.overextended_trailing_stop_pct is None ): return trailing_stop_pct gain_pct = info.get("gain_pct") if gain_pct is None or gain_pct < strategy.overextended_trailing_gain_pct: return trailing_stop_pct return strategy.overextended_trailing_stop_pct def _trade_catastrophic_stop_price(info: dict, strategy: StrategyParams) -> float | None: """Return the initial catastrophic stop price for a trade, if any.""" entry_price_raw = info.get("entry_price_raw") if entry_price_raw is None or entry_price_raw <= 0: return None if strategy.atr_stop_multiplier is not None: atr_14 = info.get("atr_14") if atr_14 is None or atr_14 <= 0: return None return max(0.0, float(entry_price_raw) - float(atr_14) * strategy.atr_stop_multiplier) if strategy.opening_range_stop_multiplier is not None: opening_range_width = info.get("opening_range_width") if opening_range_width is None or opening_range_width <= 0: return None return max( 0.0, float(entry_price_raw) - float(opening_range_width) * strategy.opening_range_stop_multiplier, ) if strategy.stop_loss_pct is not None: return max(0.0, float(entry_price_raw) * (1.0 + strategy.stop_loss_pct)) return None def _five_sleeve_specs(strategy: StrategyParams) -> list[dict[str, object]]: sleeves: list[dict[str, object]] = [ { "label": "core", "weight": strategy.five_sleeve_core_weight, "key_fn": lambda item: ( item[1]["gain_pct"], item[1].get("volume_ratio_14d", 0.0), item[1].get("entry_volume", 0.0), ), "component": lambda info: info["gain_pct"], }, { "label": "gap", "weight": strategy.five_sleeve_gap_weight, "key_fn": lambda item: ( _safe_value(item[1].get("gap_pct"), default=-999.0), item[1]["gain_pct"], item[1].get("volume_ratio_14d", 0.0), ), "component": lambda info: max(info.get("gap_pct") or 0.0, 0.0), }, { "label": "volume", "weight": strategy.five_sleeve_volume_weight, "key_fn": lambda item: ( item[1].get("volume_ratio_14d", 0.0), item[1].get("entry_volume", 0.0), item[1]["gain_pct"], ), "component": lambda info: info.get("volume_ratio_14d") or 0.0, }, { "label": "entropy", "weight": strategy.five_sleeve_entropy_weight, "key_fn": lambda item: ( -_safe_value(item[1].get("entropy_20d"), default=1.0), item[1]["gain_pct"], item[1].get("volume_ratio_14d", 0.0), ), "component": lambda info: ( 1.0 - info["entropy_20d"] if info.get("entropy_20d") is not None else 0.0 ), }, { "label": "trend", "weight": strategy.five_sleeve_trend_weight, "key_fn": lambda item: ( _safe_value(item[1].get("ret_5d"), default=-999.0), item[1]["gain_pct"], item[1].get("volume_ratio_14d", 0.0), ), "component": lambda info: max(info.get("ret_5d") or 0.0, 0.0), }, ] if strategy.use_slow_ignite_sleeve and strategy.slow_ignite_weight > 0: sleeves.append( { "label": "slow_ignite", "weight": strategy.slow_ignite_weight, "key_fn": lambda item: ( 1 if item[1].get("is_slow_ignite") else 0, item[1].get("confirmation_return_pct", -999.0), item[1].get("volume_ratio_14d", 0.0), _safe_value(item[1].get("ret_5d"), default=-999.0), item[1].get("entry_dollar_volume", 0.0), ), "component": lambda info: ( ( max(info.get("confirmation_return_pct") or 0.0, 0.0) * 5.0 + max(min(info.get("volume_ratio_14d") or 0.0, 0.5), 0.0) + max(min(info.get("ret_5d") or 0.0, 0.2), 0.0) ) if info.get("is_slow_ignite") else 0.0 ), } ) if strategy.use_liquid_largecap_sleeve and strategy.liquid_largecap_weight > 0: sleeves.append( { "label": "liquid_largecap", "weight": strategy.liquid_largecap_weight, "key_fn": lambda item: ( 1 if item[1].get("is_liquid_largecap") else 0, item[1].get("entry_dollar_volume", 0.0), item[1].get("confirmation_return_pct", -999.0), item[1].get("gain_pct", 0.0), item[1].get("avg_dollar_vol_30d", 0.0), ), "component": lambda info: ( ( min(max((info.get("entry_dollar_volume") or 0.0) / 500_000_000.0, 0.0), 4.0) + max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0) + max((info.get("gain_pct") or 0.0) * 10.0, 0.0) + min(max((info.get("avg_dollar_vol_30d") or 0.0) / 1_000_000_000.0, 0.0), 3.0) ) if info.get("is_liquid_largecap") else 0.0 ), } ) if strategy.use_gap_reclaim_sleeve and strategy.gap_reclaim_weight > 0: sleeves.append( { "label": "gap_reclaim", "weight": strategy.gap_reclaim_weight, "key_fn": lambda item: ( 1 if item[1].get("is_gap_reclaim") else 0, item[1].get("confirmation_return_pct", -999.0), item[1].get("recovery_from_opening_low_pct", 0.0), item[1].get("entry_dollar_volume", 0.0), item[1].get("gap_pct", 0.0), ), "component": lambda info: ( ( max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0) + max((info.get("recovery_from_opening_low_pct") or 0.0) * 20.0, 0.0) + min(max((info.get("entry_dollar_volume") or 0.0) / 250_000_000.0, 0.0), 4.0) + min(max((info.get("gap_pct") or 0.0) * 5.0, 0.0), 2.0) ) if info.get("is_gap_reclaim") else 0.0 ), } ) return sleeves def _select_momentum_sleeves( morning_gains: dict[str, dict], strategy: StrategyParams, ticker_sectors: dict[str, str] | None = None, ) -> list[tuple[str, str]]: """Return ordered (ticker, sleeve) picks for the day.""" if not morning_gains: return [] sector_cap = strategy.max_positions_per_sector if strategy.max_positions_per_sector and strategy.max_positions_per_sector > 0 else None sector_counts: dict[str, int] = {} def _sector_for_ticker(ticker: str) -> str | None: if not ticker_sectors: return None sector = str(ticker_sectors.get(ticker) or "").strip() if not sector or sector.upper() == "UNKNOWN": return None return sector def _can_pick_ticker(ticker: str) -> bool: if sector_cap is None: return True sector = _sector_for_ticker(ticker) if sector is None: return True return sector_counts.get(sector, 0) < sector_cap def _record_pick(ticker: str) -> None: if sector_cap is None: return sector = _sector_for_ticker(ticker) if sector is None: return sector_counts[sector] = sector_counts.get(sector, 0) + 1 if not strategy.use_five_sleeves: ranked = sorted( morning_gains.keys(), key=lambda t: ( morning_gains[t]["gain_pct"], morning_gains[t].get("entry_volume", 0.0), ), reverse=True, ) picks: list[tuple[str, str]] = [] for ticker in ranked: if not _can_pick_ticker(ticker): continue picks.append((ticker, "core")) _record_pick(ticker) if len(picks) >= strategy.top_n: break return picks sleeves = _five_sleeve_specs(strategy) picks: list[tuple[str, str]] = [] chosen: set[str] = set() items = list(morning_gains.items()) forced_sleeves = [ sleeve for sleeve in sorted(sleeves, key=lambda sleeve: float(sleeve["weight"]), reverse=True) if float(sleeve["weight"]) > 0 ][: max(0, min(strategy.five_sleeve_force_count, len(sleeves)))] for sleeve in forced_sleeves: key_fn = sleeve["key_fn"] ranked = sorted(items, key=key_fn, reverse=True) for ticker, _info in ranked: if ticker in chosen: continue if not _can_pick_ticker(ticker): continue picks.append((ticker, str(sleeve["label"]))) chosen.add(ticker) _record_pick(ticker) break if len(picks) >= strategy.top_n: return picks[: strategy.top_n] fallback_slots = max(0, int(getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0)) fallback_trigger = max(0, int(getattr(strategy, "fallback_liquid_largecap_trigger_below", 0) or 0)) if ( fallback_slots > 0 and len(picks) < strategy.top_n and len(picks) < fallback_trigger ): ranked_liquid = sorted( ( item for item in items if item[1].get("is_liquid_largecap") ), key=lambda item: ( item[1].get("entry_dollar_volume", 0.0), item[1].get("confirmation_return_pct", 0.0), item[1].get("gain_pct", 0.0), item[1].get("avg_dollar_vol_30d", 0.0), ), reverse=True, ) added = 0 for ticker, _info in ranked_liquid: if ticker in chosen: continue if not _can_pick_ticker(ticker): continue picks.append((ticker, "liquid_largecap_fallback")) chosen.add(ticker) _record_pick(ticker) added += 1 if len(picks) >= strategy.top_n or added >= fallback_slots: break def blended_score(item: tuple[str, dict]) -> float: _ticker, info = item score = 0.0 for sleeve in sleeves: weight = float(sleeve["weight"]) if weight <= 0: continue score += weight * float(sleeve["component"](info)) return score ranked_fill = sorted(items, key=blended_score, reverse=True) for ticker, _info in ranked_fill: if ticker in chosen: continue if not _can_pick_ticker(ticker): continue picks.append((ticker, "blend")) chosen.add(ticker) _record_pick(ticker) if len(picks) >= strategy.top_n: break return picks # ── Trade Simulation ─────────────────────────────────────────────────────── def _apply_slippage_entry(price: float, slippage_bps: float) -> float: """Long entry fill: price × (1 + bps/10000).""" return price * (1.0 + slippage_bps / 10_000) def _apply_slippage_exit(price: float, slippage_bps: float) -> float: """Long exit fill: price × (1 - bps/10000).""" return price * (1.0 - slippage_bps / 10_000) def simulate_trade( bars: list[dict], entry_bar: dict, entry_price_raw: float, exit_offset_minutes: int, stop_loss_pct: float | None, trailing_stop_pct: float | None, catastrophic_stop_price_raw: float | None, trailing_activation_gain_pct: float | None, slippage_bps: float, date_str: str, ) -> tuple[float, str, str]: """Simulate a single intraday trade. Supports catastrophic/fixed stops plus optional delayed trailing stops. Returns: (exit_price_after_slippage, exit_time_str, exit_reason) """ entry_price = _apply_slippage_entry(entry_price_raw, slippage_bps) entry_ts = _parse_ts(entry_bar["timestamp"]) # Compute exit target time market_close = _market_open_ts(date_str).replace(hour=16, minute=0) exit_target = market_close - dt.timedelta(minutes=exit_offset_minutes) exit_price_raw = entry_price_raw exit_time_str = entry_bar["timestamp"] exit_reason = "close" # Trailing stop state peak_price = entry_price_raw for b in bars: ts = _parse_ts(b["timestamp"]) if ts <= entry_ts: continue # Update peak for trailing stop if b["high"] > peak_price: peak_price = b["high"] peak_gain_pct = (peak_price - entry_price_raw) / entry_price_raw if entry_price_raw > 0 else 0.0 trailing_active = ( trailing_stop_pct is not None and ( trailing_activation_gain_pct is None or peak_gain_pct >= trailing_activation_gain_pct ) ) # Determine effective stop level if trailing_active: # Trailing: stop = peak × (1 + trailing_pct), trails upward stop_price = peak_price * (1.0 + trailing_stop_pct) # trailing_pct is negative low_price = b["low"] if low_price <= stop_price: exit_price_raw = stop_price exit_time_str = b["timestamp"] exit_reason = "trailing_stop" break else: stop_price = catastrophic_stop_price_raw if stop_price is None and stop_loss_pct is not None: stop_price = entry_price_raw * (1.0 + stop_loss_pct) if stop_price is not None and b["low"] <= stop_price: exit_price_raw = stop_price exit_time_str = b["timestamp"] exit_reason = "stop_loss" break # Check scheduled exit time if ts >= exit_target: exit_price_raw = b["close"] exit_time_str = b["timestamp"] exit_reason = "close" break # Update running exit (last bar before exit time) exit_price_raw = b["close"] exit_time_str = b["timestamp"] exit_price = _apply_slippage_exit(exit_price_raw, slippage_bps) return exit_price, exit_time_str, exit_reason # ── Morning Gain Computation ─────────────────────────────────────────────── def compute_morning_gains( bars_by_ticker: dict[str, list[dict]], strategy: StrategyParams, date_str: str, blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, daily_features_by_ticker: dict[str, dict] | None = None, vix_value: float | None = None, ) -> dict[str, dict]: """Compute each ticker's gain from open to entry time, applying all filters. Filters applied: - Minimum market-hours bars (_MIN_BARS) - min_morning_gain_pct: stock must be up enough to qualify - max_morning_gain_pct: cap extreme gap-ups that tend to mean-revert - min_entry_volume: require sufficient trading activity by entry time - blacklisted_tickers: tickers in cooldown period (recently traded) - market_regime_spy_threshold: skip if SPY is down too much Returns: {ticker: {gain_pct, entry_price_raw, entry_bar, mkt_bars, entry_volume}} """ market_open = _market_open_ts(date_str) # Market regime check: compute SPY's morning return if strategy.market_regime_spy_threshold is not None and spy_bars: spy_mkt = filter_market_hours(spy_bars) if len(spy_mkt) >= 2: spy_open = spy_mkt[0]["open"] spy_entry_bar = _bar_at_offset(spy_mkt, market_open, strategy.entry_minutes_after_open) if spy_open > 0 and spy_entry_bar is not None: spy_gain = (spy_entry_bar["close"] - spy_open) / spy_open if spy_gain < strategy.market_regime_spy_threshold: return {} # Skip this day entirely if strategy.max_vix is not None and vix_value is not None and vix_value > strategy.max_vix: return {} result = {} for ticker, all_bars in bars_by_ticker.items(): # Skip blacklisted tickers (cooldown) if blacklisted_tickers and ticker in blacklisted_tickers: continue mkt_bars = filter_market_hours(all_bars) if len(mkt_bars) < _MIN_BARS: continue open_price = mkt_bars[0]["open"] if open_price <= 0: continue initial_entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) if initial_entry_bar is None: continue entry_bar = initial_entry_bar if strategy.confirmation_minutes_after_entry > 0: confirmation_bar = _bar_at_offset( mkt_bars, market_open, strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry, ) if confirmation_bar is None: continue confirmation_return = ( confirmation_bar["close"] - initial_entry_bar["close"] ) / initial_entry_bar["close"] entry_bar = confirmation_bar else: confirmation_return = None entry_price_raw = entry_bar["close"] if entry_price_raw <= 0: continue gain_pct = (entry_price_raw - open_price) / open_price # volume filter: cumulative volume up to entry time entry_ts = _parse_ts(entry_bar["timestamp"]) entry_vol = _volume_up_to_bar(mkt_bars, entry_ts) if strategy.min_entry_volume is not None and entry_vol < strategy.min_entry_volume: continue entry_dollar_vol = _dollar_volume_up_to_bar(mkt_bars, entry_ts) if ( strategy.min_entry_dollar_volume is not None and entry_dollar_vol < strategy.min_entry_dollar_volume ): continue daily_features = (daily_features_by_ticker or {}).get(ticker, {}) gap_pct = daily_features.get("gap_pct") gap_min_ok = ( strategy.min_gap_pct is None or (gap_pct is not None and gap_pct >= strategy.min_gap_pct) ) gap_max_ok = ( strategy.max_gap_pct is None or (gap_pct is not None and gap_pct <= strategy.max_gap_pct) ) volume_ratio_14d = None avg_daily_vol_14d = daily_features.get("avg_daily_vol_14d") if avg_daily_vol_14d and avg_daily_vol_14d > 0: volume_ratio_14d = entry_vol / avg_daily_vol_14d if ( strategy.min_volume_ratio_14d is not None and (volume_ratio_14d is None or volume_ratio_14d < strategy.min_volume_ratio_14d) ): continue ret_5d = daily_features.get("ret_5d") if strategy.min_ret_5d is not None and (ret_5d is None or ret_5d < strategy.min_ret_5d): continue entropy_20d = daily_features.get("entropy_20d") avg_dollar_vol_30d = daily_features.get("avg_dollar_vol_30d") atr_14 = daily_features.get("atr_14") if strategy.min_entropy_20d is not None and (entropy_20d is None or entropy_20d < strategy.min_entropy_20d): continue global_max_entropy_ok = True if strategy.max_entropy_20d is not None and (entropy_20d is None or entropy_20d > strategy.max_entropy_20d): global_max_entropy_ok = False opening_range_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts] if not opening_range_bars: continue opening_range_high = max(float(bar["high"]) for bar in opening_range_bars) opening_range_low = min(float(bar["low"]) for bar in opening_range_bars) opening_range_width = max(0.0, opening_range_high - opening_range_low) recovery_from_opening_low_pct = ( (entry_price_raw - opening_range_low) / opening_range_low if opening_range_low > 0 else None ) if strategy.atr_stop_multiplier is not None and (atr_14 is None or atr_14 <= 0): continue if strategy.opening_range_stop_multiplier is not None and opening_range_width <= 0: continue confirmation_ok = ( strategy.min_confirmation_return_pct is None or confirmation_return is None or confirmation_return >= strategy.min_confirmation_return_pct ) regular_ok = gap_min_ok and gap_max_ok and global_max_entropy_ok and confirmation_ok and gain_pct >= strategy.min_morning_gain_pct and ( strategy.max_morning_gain_pct is None or gain_pct <= strategy.max_morning_gain_pct ) slow_ignite_ok = False if ( strategy.use_slow_ignite_sleeve and gap_min_ok and gap_max_ok and global_max_entropy_ok and confirmation_ok and gain_pct < strategy.min_morning_gain_pct ): if strategy.slow_ignite_min_gain_pct is not None and gain_pct < strategy.slow_ignite_min_gain_pct: pass elif strategy.slow_ignite_max_gain_pct is not None and gain_pct > strategy.slow_ignite_max_gain_pct: pass elif ( strategy.slow_ignite_min_entry_dollar_volume is not None and entry_dollar_vol < strategy.slow_ignite_min_entry_dollar_volume ): pass elif ( strategy.slow_ignite_min_volume_ratio_14d is not None and (volume_ratio_14d is None or volume_ratio_14d < strategy.slow_ignite_min_volume_ratio_14d) ): pass elif ( strategy.slow_ignite_min_ret_5d is not None and (ret_5d is None or ret_5d < strategy.slow_ignite_min_ret_5d) ): pass elif ( strategy.slow_ignite_max_entropy_20d is not None and (entropy_20d is None or entropy_20d > strategy.slow_ignite_max_entropy_20d) ): pass else: slow_ignite_ok = True liquid_largecap_ok = False liquid_largecap_enabled = ( strategy.use_liquid_largecap_sleeve or (getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0) > 0 ) if liquid_largecap_enabled and gap_min_ok and gap_max_ok and confirmation_ok: liquid_largecap_entropy_cap = strategy.liquid_largecap_max_entropy_20d if liquid_largecap_entropy_cap is None: liquid_largecap_entropy_cap = strategy.max_entropy_20d if ( strategy.liquid_largecap_min_gain_pct is not None and gain_pct < strategy.liquid_largecap_min_gain_pct ): pass elif ( strategy.liquid_largecap_max_gain_pct is not None and gain_pct > strategy.liquid_largecap_max_gain_pct ): pass elif ( strategy.liquid_largecap_min_confirmation_return_pct is not None and confirmation_return < strategy.liquid_largecap_min_confirmation_return_pct ): pass elif ( strategy.liquid_largecap_min_entry_dollar_volume is not None and entry_dollar_vol < strategy.liquid_largecap_min_entry_dollar_volume ): pass elif ( strategy.liquid_largecap_min_avg_dollar_vol_30d is not None and ( avg_dollar_vol_30d is None or avg_dollar_vol_30d < strategy.liquid_largecap_min_avg_dollar_vol_30d ) ): pass elif ( liquid_largecap_entropy_cap is not None and (entropy_20d is None or entropy_20d > liquid_largecap_entropy_cap) ): pass else: liquid_largecap_ok = True gap_reclaim_ok = False if strategy.use_gap_reclaim_sleeve: if strategy.gap_reclaim_min_gap_pct is not None and ( gap_pct is None or gap_pct < strategy.gap_reclaim_min_gap_pct ): pass elif strategy.gap_reclaim_min_gain_pct is not None and gain_pct < strategy.gap_reclaim_min_gain_pct: pass elif strategy.gap_reclaim_max_gain_pct is not None and gain_pct > strategy.gap_reclaim_max_gain_pct: pass elif ( strategy.gap_reclaim_min_confirmation_return_pct is not None and ( confirmation_return is None or confirmation_return < strategy.gap_reclaim_min_confirmation_return_pct ) ): pass elif ( strategy.gap_reclaim_min_entry_dollar_volume is not None and entry_dollar_vol < strategy.gap_reclaim_min_entry_dollar_volume ): pass elif ( strategy.gap_reclaim_min_recovery_from_opening_low_pct is not None and ( recovery_from_opening_low_pct is None or recovery_from_opening_low_pct < strategy.gap_reclaim_min_recovery_from_opening_low_pct ) ): pass else: gap_reclaim_ok = True if not regular_ok and not slow_ignite_ok and not liquid_largecap_ok and not gap_reclaim_ok: continue result[ticker] = { "gain_pct": gain_pct, "entry_price_raw": entry_price_raw, "entry_bar": entry_bar, "mkt_bars": mkt_bars, "entry_volume": entry_vol, "entry_dollar_volume": entry_dollar_vol, "gap_pct": gap_pct, "volume_ratio_14d": volume_ratio_14d, "ret_5d": ret_5d, "entropy_20d": entropy_20d, "avg_dollar_vol_30d": avg_dollar_vol_30d, "atr_14": atr_14, "opening_range_width": opening_range_width, "recovery_from_opening_low_pct": recovery_from_opening_low_pct, "confirmation_return_pct": confirmation_return, "is_slow_ignite": slow_ignite_ok, "is_liquid_largecap": liquid_largecap_ok, "is_gap_reclaim": gap_reclaim_ok, } return result # ── Day Simulation ───────────────────────────────────────────────────────── def simulate_day( bars_by_ticker: dict[str, list[dict]], date_str: str, strategy: StrategyParams, blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, daily_features_by_ticker: dict[str, dict] | None = None, vix_value: float | None = None, current_equity: float | None = None, ticker_sectors: dict[str, str] | None = None, ) -> DayResult: """Simulate one full trading day. 1. Apply all filters to find qualified morning gainers. 2. Rank by gain, pick top N. 3. Simulate each trade with stop-loss / trailing stop. 4. Compute daily P&L. Args: current_equity: Current portfolio equity for compound position sizing. When strategy.compound_returns=True and this is provided, position sizes scale with current equity. Otherwise uses strategy.initial_capital (simple/단리 mode). """ result = DayResult(date=date_str) morning_gains = compute_morning_gains( bars_by_ticker, strategy, date_str, blacklisted_tickers=blacklisted_tickers, spy_bars=spy_bars, daily_features_by_ticker=daily_features_by_ticker, vix_value=vix_value, ) result.candidates_found = len(morning_gains) if not morning_gains: return result top_tickers = _select_momentum_sleeves(morning_gains, strategy, ticker_sectors=ticker_sectors) if not top_tickers: return result if len(top_tickers) < max(1, strategy.min_positions_to_trade): return result if strategy.daily_budget_reset: # Research mode: every day resets to initial_capital (ignore prior-day PnL). sizing_capital = strategy.initial_capital elif strategy.compound_returns and current_equity is not None: sizing_capital = max(current_equity, 0.0) elif current_equity is not None: # Simple mode: fixed at initial_capital, but cannot exceed actual equity # (can't invest money you don't have after drawdowns). sizing_capital = min(strategy.initial_capital, max(current_equity, 0.0)) else: sizing_capital = strategy.initial_capital capital_budget = ( sizing_capital * _vix_day_scaler(vix_value, strategy) * _sparse_day_scaler(len(top_tickers), strategy) ) capital_per_trade = capital_budget / len(top_tickers) for ticker, sleeve in top_tickers: info = morning_gains[ticker] entry_price_raw = info["entry_price_raw"] entry_bar = info["entry_bar"] mkt_bars = info["mkt_bars"] exit_price, exit_time_str, exit_reason = simulate_trade( mkt_bars, entry_bar, entry_price_raw, strategy.exit_minutes_before_close, strategy.stop_loss_pct, _trade_trailing_stop_pct(info, strategy), _trade_catastrophic_stop_price(info, strategy), strategy.trailing_activation_gain_pct, strategy.slippage_bps, date_str, ) entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps) trade_capital = capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy) shares = trade_capital / entry_price_filled pnl_pct = (exit_price - entry_price_filled) / entry_price_filled pnl = pnl_pct * trade_capital slippage_cost = ( (entry_price_filled - entry_price_raw) + (entry_price_raw * strategy.slippage_bps / 10_000) ) * shares trade = 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(info["gain_pct"], 6), slippage_cost=round(slippage_cost, 4), trade_sleeve=sleeve, ) result.trades.append(trade) result.daily_pnl += trade.pnl if result.trades: total_deployed = sum(t.shares * t.entry_price for t in result.trades) if total_deployed > 0: result.daily_return_pct = result.daily_pnl / total_deployed return result # ── Full Backtest Simulation ─────────────────────────────────────────────── def run_simulation( all_intraday: dict[str, dict[str, list[dict]]], trading_days: list[str], strategy: StrategyParams, *, daily_enrichment: dict[str, dict[str, dict]] | None = None, vix_by_day: dict[str, float] | None = None, ticker_sectors: dict[str, str] | None = None, ) -> list[DayResult]: """Run the full backtest simulation across all trading days. Pure computation — no API calls, no disk I/O. Safe to call repeatedly with different strategy params for sweep mode. Implements: - Ticker cooldown (blackout period after trading a ticker) - Market regime filter via SPY bars - All strategy filters (max gain, min volume, trailing stop, etc.) Args: all_intraday: {date: {ticker: [bars]}} — pre-loaded intraday data. trading_days: Ordered list of dates to simulate. strategy: Strategy parameters. Returns: List of DayResult objects (one per trading day; days without intraday data get a 0% return result). """ results: list[DayResult] = [] # Ticker cooldown: map ticker -> last traded date ticker_last_traded: dict[str, dt.date] = {} # Compound return tracking: equity grows with each day's P&L equity = strategy.initial_capital for date_str in 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 # Build blacklist from cooldown blacklisted: set[str] = set() if strategy.ticker_cooldown_days > 0: current_date = dt.date.fromisoformat(date_str) for ticker, last_dt in ticker_last_traded.items(): days_since = (current_date - last_dt).days if days_since <= strategy.ticker_cooldown_days: blacklisted.add(ticker) # Extract SPY bars for regime filter spy_bars = bars_by_ticker.get("SPY") if strategy.market_regime_spy_threshold is not None else None day_result = simulate_day( bars_by_ticker, date_str, strategy, blacklisted_tickers=blacklisted if blacklisted else None, spy_bars=spy_bars, daily_features_by_ticker=( { ticker: daily_enrichment.get(ticker, {}).get(date_str, {}) for ticker in bars_by_ticker.keys() } if daily_enrichment else None ), vix_value=(vix_by_day or {}).get(date_str), current_equity=equity, ticker_sectors=ticker_sectors, ) results.append(day_result) equity += day_result.daily_pnl # Update cooldown tracker if strategy.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