"""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 import math 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 SECTOR_PROXY_BY_LABEL: dict[str, str] = { "Basic Materials": "XLB", "Communication Services": "XLC", "Consumer Cyclical": "XLY", "Consumer Defensive": "XLP", "Consumer Staples": "XLP", "Energy": "XLE", "Financial Services": "XLF", "Financial": "XLF", "Healthcare": "XLV", "Industrials": "XLI", "Real Estate": "XLRE", "Technology": "XLK", "Utilities": "XLU", } SECTOR_PROXY_TICKERS: tuple[str, ...] = tuple(sorted(set(SECTOR_PROXY_BY_LABEL.values()))) # ── 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 _intraday_regime_failed( strategy: StrategyParams, spy_bars: list[dict] | None, date_str: str, ) -> bool: if strategy.market_regime_spy_threshold is None or not spy_bars: return False spy_mkt = filter_market_hours(spy_bars) if len(spy_mkt) < 2: return False spy_open = spy_mkt[0]["open"] spy_entry_bar = _bar_at_offset( spy_mkt, _market_open_ts(date_str), strategy.entry_minutes_after_open, ) if spy_open <= 0 or spy_entry_bar is None: return False spy_gain = (spy_entry_bar["close"] - spy_open) / spy_open return spy_gain < strategy.market_regime_spy_threshold def _day_regime_scaler( strategy: StrategyParams, daily_features_by_ticker: dict[str, dict] | None, ) -> tuple[float, str | None]: if not any( value is not None and value != default for value, default in [ (strategy.market_regime_gap_threshold, None), (strategy.regime_size_scale_low, None), (strategy.regime_size_scale_high, None), (strategy.regime_skip_below, None), ] ): return 1.0, None regime_ticker = strategy.market_regime_gap_ticker or "SPY" regime_features = (daily_features_by_ticker or {}).get(regime_ticker, {}) prev_close = regime_features.get("prev_close") today_open = regime_features.get("today_open") if not prev_close or not today_open or prev_close <= 0: return 1.0, None regime_gap = (today_open - prev_close) / prev_close if strategy.regime_skip_below is not None and regime_gap < strategy.regime_skip_below: return 1.0, "market_regime" if ( strategy.regime_size_scale_low is None and strategy.market_regime_gap_threshold is not None and regime_gap < strategy.market_regime_gap_threshold ): return 1.0, "market_regime" scaler = _linear_scaler( regime_gap, strategy.regime_size_scale_low, strategy.regime_size_scale_high, strategy.regime_size_scale_min, invert=True, ) return scaler, None def _day_breadth_scaler( strategy: StrategyParams, bars_by_ticker: dict[str, list[dict]], daily_features_by_ticker: dict[str, dict] | None, ) -> tuple[float, str | None]: if not any( value is not None and value != default for value, default in [ (strategy.min_candidate_breadth, None), (strategy.breadth_size_scale_low, None), (strategy.breadth_size_scale_high, None), (strategy.breadth_skip_below, None), ] ): return 1.0, None pos_gap_count = 0 total_with_data = 0 for ticker in bars_by_ticker: ticker_day = (daily_features_by_ticker or {}).get(ticker, {}) prev_close = ticker_day.get("prev_close") today_open = ticker_day.get("today_open") if prev_close and today_open and prev_close > 0: total_with_data += 1 if today_open > prev_close: pos_gap_count += 1 if total_with_data <= 0: return 1.0, None breadth_ratio = pos_gap_count / total_with_data if strategy.breadth_skip_below is not None and breadth_ratio < strategy.breadth_skip_below: return 1.0, "breadth" if ( strategy.breadth_size_scale_low is None and strategy.min_candidate_breadth is not None and breadth_ratio < strategy.min_candidate_breadth ): return 1.0, "breadth" scaler = _linear_scaler( breadth_ratio, strategy.breadth_size_scale_low, strategy.breadth_size_scale_high, strategy.breadth_size_scale_min, invert=True, ) return scaler, None def _basket_sector_scaler( picks: list[tuple[str, str]], ticker_sectors: dict[str, str] | None, strategy: StrategyParams, ) -> float: if not picks or not ticker_sectors: return 1.0 counts: dict[str, int] = {} known = 0 for ticker, _sleeve in picks: sector = str(ticker_sectors.get(ticker) or "").strip() if not sector or sector.upper() == "UNKNOWN": continue counts[sector] = counts.get(sector, 0) + 1 known += 1 if known <= 1 or not counts: return 1.0 concentration = max(counts.values()) / known return _linear_scaler( concentration, strategy.sector_concentration_scale_low, strategy.sector_concentration_scale_high, strategy.sector_concentration_scale_min, ) 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_event_sleeve and strategy.event_weight > 0: sleeves.append( { "label": "event", "weight": strategy.event_weight, "key_fn": lambda item: ( 1 if item[1].get("is_event_candidate") else 0, item[1].get("event_score", 0.0), item[1].get("confirmation_return_pct", -999.0), item[1].get("entry_dollar_volume", 0.0), item[1].get("gain_pct", 0.0), ), "component": lambda info: ( ( min(max(info.get("event_score") or 0.0, 0.0), 3.0) + max((info.get("confirmation_return_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, ) + max((info.get("gain_pct") or 0.0) * 10.0, 0.0) ) if info.get("is_event_candidate") else 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_moderate_gap_liquid_sleeve and strategy.moderate_gap_liquid_weight > 0: sleeves.append( { "label": "moderate_gap_liquid", "weight": strategy.moderate_gap_liquid_weight, "key_fn": lambda item: ( 1 if item[1].get("is_moderate_gap_liquid") else 0, item[1].get("confirmation_return_pct", -999.0), item[1].get("entry_dollar_volume", 0.0), item[1].get("gain_pct", 0.0), -_safe_value(item[1].get("entropy_20d"), default=1.0), item[1].get("avg_dollar_vol_30d", 0.0), ), "component": lambda info: ( ( max((info.get("confirmation_return_pct") or 0.0) * 15.0, 0.0) + max((info.get("gain_pct") or 0.0) * 8.0, 0.0) + min(max((info.get("entry_dollar_volume") or 0.0) / 150_000_000.0, 0.0), 3.0) + min(max((info.get("avg_dollar_vol_30d") or 0.0) / 750_000_000.0, 0.0), 2.5) + max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0) ) if info.get("is_moderate_gap_liquid") else 0.0 ), } ) if strategy.use_sector_thrust_sleeve and strategy.sector_thrust_weight > 0: sleeves.append( { "label": "sector_thrust", "weight": strategy.sector_thrust_weight, "key_fn": lambda item: ( 1 if item[1].get("is_sector_thrust") else 0, item[1].get("sector_thrust_score", 0.0), item[1].get("sector_thrust_member_count", 0), item[1].get("entry_dollar_volume", 0.0), item[1].get("confirmation_return_pct", -999.0), item[1].get("gain_pct", 0.0), ), "component": lambda info: ( float(info.get("sector_thrust_score") or 0.0) if info.get("is_sector_thrust") 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 _momentum_quality_score(info: dict, strategy: StrategyParams) -> float: if strategy.use_five_sleeves: score = 0.0 for sleeve in _five_sleeve_specs(strategy): weight = float(sleeve["weight"]) if weight <= 0: continue score += weight * float(sleeve["component"](info)) return score return ( max(float(info.get("gain_pct") or 0.0), 0.0) * 5.0 + max(float(info.get("confirmation_return_pct") or 0.0), 0.0) * 10.0 + min(max(float(info.get("volume_ratio_14d") or 0.0), 0.0), 0.5) + min(max(float(info.get("entry_dollar_volume") or 0.0) / 50_000_000.0, 0.0), 4.0) + max(float(info.get("ret_5d") or 0.0), 0.0) + max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0) ) def _clip_unit_score(value: float | None, cap: float) -> float: if value is None or cap <= 0: return 0.0 return min(max(float(value), 0.0), cap) / cap def _clip_log_score(value: float | None, low: float, high: float) -> float: if value is None or value <= 0 or low <= 0 or high <= low: return 0.0 scaled = (math.log10(float(value)) - math.log10(low)) / ( math.log10(high) - math.log10(low) ) return min(max(scaled, 0.0), 1.0) def _round_optional(value: object, ndigits: int) -> float | None: if value is None: return None try: return round(float(value), ndigits) except (TypeError, ValueError): return None def _same_day_support_score(info: dict) -> float: """Blend liquidity and same-day attention into one support score. This is intentionally conservative: a thin single-name move only receives meaningful support when both prior liquidity and entry-time liquidity are decent, or when there is unusually strong same-day attention. """ if bool(info.get("is_liquid_largecap")): return 1.0 prior_liquidity = _clip_log_score( info.get("avg_dollar_vol_30d"), 30_000_000.0, 300_000_000.0, ) entry_liquidity = _clip_log_score( info.get("entry_dollar_volume"), 5_000_000.0, 50_000_000.0, ) liquidity_support = min(prior_liquidity, entry_liquidity) attention_support = max( _clip_unit_score(info.get("attention_wiki_spike_10d"), 5.0), _clip_unit_score( max( int(info.get("attention_article_count_3d") or 0), int(info.get("attention_us_article_count_3d") or 0), ), 3.0, ), ) catalyst_support = _clip_unit_score(info.get("event_score"), 1.25) * 0.25 return max(liquidity_support, attention_support, catalyst_support) def sector_proxy_ticker_for_sector(sector: str | None) -> str | None: if sector is None: return None normalized = str(sector).strip() if not normalized or normalized.upper() == "UNKNOWN": return None return SECTOR_PROXY_BY_LABEL.get(normalized) def _liquid_cluster_overlay_enabled(strategy: StrategyParams) -> bool: return bool(strategy.use_liquid_cluster_engine or strategy.use_sector_etf_sleeve) def _event_day_liquid_overlay_enabled(strategy: StrategyParams) -> bool: return bool(strategy.use_event_day_liquid_sleeve) def _event_day_liquid_activation_stats( morning_gains: dict[str, dict], strategy: StrategyParams, ) -> dict[str, float | int | bool]: contributors: list[dict] = [] min_event_score = getattr(strategy, "event_day_liquid_min_event_score", None) min_support_score = getattr(strategy, "event_day_liquid_min_event_support_score", None) allowed_event_types = { str(value).strip().lower() for value in getattr(strategy, "event_day_liquid_allowed_event_types", []) if str(value).strip() } for info in morning_gains.values(): raw_event_flag = bool(info.get("raw_event_flag", info.get("event_flag"))) if not raw_event_flag: continue event_score = float(info.get("raw_event_score", info.get("event_score")) or 0.0) if allowed_event_types: event_types = { str(value).strip().lower() for value in (info.get("event_types") or []) if str(value).strip() } if not event_types or not any(event_type in allowed_event_types for event_type in event_types): continue elif not bool(info.get("event_flag")): continue if min_event_score is not None and event_score < float(min_event_score): continue support_score = _same_day_support_score(info) if min_support_score is not None and support_score < float(min_support_score): continue contributors.append( { "event_score": event_score, "support_score": support_score, "entry_dollar_volume": float(info.get("entry_dollar_volume") or 0.0), } ) if not contributors: return { "qualifies": False, "event_count": 0, "max_event_score": 0.0, "max_support_score": 0.0, "total_entry_dollar_volume": 0.0, } event_count = len(contributors) total_entry_dollar_volume = sum(item["entry_dollar_volume"] for item in contributors) qualifies = event_count >= max(1, int(strategy.event_day_liquid_min_event_names or 1)) if ( qualifies and strategy.event_day_liquid_min_total_event_entry_dollar_volume is not None and total_entry_dollar_volume < float(strategy.event_day_liquid_min_total_event_entry_dollar_volume) ): qualifies = False return { "qualifies": qualifies, "event_count": event_count, "max_event_score": max(item["event_score"] for item in contributors), "max_support_score": max(item["support_score"] for item in contributors), "total_entry_dollar_volume": total_entry_dollar_volume, } def _event_day_liquid_pick_score( info: dict, ) -> tuple[float, float, float, float, float, float, float]: entropy_20d = info.get("entropy_20d") return ( 1.0 if info.get("is_moderate_gap_liquid") else 0.0, 1.0 if info.get("is_liquid_largecap") else 0.0, _same_day_support_score(info), float(info.get("confirmation_return_pct") or 0.0), float(info.get("entry_dollar_volume") or 0.0), float(info.get("avg_dollar_vol_30d") or 0.0), -(float(entropy_20d) if entropy_20d is not None else 1.0), ) def _passes_liquid_cluster_own_gate( strategy: StrategyParams, *, gain_pct: float, confirmation_return_pct: float | None, entry_dollar_volume: float, avg_dollar_vol_30d: float | None, volume_ratio_14d: float | None, entropy_20d: float | None, is_moderate_gap_liquid: bool, is_liquid_largecap: bool, ) -> bool: if ( strategy.liquid_cluster_require_special_liquidity_gate and not (is_moderate_gap_liquid or is_liquid_largecap) ): return False if ( strategy.liquid_cluster_min_gain_pct is not None and gain_pct < strategy.liquid_cluster_min_gain_pct ): return False if ( strategy.liquid_cluster_max_gain_pct is not None and gain_pct > strategy.liquid_cluster_max_gain_pct ): return False if ( strategy.liquid_cluster_min_confirmation_return_pct is not None and ( confirmation_return_pct is None or confirmation_return_pct < strategy.liquid_cluster_min_confirmation_return_pct ) ): return False if ( strategy.liquid_cluster_min_entry_dollar_volume is not None and entry_dollar_volume < strategy.liquid_cluster_min_entry_dollar_volume ): return False if ( strategy.liquid_cluster_min_avg_dollar_vol_30d is not None and ( avg_dollar_vol_30d is None or avg_dollar_vol_30d < strategy.liquid_cluster_min_avg_dollar_vol_30d ) ): return False if ( strategy.liquid_cluster_max_avg_dollar_vol_30d is not None and ( avg_dollar_vol_30d is None or avg_dollar_vol_30d > strategy.liquid_cluster_max_avg_dollar_vol_30d ) ): return False if ( strategy.liquid_cluster_min_volume_ratio_14d is not None and ( volume_ratio_14d is None or volume_ratio_14d < strategy.liquid_cluster_min_volume_ratio_14d ) ): return False if ( strategy.liquid_cluster_max_entropy_20d is not None and ( entropy_20d is None or entropy_20d > strategy.liquid_cluster_max_entropy_20d ) ): return False return True def _annotate_sector_thrust_features( morning_gains: dict[str, dict], strategy: StrategyParams, ticker_sectors: dict[str, str] | None, ) -> dict[str, dict]: """Annotate PEAD-style sector breadth-confirmation features.""" annotated = {ticker: dict(info) for ticker, info in morning_gains.items()} for info in annotated.values(): info.setdefault("is_sector_thrust", False) info.setdefault("sector_thrust_member_count", 0) info.setdefault("sector_thrust_total_entry_dollar_volume", 0.0) info.setdefault("sector_thrust_avg_confirmation_return_pct", 0.0) info.setdefault("sector_thrust_score", 0.0) if not annotated or not strategy.use_sector_thrust_sleeve or not ticker_sectors: return annotated def _sector_for_ticker(ticker: str) -> str | None: sector = str(ticker_sectors.get(ticker) or "").strip() if not sector or sector.upper() == "UNKNOWN": return None return sector def _passes_own_gate(info: dict) -> bool: gain_pct = float(info.get("gain_pct") or 0.0) confirmation_return_pct = float(info.get("confirmation_return_pct") or 0.0) entry_dollar_volume = float(info.get("entry_dollar_volume") or 0.0) avg_dollar_vol_30d = float(info.get("avg_dollar_vol_30d") or 0.0) if ( strategy.sector_thrust_min_gain_pct is not None and gain_pct < strategy.sector_thrust_min_gain_pct ): return False if ( strategy.sector_thrust_min_confirmation_return_pct is not None and confirmation_return_pct < strategy.sector_thrust_min_confirmation_return_pct ): return False if ( strategy.sector_thrust_min_entry_dollar_volume is not None and entry_dollar_volume < strategy.sector_thrust_min_entry_dollar_volume ): return False if ( strategy.sector_thrust_min_avg_dollar_vol_30d is not None and avg_dollar_vol_30d < strategy.sector_thrust_min_avg_dollar_vol_30d ): return False return True sector_members: dict[str, list[tuple[str, dict]]] = {} for ticker, info in annotated.items(): sector = _sector_for_ticker(ticker) if sector is None or not _passes_own_gate(info): continue sector_members.setdefault(sector, []).append((ticker, info)) min_members = max(1, int(strategy.sector_thrust_min_members or 1)) sector_stats: dict[str, dict[str, float | bool | set[str]]] = {} for sector, members in sector_members.items(): member_count = len(members) avg_confirmation = sum( float(info.get("confirmation_return_pct") or 0.0) for _ticker, info in members ) / member_count total_entry_dollar_volume = sum( float(info.get("entry_dollar_volume") or 0.0) for _ticker, info in members ) qualifies = member_count >= min_members if ( strategy.sector_thrust_min_sector_avg_confirmation_return_pct is not None and avg_confirmation < strategy.sector_thrust_min_sector_avg_confirmation_return_pct ): qualifies = False if ( strategy.sector_thrust_min_sector_total_entry_dollar_volume is not None and total_entry_dollar_volume < strategy.sector_thrust_min_sector_total_entry_dollar_volume ): qualifies = False sector_stats[sector] = { "member_count": float(member_count), "avg_confirmation_return_pct": avg_confirmation, "total_entry_dollar_volume": total_entry_dollar_volume, "qualifies": qualifies, "tickers": {ticker for ticker, _info in members}, } for ticker, info in annotated.items(): sector = _sector_for_ticker(ticker) if sector is None: continue stats = sector_stats.get(sector) if not stats: continue info["sector_thrust_member_count"] = int(stats["member_count"]) info["sector_thrust_total_entry_dollar_volume"] = float( stats["total_entry_dollar_volume"] ) info["sector_thrust_avg_confirmation_return_pct"] = float( stats["avg_confirmation_return_pct"] ) if not bool(stats["qualifies"]) or ticker not in stats["tickers"]: continue count_score = _clip_unit_score(stats["member_count"], 5.0) avg_confirmation_score = _clip_unit_score( stats["avg_confirmation_return_pct"], 0.015, ) total_entry_dollar_volume_score = _clip_log_score( stats["total_entry_dollar_volume"], 50_000_000.0, 2_000_000_000.0, ) own_confirmation_score = _clip_unit_score( info.get("confirmation_return_pct"), 0.02, ) own_entry_dollar_volume_score = _clip_log_score( info.get("entry_dollar_volume"), 10_000_000.0, 500_000_000.0, ) info["is_sector_thrust"] = True info["sector_thrust_score"] = ( 0.30 * count_score + 0.20 * avg_confirmation_score + 0.20 * total_entry_dollar_volume_score + 0.20 * own_confirmation_score + 0.10 * own_entry_dollar_volume_score ) return annotated def _annotate_liquid_cluster_features( morning_gains: dict[str, dict], strategy: StrategyParams, ticker_sectors: dict[str, str] | None, ) -> tuple[dict[str, dict], dict[str, dict[str, object]]]: """Annotate separate post-allocation liquid-cluster overlay features.""" annotated = {ticker: dict(info) for ticker, info in morning_gains.items()} for info in annotated.values(): info.setdefault("is_liquid_cluster", False) info.setdefault("liquid_cluster_member_count", 0) info.setdefault("liquid_cluster_total_entry_dollar_volume", 0.0) info.setdefault("liquid_cluster_sector", None) info.setdefault("liquid_cluster_sector_score", 0.0) info.setdefault("liquid_cluster_score", 0.0) info.setdefault("sector_proxy_ticker", None) if not annotated or not ticker_sectors or not _liquid_cluster_overlay_enabled(strategy): return annotated, {} def _sector_for_ticker(ticker: str) -> str | None: sector = str(ticker_sectors.get(ticker) or "").strip() if not sector or sector.upper() == "UNKNOWN": return None return sector def _passes_own_gate(info: dict) -> bool: return _passes_liquid_cluster_own_gate( strategy, gain_pct=float(info.get("gain_pct") or 0.0), confirmation_return_pct=info.get("confirmation_return_pct"), entry_dollar_volume=float(info.get("entry_dollar_volume") or 0.0), avg_dollar_vol_30d=info.get("avg_dollar_vol_30d"), volume_ratio_14d=info.get("volume_ratio_14d"), entropy_20d=info.get("entropy_20d"), is_moderate_gap_liquid=bool(info.get("is_moderate_gap_liquid")), is_liquid_largecap=bool(info.get("is_liquid_largecap")), ) sector_members: dict[str, list[tuple[str, dict]]] = {} for ticker, info in annotated.items(): sector = _sector_for_ticker(ticker) if sector is None or not _passes_own_gate(info): continue sector_members.setdefault(sector, []).append((ticker, info)) min_members = max(1, int(strategy.liquid_cluster_min_members or 1)) sector_stats: dict[str, dict[str, object]] = {} for sector, members in sector_members.items(): member_count = len(members) avg_confirmation = sum( float(info.get("confirmation_return_pct") or 0.0) for _ticker, info in members ) / member_count total_entry_dollar_volume = sum( float(info.get("entry_dollar_volume") or 0.0) for _ticker, info in members ) qualifies = member_count >= min_members if ( strategy.liquid_cluster_min_sector_avg_confirmation_return_pct is not None and avg_confirmation < strategy.liquid_cluster_min_sector_avg_confirmation_return_pct ): qualifies = False if ( strategy.liquid_cluster_min_sector_total_entry_dollar_volume is not None and total_entry_dollar_volume < strategy.liquid_cluster_min_sector_total_entry_dollar_volume ): qualifies = False count_score = _clip_unit_score(member_count, 5.0) avg_confirmation_score = _clip_unit_score(avg_confirmation, 0.015) total_entry_dollar_volume_score = _clip_log_score( total_entry_dollar_volume, 100_000_000.0, 5_000_000_000.0, ) sector_score = ( 0.35 * count_score + 0.30 * avg_confirmation_score + 0.35 * total_entry_dollar_volume_score ) sector_stats[sector] = { "member_count": float(member_count), "avg_confirmation_return_pct": avg_confirmation, "total_entry_dollar_volume": total_entry_dollar_volume, "qualifies": qualifies, "sector_score": sector_score, "proxy_ticker": sector_proxy_ticker_for_sector(sector), "tickers": {ticker for ticker, _info in members}, } for ticker, info in annotated.items(): sector = _sector_for_ticker(ticker) if sector is None: continue stats = sector_stats.get(sector) if not stats: continue info["liquid_cluster_member_count"] = int(stats["member_count"]) info["liquid_cluster_total_entry_dollar_volume"] = float( stats["total_entry_dollar_volume"] ) info["liquid_cluster_sector"] = sector info["liquid_cluster_sector_score"] = float(stats["sector_score"]) info["sector_proxy_ticker"] = stats["proxy_ticker"] if not bool(stats["qualifies"]) or ticker not in stats["tickers"]: continue own_confirmation_score = _clip_unit_score( info.get("confirmation_return_pct"), 0.02, ) own_entry_dollar_volume_score = _clip_log_score( info.get("entry_dollar_volume"), 10_000_000.0, 1_000_000_000.0, ) own_avg_dollar_vol_score = _clip_log_score( info.get("avg_dollar_vol_30d"), 100_000_000.0, 10_000_000_000.0, ) own_gain_score = _clip_unit_score( info.get("gain_pct"), 0.05, ) info["is_liquid_cluster"] = True info["liquid_cluster_score"] = ( 0.45 * float(stats["sector_score"]) + 0.20 * own_confirmation_score + 0.15 * own_entry_dollar_volume_score + 0.15 * own_avg_dollar_vol_score + 0.05 * own_gain_score ) return annotated, sector_stats def _basket_quality_stats( picks: list[tuple[str, str]], morning_gains: dict[str, dict], strategy: StrategyParams, ) -> dict[str, float]: if not picks: return { "count": 0.0, "avg_quality": 0.0, "best_quality": 0.0, "event_count": 0.0, "strong_event_count": 0.0, "liquid_largecap_count": 0.0, "moderate_gap_liquid_count": 0.0, "max_gain_pct": 0.0, "avg_support": 0.0, "max_entropy_20d": 0.0, "max_confirmation_return_pct": 0.0, } qualities: list[float] = [] support_scores: list[float] = [] event_count = 0 strong_event_count = 0 liquid_largecap_count = 0 moderate_gap_liquid_count = 0 max_gain_pct = 0.0 max_entropy_20d = 0.0 max_confirmation_return_pct = 0.0 for ticker, _sleeve in picks: info = morning_gains.get(ticker, {}) qualities.append(_momentum_quality_score(info, strategy)) support_score = _same_day_support_score(info) support_scores.append(support_score) if info.get("is_event_candidate"): event_count += 1 min_event_support = strategy.tail_risk_day_event_exemption_min_support_score if min_event_support is None or support_score >= min_event_support: strong_event_count += 1 if info.get("is_liquid_largecap"): liquid_largecap_count += 1 if info.get("is_moderate_gap_liquid"): moderate_gap_liquid_count += 1 max_gain_pct = max(max_gain_pct, float(info.get("gain_pct") or 0.0)) max_entropy_20d = max(max_entropy_20d, float(info.get("entropy_20d") or 0.0)) max_confirmation_return_pct = max( max_confirmation_return_pct, float(info.get("confirmation_return_pct") or 0.0), ) return { "count": float(len(picks)), "avg_quality": sum(qualities) / len(qualities), "best_quality": max(qualities), "event_count": float(event_count), "strong_event_count": float(strong_event_count), "liquid_largecap_count": float(liquid_largecap_count), "moderate_gap_liquid_count": float(moderate_gap_liquid_count), "max_gain_pct": max_gain_pct, "avg_support": sum(support_scores) / len(support_scores), "max_entropy_20d": max_entropy_20d, "max_confirmation_return_pct": max_confirmation_return_pct, } def _should_enable_soft_day_event_sleeve( picks: list[tuple[str, str]], morning_gains: dict[str, dict], strategy: StrategyParams, *, base_soft_day: bool, ) -> bool: if not ( base_soft_day and strategy.use_event_sleeve and strategy.event_sleeve_soft_day_only ): return False stats = _basket_quality_stats(picks, morning_gains, strategy) if ( strategy.event_sleeve_soft_day_max_trades is not None and stats["count"] > strategy.event_sleeve_soft_day_max_trades ): return False if ( strategy.event_sleeve_soft_day_max_avg_quality is not None and stats["avg_quality"] > strategy.event_sleeve_soft_day_max_avg_quality ): return False if ( strategy.event_sleeve_soft_day_require_no_existing_event and stats["event_count"] > 0 ): return False return True def _tail_risk_day_scaler( picks: list[tuple[str, str]], morning_gains: dict[str, dict], strategy: StrategyParams, ) -> float: if not picks or strategy.tail_risk_day_scale >= 1.0: return 1.0 if not any( [ strategy.tail_risk_day_max_trades is not None, strategy.tail_risk_day_min_max_gain_pct is not None, strategy.tail_risk_day_max_avg_quality is not None, strategy.tail_risk_day_max_support_score is not None, strategy.tail_risk_day_min_max_entropy_20d is not None, strategy.tail_risk_day_min_max_confirmation_return_pct is not None, strategy.tail_risk_day_require_no_event, strategy.tail_risk_day_exempt_largecap, ] ): return 1.0 stats = _basket_quality_stats(picks, morning_gains, strategy) if ( strategy.tail_risk_day_max_trades is not None and stats["count"] > strategy.tail_risk_day_max_trades ): return 1.0 if ( strategy.tail_risk_day_min_max_gain_pct is not None and stats["max_gain_pct"] < strategy.tail_risk_day_min_max_gain_pct ): return 1.0 if ( strategy.tail_risk_day_max_avg_quality is not None and stats["avg_quality"] > strategy.tail_risk_day_max_avg_quality ): return 1.0 if ( strategy.tail_risk_day_max_support_score is not None and stats["avg_support"] > strategy.tail_risk_day_max_support_score ): return 1.0 if ( strategy.tail_risk_day_min_max_entropy_20d is not None and stats["max_entropy_20d"] < strategy.tail_risk_day_min_max_entropy_20d ): return 1.0 if ( strategy.tail_risk_day_min_max_confirmation_return_pct is not None and stats["max_confirmation_return_pct"] < strategy.tail_risk_day_min_max_confirmation_return_pct ): return 1.0 if strategy.tail_risk_day_require_no_event and stats["strong_event_count"] > 0: return 1.0 if strategy.tail_risk_day_exempt_largecap and stats["liquid_largecap_count"] > 0: return 1.0 return max(0.0, min(1.0, strategy.tail_risk_day_scale)) def _low_momentum_single_name_scaler( picks: list[tuple[str, str]], morning_gains: dict[str, dict], strategy: StrategyParams, ) -> float: scale = strategy.low_momentum_single_name_scale threshold = strategy.low_momentum_single_name_max_gain_pct if not picks or len(picks) != 1 or threshold is None or scale >= 1.0: return 1.0 stats = _basket_quality_stats(picks, morning_gains, strategy) if stats["max_gain_pct"] > threshold: return 1.0 if ( strategy.low_momentum_single_name_require_no_event and stats["strong_event_count"] > 0 ): return 1.0 if ( strategy.low_momentum_single_name_exempt_largecap and stats["liquid_largecap_count"] > 0 ): return 1.0 return max(0.0, min(1.0, scale)) def _soft_day_sparse_scaler( picks: list[tuple[str, str]], morning_gains: dict[str, dict], strategy: StrategyParams, *, is_soft_day: bool, ) -> float: scale = strategy.soft_day_sparse_scale if not is_soft_day or not picks or scale >= 1.0: return 1.0 if not any( [ strategy.soft_day_sparse_max_trades is not None, strategy.soft_day_sparse_require_no_event, strategy.soft_day_sparse_exempt_largecap, strategy.soft_day_sparse_exempt_moderate_gap_liquid, ] ): return 1.0 stats = _basket_quality_stats(picks, morning_gains, strategy) if ( strategy.soft_day_sparse_max_trades is not None and stats["count"] > strategy.soft_day_sparse_max_trades ): return 1.0 if ( strategy.soft_day_sparse_require_no_event and stats["strong_event_count"] > 0 ): return 1.0 if ( strategy.soft_day_sparse_exempt_largecap and stats["liquid_largecap_count"] > 0 ): return 1.0 if ( strategy.soft_day_sparse_exempt_moderate_gap_liquid and stats["moderate_gap_liquid_count"] > 0 ): return 1.0 return max(0.0, min(1.0, scale)) def _apply_basket_quality_floor( picks: list[tuple[str, str]], morning_gains: dict[str, dict], strategy: StrategyParams, ) -> list[tuple[str, str]]: floor = getattr(strategy, "basket_quality_relative_floor", None) if floor is None or floor <= 0 or not picks: return picks quality_by_ticker = { ticker: _momentum_quality_score(morning_gains[ticker], strategy) for ticker, _sleeve in picks } best_quality = max(quality_by_ticker.values(), default=0.0) if best_quality <= 0: return picks threshold = best_quality * float(floor) blend_only = bool(getattr(strategy, "basket_quality_prune_blend_only", False)) prunable = [ (ticker, sleeve) for ticker, sleeve in picks if not blend_only or sleeve == "blend" ] if not prunable: return picks protected = { ticker for ticker, sleeve in picks if blend_only and sleeve != "blend" } min_count = max(0, min(int(getattr(strategy, "basket_quality_min_count", 0) or 0), len(picks))) required_from_prunable = max(0, min_count - len(protected)) forced = { ticker for ticker, _score in sorted( ((ticker, quality_by_ticker[ticker]) for ticker, _sleeve in prunable), key=lambda item: item[1], reverse=True, )[:required_from_prunable] } kept: list[tuple[str, str]] = [] for ticker, sleeve in picks: if ticker in protected or ticker in forced or quality_by_ticker.get(ticker, 0.0) >= threshold: kept.append((ticker, sleeve)) return kept def _is_liquid_continuation_candidate(info: dict) -> bool: return bool( info.get("is_moderate_gap_liquid") or info.get("is_liquid_largecap") or info.get("is_sector_thrust") ) def _liquid_continuation_core_score(info: dict) -> float: low_entropy = max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0) return ( (3.0 if info.get("is_moderate_gap_liquid") else 0.0) + (2.5 if info.get("is_liquid_largecap") else 0.0) + (1.5 if info.get("is_sector_thrust") else 0.0) + 2.0 * _same_day_support_score(info) + max((info.get("confirmation_return_pct") or 0.0) * 25.0, 0.0) + max((info.get("gain_pct") or 0.0) * 8.0, 0.0) + min(max((info.get("entry_dollar_volume") or 0.0) / 150_000_000.0, 0.0), 4.0) + min(max((info.get("avg_dollar_vol_30d") or 0.0) / 1_000_000_000.0, 0.0), 4.0) + low_entropy ) def _select_liquid_continuation_core( eligible_gains: dict[str, dict], strategy: StrategyParams, *, can_pick_ticker, record_pick, ) -> list[tuple[str, str]]: items = list(eligible_gains.items()) preferred = [item for item in items if _is_liquid_continuation_candidate(item[1])] if not preferred: return [] ranked_preferred = sorted( preferred, key=lambda item: ( _liquid_continuation_core_score(item[1]), _same_day_support_score(item[1]), item[1].get("confirmation_return_pct", -999.0), item[1].get("entry_dollar_volume", 0.0), item[1].get("gain_pct", 0.0), -_safe_value(item[1].get("entropy_20d"), default=1.0), ), reverse=True, ) picks: list[tuple[str, str]] = [] chosen: set[str] = set() for ticker, _info in ranked_preferred: if ticker in chosen: continue if not can_pick_ticker(ticker): continue picks.append((ticker, "liquid_continuation_core")) chosen.add(ticker) record_pick(ticker) if len(picks) >= strategy.top_n: return _apply_basket_quality_floor(picks, eligible_gains, strategy) return _apply_basket_quality_floor(picks, eligible_gains, strategy) 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 [] morning_gains = _annotate_sector_thrust_features( morning_gains, strategy, ticker_sectors, ) 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 eligible_gains = { ticker: info for ticker, info in morning_gains.items() if not info.get("overlay_only_candidate") } if not eligible_gains: return [] selection_mode = str(getattr(strategy, "momentum_selection_mode", "standard") or "standard").lower() if selection_mode == "liquid_continuation": return _select_liquid_continuation_core( eligible_gains, strategy, can_pick_ticker=_can_pick_ticker, record_pick=_record_pick, ) if not strategy.use_five_sleeves: ranked = sorted( eligible_gains.keys(), key=lambda t: ( eligible_gains[t]["gain_pct"], eligible_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 _apply_basket_quality_floor(picks, eligible_gains, strategy) sleeves = _five_sleeve_specs(strategy) picks: list[tuple[str, str]] = [] chosen: set[str] = set() items = list(eligible_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 _apply_basket_quality_floor(picks[: strategy.top_n], eligible_gains, strategy) 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 _apply_basket_quality_floor(picks, eligible_gains, strategy) def _select_event_day_liquid_picks( morning_gains: dict[str, dict], existing_picks: list[tuple[str, str]], strategy: StrategyParams, *, is_soft_day: bool, activation_stats: dict[str, float | int | bool] | None = None, ) -> list[tuple[str, str]]: if not ( strategy.use_event_day_liquid_sleeve and strategy.event_day_liquid_capital_fraction > 0 and strategy.event_day_liquid_max_positions > 0 ): return [] if strategy.event_day_liquid_soft_day_only and not is_soft_day: return [] activation = activation_stats or _event_day_liquid_activation_stats(morning_gains, strategy) if not bool(activation.get("qualifies")): return [] chosen = {ticker for ticker, _sleeve in existing_picks} ranked = sorted( ( (ticker, info) for ticker, info in morning_gains.items() if ticker not in chosen ), key=lambda item: _event_day_liquid_pick_score(item[1]), reverse=True, ) picks: list[tuple[str, str]] = [] for ticker, info in ranked: gain_pct = float(info.get("gain_pct") or 0.0) if ( strategy.event_day_liquid_min_gain_pct is not None and gain_pct < strategy.event_day_liquid_min_gain_pct ): continue if ( strategy.event_day_liquid_max_gain_pct is not None and gain_pct > strategy.event_day_liquid_max_gain_pct ): continue confirmation_return_pct = info.get("confirmation_return_pct") if ( strategy.event_day_liquid_min_confirmation_return_pct is not None and ( confirmation_return_pct is None or confirmation_return_pct < strategy.event_day_liquid_min_confirmation_return_pct ) ): continue entry_dollar_volume = float(info.get("entry_dollar_volume") or 0.0) if ( strategy.event_day_liquid_min_entry_dollar_volume is not None and entry_dollar_volume < strategy.event_day_liquid_min_entry_dollar_volume ): continue avg_dollar_vol_30d = info.get("avg_dollar_vol_30d") if ( strategy.event_day_liquid_min_avg_dollar_vol_30d is not None and ( avg_dollar_vol_30d is None or avg_dollar_vol_30d < strategy.event_day_liquid_min_avg_dollar_vol_30d ) ): continue entropy_20d = info.get("entropy_20d") if ( strategy.event_day_liquid_max_entropy_20d is not None and ( entropy_20d is None or entropy_20d > strategy.event_day_liquid_max_entropy_20d ) ): continue support_score = _same_day_support_score(info) if ( strategy.event_day_liquid_min_support_score is not None and support_score < strategy.event_day_liquid_min_support_score ): continue picks.append((ticker, "event_day_liquid")) if len(picks) >= strategy.event_day_liquid_max_positions: break return picks def _select_liquid_cluster_picks( morning_gains: dict[str, dict], base_picks: list[tuple[str, str]], strategy: StrategyParams, ) -> list[tuple[str, str]]: if not ( strategy.use_liquid_cluster_engine and strategy.liquid_cluster_capital_fraction > 0 and strategy.liquid_cluster_max_positions > 0 ): return [] chosen = {ticker for ticker, _sleeve in base_picks} max_per_sector = max(1, int(strategy.liquid_cluster_max_positions_per_sector or 1)) sector_counts: dict[str, int] = {} picks: list[tuple[str, str]] = [] ranked = sorted( ( (ticker, info) for ticker, info in morning_gains.items() if info.get("is_liquid_cluster") and ticker not in chosen ), key=lambda item: ( float(item[1].get("liquid_cluster_score") or 0.0), float(item[1].get("entry_dollar_volume") or 0.0), float(item[1].get("confirmation_return_pct") or -999.0), float(item[1].get("gain_pct") or 0.0), ), reverse=True, ) for ticker, info in ranked: sector = str(info.get("liquid_cluster_sector") or "").strip() if sector and sector_counts.get(sector, 0) >= max_per_sector: continue picks.append((ticker, "liquid_cluster_engine")) chosen.add(ticker) if sector: sector_counts[sector] = sector_counts.get(sector, 0) + 1 if len(picks) >= strategy.liquid_cluster_max_positions: break return picks def _select_sector_etf_picks( morning_gains: dict[str, dict], cluster_stats: dict[str, dict[str, object]], base_picks: list[tuple[str, str]], cluster_picks: list[tuple[str, str]], sector_proxy_bars_by_ticker: dict[str, list[dict]] | None, strategy: StrategyParams, ) -> list[tuple[str, str, str]]: if not ( strategy.use_sector_etf_sleeve and strategy.sector_etf_capital_fraction > 0 and strategy.sector_etf_max_positions > 0 and sector_proxy_bars_by_ticker ): return [] base_sectors = { str(morning_gains.get(ticker, {}).get("liquid_cluster_sector") or "").strip() for ticker, _sleeve in cluster_picks } ranked: list[tuple[float, float, float, str, str]] = [] for sector, stats in cluster_stats.items(): if not bool(stats.get("qualifies")): continue if sector in base_sectors: continue proxy_ticker = stats.get("proxy_ticker") if not isinstance(proxy_ticker, str) or proxy_ticker not in sector_proxy_bars_by_ticker: continue sector_score = float(stats.get("sector_score") or 0.0) if ( strategy.sector_etf_min_sector_score is not None and sector_score < strategy.sector_etf_min_sector_score ): continue ranked.append( ( sector_score, float(stats.get("total_entry_dollar_volume") or 0.0), float(stats.get("avg_confirmation_return_pct") or 0.0), sector, proxy_ticker, ) ) ranked.sort(reverse=True) picks: list[tuple[str, str, str]] = [] chosen_proxies = {ticker for ticker, _sleeve in base_picks} for _score, _total_dv, _avg_conf, sector, proxy_ticker in ranked: if proxy_ticker in chosen_proxies: continue picks.append((proxy_ticker, "sector_etf", sector)) chosen_proxies.add(proxy_ticker) if len(picks) >= strategy.sector_etf_max_positions: break return picks def _execution_info_from_bars( bars: list[dict], strategy: StrategyParams, date_str: str, ) -> dict | None: """Build execution context from raw intraday bars without candidate filters.""" market_open = _market_open_ts(date_str) mkt_bars = filter_market_hours(bars) if len(mkt_bars) < _MIN_BARS: return None open_price = mkt_bars[0]["open"] if open_price <= 0: return None initial_entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open) if initial_entry_bar is None: return None entry_bar = initial_entry_bar confirmation_return = None 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: return None confirmation_return = ( confirmation_bar["close"] - initial_entry_bar["close"] ) / initial_entry_bar["close"] entry_bar = confirmation_bar entry_price_raw = entry_bar["close"] if entry_price_raw <= 0: return None entry_ts = _parse_ts(entry_bar["timestamp"]) opening_range_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts] if not opening_range_bars: return None 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) return { "gain_pct": (entry_price_raw - open_price) / open_price, "entry_price_raw": entry_price_raw, "entry_bar": entry_bar, "mkt_bars": mkt_bars, "entry_volume": _volume_up_to_bar(mkt_bars, entry_ts), "entry_dollar_volume": _dollar_volume_up_to_bar(mkt_bars, entry_ts), "opening_range_width": opening_range_width, "confirmation_return_pct": confirmation_return, "recovery_from_opening_low_pct": ( (entry_price_raw - opening_range_low) / opening_range_low if opening_range_low > 0 else None ), "gap_pct": None, "volume_ratio_14d": None, "ret_5d": None, "entropy_20d": None, "avg_dollar_vol_30d": None, "atr_14": None, "event_score": None, "is_liquid_largecap": False, "is_moderate_gap_liquid": False, "is_sector_thrust": False, "is_liquid_cluster": False, } def _build_intraday_trade( ticker: str, info: dict, *, date_str: str, strategy: StrategyParams, trade_capital: float, sleeve: str, ) -> IntradayTrade: 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) shares = trade_capital / entry_price_filled if trade_capital > 0 else 0.0 pnl_pct = (exit_price - entry_price_filled) / entry_price_filled if entry_price_filled > 0 else 0.0 pnl = pnl_pct * trade_capital slippage_cost = ( (entry_price_filled - entry_price_raw) + (entry_price_raw * strategy.slippage_bps / 10_000) ) * 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=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(float(info.get("gain_pct") or 0.0), 6), slippage_cost=round(slippage_cost, 4), trade_sleeve=sleeve, gap_pct=_round_optional(info.get("gap_pct"), 6), confirmation_return_pct=_round_optional(info.get("confirmation_return_pct"), 6), entry_dollar_volume=_round_optional(info.get("entry_dollar_volume"), 2), avg_dollar_vol_30d=_round_optional(info.get("avg_dollar_vol_30d"), 2), entropy_20d=_round_optional(info.get("entropy_20d"), 6), ret_5d=_round_optional(info.get("ret_5d"), 6), event_score=_round_optional(info.get("event_score"), 4), support_score=round(_same_day_support_score(info), 6), is_liquid_largecap=bool(info.get("is_liquid_largecap")), is_moderate_gap_liquid=bool(info.get("is_moderate_gap_liquid")), is_sector_thrust=bool(info.get("is_sector_thrust")), sector_thrust_member_count=int(info.get("sector_thrust_member_count") or 0), sector_thrust_total_entry_dollar_volume=_round_optional( info.get("sector_thrust_total_entry_dollar_volume"), 2, ), is_liquid_cluster=bool(info.get("is_liquid_cluster")), liquid_cluster_member_count=int(info.get("liquid_cluster_member_count") or 0), liquid_cluster_total_entry_dollar_volume=_round_optional( info.get("liquid_cluster_total_entry_dollar_volume"), 2, ), liquid_cluster_sector=( str(info.get("liquid_cluster_sector")) if info.get("liquid_cluster_sector") else None ), liquid_cluster_sector_score=_round_optional(info.get("liquid_cluster_sector_score"), 6), sector_proxy_ticker=( str(info.get("sector_proxy_ticker")) if info.get("sector_proxy_ticker") else None ), total_capital_deployed=round(trade_capital, 4), ) # ── 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) allowed_event_types = { str(value).strip().lower() for value in getattr(strategy, "candidate_allowed_event_types", []) if str(value).strip() } def _effective_event_state(daily_features: dict) -> tuple[bool, float]: raw_event_flag = bool(daily_features.get("event_flag")) raw_event_score = float(daily_features.get("event_score") or 0.0) if not raw_event_flag: return False, 0.0 if allowed_event_types: raw_event_types = daily_features.get("event_types") or [] event_types = { str(value).strip().lower() for value in raw_event_types if str(value).strip() } if not event_types or not any(event_type in allowed_event_types for event_type in event_types): return False, 0.0 return True, raw_event_score # 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") raw_event_flag = bool(daily_features.get("event_flag")) raw_event_score = float(daily_features.get("event_score") or 0.0) event_types = list(daily_features.get("event_types") or []) event_flag, event_score = _effective_event_state(daily_features) attention_wiki_spike_10d = float(daily_features.get("attention_wiki_spike_10d") or 0.0) attention_article_count_3d = int(daily_features.get("attention_article_count_3d") or 0) attention_us_article_count_3d = int(daily_features.get("attention_us_article_count_3d") or 0) attention_resolver_confidence = float(daily_features.get("attention_resolver_confidence") or 0.0) is_event_candidate = False if strategy.use_event_sleeve and event_flag: if strategy.event_min_score is None or event_score >= strategy.event_min_score: is_event_candidate = True 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 or _liquid_cluster_overlay_enabled(strategy) or _event_day_liquid_overlay_enabled(strategy) ) 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 moderate_gap_liquid_ok = False moderate_gap_liquid_enabled = ( strategy.use_moderate_gap_liquid_sleeve or _liquid_cluster_overlay_enabled(strategy) or _event_day_liquid_overlay_enabled(strategy) ) if moderate_gap_liquid_enabled: moderate_entropy_cap = strategy.moderate_gap_liquid_max_entropy_20d if moderate_entropy_cap is None: moderate_entropy_cap = strategy.max_entropy_20d atr_pct = ( float(atr_14) / float(open_price) if atr_14 is not None and open_price > 0 else None ) if strategy.moderate_gap_liquid_min_gap_pct is not None and ( gap_pct is None or gap_pct < strategy.moderate_gap_liquid_min_gap_pct ): pass elif strategy.moderate_gap_liquid_max_gap_pct is not None and ( gap_pct is None or gap_pct > strategy.moderate_gap_liquid_max_gap_pct ): pass elif ( strategy.moderate_gap_liquid_min_gain_pct is not None and gain_pct < strategy.moderate_gap_liquid_min_gain_pct ): pass elif ( strategy.moderate_gap_liquid_max_gain_pct is not None and gain_pct > strategy.moderate_gap_liquid_max_gain_pct ): pass elif ( strategy.moderate_gap_liquid_min_confirmation_return_pct is not None and ( confirmation_return is None or confirmation_return < strategy.moderate_gap_liquid_min_confirmation_return_pct ) ): pass elif ( strategy.moderate_gap_liquid_min_entry_dollar_volume is not None and entry_dollar_vol < strategy.moderate_gap_liquid_min_entry_dollar_volume ): pass elif ( strategy.moderate_gap_liquid_min_avg_dollar_vol_30d is not None and ( avg_dollar_vol_30d is None or avg_dollar_vol_30d < strategy.moderate_gap_liquid_min_avg_dollar_vol_30d ) ): pass elif ( strategy.moderate_gap_liquid_max_avg_dollar_vol_30d is not None and ( avg_dollar_vol_30d is None or avg_dollar_vol_30d > strategy.moderate_gap_liquid_max_avg_dollar_vol_30d ) ): pass elif ( strategy.moderate_gap_liquid_min_volume_ratio_14d is not None and ( volume_ratio_14d is None or volume_ratio_14d < strategy.moderate_gap_liquid_min_volume_ratio_14d ) ): pass elif ( strategy.moderate_gap_liquid_min_atr_pct is not None and (atr_pct is None or atr_pct < strategy.moderate_gap_liquid_min_atr_pct) ): pass elif ( moderate_entropy_cap is not None and (entropy_20d is None or entropy_20d > moderate_entropy_cap) ): pass else: moderate_gap_liquid_ok = True liquid_cluster_candidate_ok = False if _liquid_cluster_overlay_enabled(strategy): liquid_cluster_candidate_ok = _passes_liquid_cluster_own_gate( strategy, gain_pct=gain_pct, confirmation_return_pct=confirmation_return, entry_dollar_volume=entry_dollar_vol, avg_dollar_vol_30d=avg_dollar_vol_30d, volume_ratio_14d=volume_ratio_14d, entropy_20d=entropy_20d, is_moderate_gap_liquid=moderate_gap_liquid_ok, is_liquid_largecap=liquid_largecap_ok, ) 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 moderate_gap_liquid_ok and not liquid_cluster_candidate_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, "raw_event_flag": raw_event_flag, "raw_event_score": raw_event_score, "event_types": event_types, "event_flag": event_flag, "event_score": event_score, "attention_wiki_spike_10d": attention_wiki_spike_10d, "attention_article_count_3d": attention_article_count_3d, "attention_us_article_count_3d": attention_us_article_count_3d, "attention_resolver_confidence": attention_resolver_confidence, "is_event_candidate": is_event_candidate, "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_moderate_gap_liquid": moderate_gap_liquid_ok, "is_gap_reclaim": gap_reclaim_ok, "overlay_only_candidate": bool( liquid_cluster_candidate_ok and not regular_ok and not slow_ignite_ok and not liquid_largecap_ok and not moderate_gap_liquid_ok and not 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, sector_proxy_bars_by_ticker: dict[str, list[dict]] | 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) if strategy.max_vix is not None and vix_value is not None and vix_value > strategy.max_vix: result.skip_reason = "vix_gate" return result if _intraday_regime_failed(strategy, spy_bars, date_str): result.skip_reason = "market_regime" return result regime_scaler, regime_skip = _day_regime_scaler(strategy, daily_features_by_ticker) if regime_skip is not None: result.skip_reason = regime_skip return result breadth_scaler, breadth_skip = _day_breadth_scaler( strategy, bars_by_ticker, daily_features_by_ticker, ) if breadth_skip is not None: result.skip_reason = breadth_skip return result result.regime_scaler = regime_scaler result.breadth_scaler = breadth_scaler morning_gains = compute_morning_gains( bars_by_ticker, strategy, date_str, blacklisted_tickers=blacklisted_tickers, spy_bars=None, daily_features_by_ticker=daily_features_by_ticker, vix_value=None, ) morning_gains = _annotate_sector_thrust_features( morning_gains, strategy, ticker_sectors, ) morning_gains, liquid_cluster_stats = _annotate_liquid_cluster_features( morning_gains, strategy, ticker_sectors, ) result.candidates_found = len(morning_gains) if not morning_gains: result.skip_reason = "no_candidates" return result selection_strategy = strategy if strategy.use_event_sleeve and strategy.event_sleeve_soft_day_only: selection_strategy = strategy.model_copy(update={"use_event_sleeve": False, "event_weight": 0.0}) top_tickers = _select_momentum_sleeves( morning_gains, selection_strategy, ticker_sectors=ticker_sectors, ) if not top_tickers: result.skip_reason = "no_candidates" return result if len(top_tickers) < max(1, strategy.min_positions_to_trade): result.skip_reason = "below_min_candidates" return result sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy) result.sector_scaler = sector_scaler base_soft_day = ( regime_scaler * breadth_scaler * sector_scaler ) < strategy.soft_day_scaler_threshold result.is_soft_day = base_soft_day if _should_enable_soft_day_event_sleeve( top_tickers, morning_gains, strategy, base_soft_day=base_soft_day, ): soft_day_tickers = _select_momentum_sleeves( morning_gains, strategy, ticker_sectors=ticker_sectors, ) if soft_day_tickers: top_tickers = soft_day_tickers sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy) result.sector_scaler = sector_scaler result.is_soft_day = ( regime_scaler * breadth_scaler * sector_scaler ) < strategy.soft_day_scaler_threshold if result.is_soft_day and strategy.soft_day_max_trades is not None and strategy.soft_day_max_trades > 0: top_tickers = top_tickers[: strategy.soft_day_max_trades] sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy) result.sector_scaler = sector_scaler result.is_soft_day = (regime_scaler * breadth_scaler * sector_scaler) < strategy.soft_day_scaler_threshold soft_day_sparse_scaler = _soft_day_sparse_scaler( top_tickers, morning_gains, strategy, is_soft_day=result.is_soft_day, ) result.soft_day_sparse_scaler = soft_day_sparse_scaler tail_risk_scaler = min( _tail_risk_day_scaler(top_tickers, morning_gains, strategy), _low_momentum_single_name_scaler(top_tickers, morning_gains, strategy), soft_day_sparse_scaler, ) result.tail_risk_scaler = tail_risk_scaler event_day_liquid_activation = _event_day_liquid_activation_stats(morning_gains, strategy) result.event_day_liquid_active = bool(event_day_liquid_activation.get("qualifies")) result.event_day_liquid_event_count = int(event_day_liquid_activation.get("event_count") or 0) result.event_day_liquid_total_event_entry_dollar_volume = round( float(event_day_liquid_activation.get("total_entry_dollar_volume") or 0.0), 2, ) event_day_liquid_picks = _select_event_day_liquid_picks( morning_gains, top_tickers, strategy, is_soft_day=result.is_soft_day, activation_stats=event_day_liquid_activation, ) liquid_cluster_picks = _select_liquid_cluster_picks( morning_gains, top_tickers + event_day_liquid_picks, strategy, ) sector_etf_picks = _select_sector_etf_picks( morning_gains, liquid_cluster_stats, top_tickers, liquid_cluster_picks, sector_proxy_bars_by_ticker, strategy, ) 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) * regime_scaler * breadth_scaler * sector_scaler * tail_risk_scaler * _sparse_day_scaler(len(top_tickers), strategy) ) event_day_liquid_fraction = ( max(0.0, min(1.0, strategy.event_day_liquid_capital_fraction)) if event_day_liquid_picks else 0.0 ) liquid_cluster_fraction = ( max(0.0, min(1.0, strategy.liquid_cluster_capital_fraction)) if liquid_cluster_picks else 0.0 ) sector_etf_fraction = ( max(0.0, min(1.0, strategy.sector_etf_capital_fraction)) if sector_etf_picks else 0.0 ) reserved_fraction = event_day_liquid_fraction + liquid_cluster_fraction + sector_etf_fraction if reserved_fraction > 1.0: event_day_liquid_fraction /= reserved_fraction liquid_cluster_fraction /= reserved_fraction sector_etf_fraction /= reserved_fraction base_fraction = 0.0 else: base_fraction = 1.0 - reserved_fraction base_capital_budget = capital_budget * base_fraction event_day_liquid_capital_budget = capital_budget * event_day_liquid_fraction liquid_cluster_capital_budget = capital_budget * liquid_cluster_fraction sector_etf_capital_budget = capital_budget * sector_etf_fraction if top_tickers and base_capital_budget > 0: capital_per_trade = base_capital_budget / len(top_tickers) for ticker, sleeve in top_tickers: info = morning_gains[ticker] trade_capital = capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy) trade = _build_intraday_trade( ticker, info, date_str=date_str, strategy=strategy, trade_capital=trade_capital, sleeve=sleeve, ) result.trades.append(trade) result.daily_pnl += trade.pnl if event_day_liquid_picks and event_day_liquid_capital_budget > 0: event_day_liquid_capital_per_trade = event_day_liquid_capital_budget / len(event_day_liquid_picks) for ticker, sleeve in event_day_liquid_picks: info = morning_gains[ticker] trade_capital = event_day_liquid_capital_per_trade * _entropy_trade_scaler( info.get("entropy_20d"), strategy, ) trade = _build_intraday_trade( ticker, info, date_str=date_str, strategy=strategy, trade_capital=trade_capital, sleeve=sleeve, ) result.trades.append(trade) result.daily_pnl += trade.pnl if liquid_cluster_picks and liquid_cluster_capital_budget > 0: cluster_capital_per_trade = liquid_cluster_capital_budget / len(liquid_cluster_picks) for ticker, sleeve in liquid_cluster_picks: info = morning_gains[ticker] trade_capital = cluster_capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy) trade = _build_intraday_trade( ticker, info, date_str=date_str, strategy=strategy, trade_capital=trade_capital, sleeve=sleeve, ) result.trades.append(trade) result.daily_pnl += trade.pnl if sector_etf_picks and sector_etf_capital_budget > 0 and sector_proxy_bars_by_ticker: etf_capital_per_trade = sector_etf_capital_budget / len(sector_etf_picks) for proxy_ticker, sleeve, sector in sector_etf_picks: proxy_info = _execution_info_from_bars( sector_proxy_bars_by_ticker.get(proxy_ticker, []), strategy, date_str, ) if not proxy_info: continue stats = liquid_cluster_stats.get(sector, {}) proxy_info["liquid_cluster_sector"] = sector proxy_info["liquid_cluster_sector_score"] = float(stats.get("sector_score") or 0.0) proxy_info["liquid_cluster_member_count"] = int(stats.get("member_count") or 0) proxy_info["liquid_cluster_total_entry_dollar_volume"] = float( stats.get("total_entry_dollar_volume") or 0.0 ) proxy_info["sector_proxy_ticker"] = proxy_ticker trade = _build_intraday_trade( proxy_ticker, proxy_info, date_str=date_str, strategy=strategy, trade_capital=etf_capital_per_trade, sleeve=sleeve, ) result.trades.append(trade) result.daily_pnl += trade.pnl if result.trades: total_deployed = sum((t.total_capital_deployed or (t.shares * t.entry_price)) for t in result.trades) result.capital_deployed = round(total_deployed, 4) # Daily return should reflect portfolio-level exposure, not just deployed capital. # This keeps sparse-day / VIX / entropy size scaling visible in Sharpe and loss metrics. if sizing_capital > 0: result.daily_return_pct = result.daily_pnl / sizing_capital 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, sector_proxy_intraday_by_day: dict[str, dict[str, list[dict]]] | 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 rolling_pnl_window: list[float] = [] 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)) rolling_pnl_window.append(0.0) 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) if ( strategy.rolling_loss_days is not None and strategy.rolling_loss_threshold is not None and len(rolling_pnl_window) >= strategy.rolling_loss_days ): n_roll = strategy.rolling_loss_days rolling_pnl = sum(rolling_pnl_window[-n_roll:]) if strategy.daily_budget_reset or not strategy.compound_returns: sizing_capital_for_check = strategy.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 < strategy.rolling_loss_threshold: results.append(DayResult(date=date_str, skip_reason="rolling_loss")) rolling_pnl_window.append(0.0) continue # 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_features_by_ticker = ( { ticker: daily_enrichment.get(ticker, {}).get(date_str, {}) for ticker in bars_by_ticker.keys() } if daily_enrichment else None ) if daily_enrichment and ( strategy.market_regime_gap_threshold is not None or strategy.regime_size_scale_low is not None or strategy.regime_skip_below is not None ): regime_ticker = strategy.market_regime_gap_ticker or "SPY" day_features_by_ticker = day_features_by_ticker or {} day_features_by_ticker.setdefault( regime_ticker, daily_enrichment.get(regime_ticker, {}).get(date_str, {}), ) 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=day_features_by_ticker, vix_value=(vix_by_day or {}).get(date_str), current_equity=equity, ticker_sectors=ticker_sectors, sector_proxy_bars_by_ticker=(sector_proxy_intraday_by_day or {}).get(date_str), ) results.append(day_result) equity += day_result.daily_pnl rolling_pnl_window.append(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