"""Opening Range Breakout (ORB) simulation engine. Strategy: At 09:35 ET, identify the first 5-min candle direction. For bullish candles (long-only V1), place a stop-buy order at the candle's high. If filled before timeout (10:15 ET), manage position with ATR-based stops. Exit at 15:55 ET or on stop/trailing stop. Pure functions — no API calls, no disk I/O. run_orb_simulation() takes pre-loaded data and enrichment, returns DayResult list. Compatible with the existing metrics pipeline (compute_metrics, format_summary, etc.). """ from __future__ import annotations import datetime as dt import math from collections import deque from dataclasses import dataclass, field from typing import Callable from zoneinfo import ZoneInfo from libs.intraday.domain import DayResult, IntradayTrade, ORBStrategyParams from libs.intraday.features import compute_rvol_approx from libs.intraday.simulator import ( _apply_slippage_entry, _apply_slippage_exit, _market_open_ts, _parse_ts, filter_market_hours, ) _ET = ZoneInfo("America/New_York") _PREMARKET_OPEN = dt.time(4, 0) _MARKET_OPEN = dt.time(9, 30) _MARKET_CLOSE = dt.time(16, 0) # Doji threshold: if |close - open| / open < this, classify as doji _DOJI_THRESHOLD = 0.001 def _compute_running_vwap(bars: list[dict], up_to_ts: dt.datetime) -> float | None: """Compute running VWAP from market open up to (and including) the given timestamp. Uses typical price = (high + low + close) / 3 for each bar. Returns None if no bars with volume are found. """ cum_pv = 0.0 cum_vol = 0.0 for b in bars: ts = _parse_ts(b["timestamp"]) if ts > up_to_ts: break vol = float(b.get("volume", 0) or 0) if vol <= 0: continue typical = (float(b["high"]) + float(b["low"]) + float(b["close"])) / 3.0 cum_pv += typical * vol cum_vol += vol if cum_vol <= 0: return None return cum_pv / cum_vol def _linear_scaler( value: float | None, low: float | None, high: float | None, floor: float = 1.0, *, invert: bool = False, ) -> float: """Linear interpolation scaler (same logic as simulator._linear_scaler).""" if value is None or low is None or high is None or high <= low: return 1.0 floor = max(0.0, min(1.0, floor)) if invert: if value <= low: return floor if value >= high: return 1.0 frac = (value - low) / (high - low) return floor + frac * (1.0 - floor) if value <= low: return 1.0 if value >= high: return floor frac = (value - low) / (high - low) return 1.0 - frac * (1.0 - floor) def _linear_range_scaler( value: float | None, low: float | None, high: float | None, low_scale: float, high_scale: float, ) -> float: """Linear interpolation between arbitrary low/high scaler bounds.""" if value is None or low is None or high is None or high <= low: return 1.0 low_scale = max(0.0, low_scale) high_scale = max(0.0, high_scale) if value <= low: return low_scale if value >= high: return high_scale frac = (value - low) / (high - low) return low_scale + frac * (high_scale - low_scale) def _orb_vix_size_scaler(vix_value: float | None, params: ORBStrategyParams) -> float: """Position-size scaler based on VIX level for ORB strategy.""" return _linear_scaler( vix_value, params.vix_size_scale_low, params.vix_size_scale_high, params.vix_size_scale_min, ) def _orb_entropy_size_scaler(entropy_20d: float | None, params: ORBStrategyParams) -> float: """Per-candidate size scaler based on entropy_20d (from momentum strategy). Higher entropy → lower size. Disabled when entropy_size_scale_low is None.""" return _linear_scaler( entropy_20d, getattr(params, "entropy_size_scale_low", None), getattr(params, "entropy_size_scale_high", None), getattr(params, "entropy_size_scale_min", 0.6), ) def _orb_sparse_day_scaler(selected_count: int, params: ORBStrategyParams) -> float: threshold = getattr(params, "full_size_positions_threshold", None) if threshold is None or threshold <= 0: return 1.0 floor = max(0.0, min(1.0, getattr(params, "sparse_day_size_floor", 1.0))) if selected_count >= threshold: return 1.0 ratio = selected_count / threshold return max(floor, min(1.0, ratio)) def _first_regular_bar( all_bars: list[dict], market_open: dt.datetime, ) -> dict | None: """Return the first regular-session bar when it is plausibly aligned to the open.""" mkt_bars = filter_market_hours(all_bars) if not mkt_bars: return None first_bar = mkt_bars[0] first_bar_ts = _parse_ts(first_bar["timestamp"]) if abs((first_bar_ts - market_open).total_seconds() / 60) > 10: return None return first_bar def _first_regular_open(all_bars: list[dict], date_str: str) -> float | None: first_bar = _first_regular_bar(all_bars, _market_open_ts(date_str)) if not first_bar: return None open_price = first_bar.get("open") if open_price is None: return None try: open_float = float(open_price) except (TypeError, ValueError): return None return open_float if open_float > 0 else None def _regime_today_open( enrich: dict, all_bars: list[dict] | None, date_str: str, ) -> float | None: """Use live intraday open for synthetic same-day daily rows. Same-day daily bars can lag the intraday/today feed. The backtester creates a prev-close placeholder daily row so prior-day enrichment exists; using that placeholder open in breadth/regime would turn real gap-ups into zero-gaps. """ if enrich.get("synthetic_today_daily") and all_bars: intraday_open = _first_regular_open(all_bars, date_str) if intraday_open is not None: return intraday_open today_open = enrich.get("today_open") if today_open is None: return None try: today_open_float = float(today_open) except (TypeError, ValueError): return None return today_open_float if today_open_float > 0 else None def _bar_close_location(bar: dict | None) -> float | None: """Bar close location within range, normalized to [0, 1].""" if not bar: return None high = float(bar.get("high", 0) or 0) low = float(bar.get("low", 0) or 0) close = float(bar.get("close", 0) or 0) if high <= low: return None return (close - low) / (high - low) def _bar_return_pct(bar: dict | None) -> float | None: """Bar return from open to close.""" if not bar: return None open_ = float(bar.get("open", 0) or 0) close = float(bar.get("close", 0) or 0) if open_ <= 0: return None return (close - open_) / open_ def _bar_dollar_volume(bar: dict | None) -> float | None: """Approximate dollar volume for one intraday bar.""" if not bar: return None try: volume = float(bar.get("volume", 0) or 0) close = float(bar.get("close", 0) or bar.get("open", 0) or 0) except (TypeError, ValueError): return None if volume <= 0 or close <= 0: return None return volume * close def _last_regular_bar(all_bars: list[dict]) -> dict | None: mkt_bars = filter_market_hours(all_bars) if not mkt_bars: return None return mkt_bars[-1] def _idle_entry_bar( all_bars: list[dict], date_str: str, params: ORBStrategyParams, ) -> dict | None: mkt_bars = filter_market_hours(all_bars) if not mkt_bars: return None timing = str(getattr(params, "orb_idle_sleeve_entry_timing", "close") or "close").lower() if timing in {"minutes_after_open", "after_open", "intraday"}: minutes = max( 0, int(getattr(params, "orb_idle_sleeve_entry_minutes_after_open", 60) or 0), ) target = _market_open_ts(date_str) + dt.timedelta(minutes=minutes) for bar in mkt_bars: if _parse_ts(bar["timestamp"]) >= target: return bar return None return mkt_bars[-1] def _idle_sleeve_same_day_exit(params: ORBStrategyParams) -> bool: return str( getattr(params, "orb_idle_sleeve_exit_timing", "next_open") or "next_open" ).lower() in {"same_day_close", "day_close", "close"} def _idle_session_stats( all_bars: list[dict], date_str: str, params: ORBStrategyParams, ) -> dict[str, float | dict | str] | None: """Regular-session stats known at the configured idle-sleeve decision point.""" mkt_bars = filter_market_hours(all_bars) if not mkt_bars: return None entry_bar = _idle_entry_bar(all_bars, date_str, params) if entry_bar is None: return None entry_ts = _parse_ts(entry_bar["timestamp"]) mkt_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts] if not mkt_bars: return None first_bar = _first_regular_bar(all_bars, _market_open_ts(date_str)) last_bar = mkt_bars[-1] if first_bar is None: first_bar = mkt_bars[0] open_price = float(first_bar.get("open", 0) or 0) close_price = float(last_bar.get("close", 0) or 0) if open_price <= 0 or close_price <= 0: return None high = max(float(b.get("high", 0) or 0) for b in mkt_bars) low = min(float(b.get("low", 0) or 0) for b in mkt_bars) dollar_vol = 0.0 for bar in mkt_bars: vol = float(bar.get("volume", 0) or 0) close = float(bar.get("close", 0) or bar.get("open", 0) or 0) if vol > 0 and close > 0: dollar_vol += vol * close close_location = (close_price - low) / (high - low) if high > low else 0.5 return { "first_bar": first_bar, "last_bar": last_bar, "open": open_price, "close": close_price, "return_pct": (close_price - open_price) / open_price, "close_location": close_location, "dollar_vol": dollar_vol, "timestamp": str(last_bar.get("timestamp") or ""), } def _idle_sleeve_support_tickers(params: ORBStrategyParams) -> set[str]: tickers: set[str] = set() for ticker in getattr(params, "orb_idle_sleeve_parking_symbols", []) or []: if ticker: tickers.add(str(ticker).upper()) for attr in ( "orb_idle_sleeve_parking_base_symbol", "orb_idle_sleeve_parking_overlay_symbol", "orb_idle_sleeve_parking_defensive_symbol", ): ticker = getattr(params, attr, None) if ticker: tickers.add(str(ticker).upper()) for ticker in getattr(params, "orb_idle_sleeve_risk_off_symbols", []) or []: if ticker: tickers.add(str(ticker).upper()) for ticker in getattr(params, "orb_idle_sleeve_sector_rotation_symbols", []) or []: if ticker: tickers.add(str(ticker).upper()) market_ticker = getattr(params, "orb_idle_sleeve_market_ticker", None) if market_ticker: tickers.add(str(market_ticker).upper()) return tickers def _idle_pick_best_symbol( symbols: list[str], bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, *, min_return_pct: float | None = None, min_close_location: float | None = None, ) -> tuple[str, dict[str, float | dict | str]] | None: ranked: list[tuple[float, str, dict[str, float | dict | str]]] = [] for raw_symbol in symbols: symbol = str(raw_symbol or "").upper() stats = _idle_session_stats(bars_by_ticker.get(symbol, []), date_str, params) if not stats: continue day_ret = float(stats["return_pct"]) if min_return_pct is not None and day_ret < float(min_return_pct): continue close_location = float(stats["close_location"]) if min_close_location is not None and close_location < float(min_close_location): continue score = day_ret + 0.10 * close_location ranked.append((score, symbol, stats)) if not ranked: return None _score, symbol, stats = max(ranked, key=lambda item: item[0]) return symbol, stats def _idle_stats_for_symbol( symbol: str | None, bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, ) -> tuple[str, dict[str, float | dict | str]] | None: ticker = str(symbol or "").upper() if not ticker: return None stats = _idle_session_stats(bars_by_ticker.get(ticker, []), date_str, params) if not stats: return None return ticker, stats def _idle_pick_defensive_symbol( bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, ) -> tuple[str, dict[str, float | dict | str]] | None: preferred = getattr(params, "orb_idle_sleeve_parking_defensive_symbol", "SGOV") symbols: list[str] = [] if preferred: symbols.append(str(preferred)) for fallback in ("SGOV",): if fallback not in {s.upper() for s in symbols}: symbols.append(fallback) for raw in getattr(params, "orb_idle_sleeve_risk_off_symbols", []) or []: if raw and str(raw).upper() not in {s.upper() for s in symbols}: symbols.append(str(raw)) for symbol in symbols: pick = _idle_stats_for_symbol(symbol, bars_by_ticker, date_str, params) if pick is not None: return pick return None def _idle_pick_pead_like_parking( bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, *, market_stats: dict[str, float | dict | str] | None, market_day_return: float | None, risk_on_allowed: bool, ) -> tuple[str, dict[str, float | dict | str]] | None: if not risk_on_allowed or market_stats is None or market_day_return is None: return _idle_pick_defensive_symbol(bars_by_ticker, date_str, params) market_close_location = float(market_stats.get("close_location") or 0.0) overlay_symbol = getattr(params, "orb_idle_sleeve_parking_overlay_symbol", None) overlay_min_return = float( getattr(params, "orb_idle_sleeve_parking_overlay_min_market_return_pct", 0.005) or 0.0 ) overlay_min_close_location = float( getattr(params, "orb_idle_sleeve_parking_overlay_min_market_close_location", 0.70) or 0.0 ) if ( overlay_symbol and market_day_return >= overlay_min_return and market_close_location >= overlay_min_close_location ): overlay = _idle_stats_for_symbol(overlay_symbol, bars_by_ticker, date_str, params) if overlay is not None: return overlay base = _idle_stats_for_symbol( getattr(params, "orb_idle_sleeve_parking_base_symbol", "QQQM"), bars_by_ticker, date_str, params, ) if base is not None: return base best = _idle_pick_best_symbol( list(getattr(params, "orb_idle_sleeve_parking_symbols", []) or []), bars_by_ticker, date_str, params, min_return_pct=None, ) if best is not None: return best if bool(getattr(params, "orb_idle_sleeve_force_defensive_fallback", False)): return _idle_pick_defensive_symbol(bars_by_ticker, date_str, params) return None def _idle_stock_score( stats: dict[str, float | dict | str], info: dict, *, event_bonus: float = 0.0, ) -> float: day_ret = float(stats["return_pct"]) close_location = float(stats["close_location"]) dollar_vol = max(1.0, float(stats["dollar_vol"])) avg_dollar_vol = max(1.0, float(info.get("avg_dollar_vol_30d") or 0.0)) return ( day_ret * 100.0 + close_location + 0.05 * math.log10(dollar_vol) + 0.03 * math.log10(avg_dollar_vol) + event_bonus ) def _idle_reclaim_stats( all_bars: list[dict], date_str: str, params: ORBStrategyParams, ) -> dict[str, float | dict[str, float | dict | str]] | None: session = _idle_session_stats(all_bars, date_str, params) if session is None: return None entry_ts = _parse_ts(str(session["timestamp"])) checkpoint_minutes = max( 1, int(getattr(params, "orb_idle_sleeve_reclaim_checkpoint_minutes", 60) or 60), ) checkpoint_ts = _market_open_ts(date_str) + dt.timedelta(minutes=checkpoint_minutes) if checkpoint_ts >= entry_ts: return None checkpoint_bar = None for bar in filter_market_hours(all_bars): bar_ts = _parse_ts(bar["timestamp"]) if bar_ts > entry_ts: break if bar_ts >= checkpoint_ts: checkpoint_bar = bar break if checkpoint_bar is None: return None try: open_price = float(session["open"]) entry_close = float(session["close"]) checkpoint_close = float(checkpoint_bar.get("close", 0) or 0) except (TypeError, ValueError): return None if open_price <= 0 or checkpoint_close <= 0 or entry_close <= 0: return None return { "session": session, "early_return_pct": (checkpoint_close - open_price) / open_price, "late_return_pct": (entry_close - checkpoint_close) / checkpoint_close, } def _idle_float(info: dict, key: str) -> float | None: value = info.get(key) if value is None: return None try: return float(value) except (TypeError, ValueError): return None 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 _idle_bounds_ok( value: float | None, *, min_value: float | None, max_value: float | None, ) -> bool: if min_value is not None and (value is None or value < float(min_value)): return False if max_value is not None and (value is None or value > float(max_value)): return False return True def _idle_feature_filters_ok(info: dict, params: ORBStrategyParams, prefix: str) -> bool: """Apply optional daily-feature bounds for ORB idle single-name sleeves.""" gap_pct = _idle_float(info, "gap_pct") ret_5d = _idle_float(info, "ret_5d") return ( _idle_bounds_ok( gap_pct, min_value=getattr(params, f"{prefix}_min_gap_pct", None), max_value=getattr(params, f"{prefix}_max_gap_pct", None), ) and _idle_bounds_ok( ret_5d, min_value=getattr(params, f"{prefix}_min_ret_5d", None), max_value=getattr(params, f"{prefix}_max_ret_5d", None), ) ) def _idle_event_age_ok(info: dict, params: ORBStrategyParams, key: str) -> bool: max_days = getattr(params, "orb_idle_sleeve_event_max_days_since", None) if max_days is None: return True days_since = _idle_float(info, key) return days_since is not None and days_since <= int(max_days) def _build_idle_sleeve_orders( bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], ) -> list[_IdleSleeveOrder]: """Pick one close-entry candidate per idle sleeve. Sleeves intentionally reuse only data available by the configured fallback entry time: ETF parking, liquid close-strength alpha, late reclaim, Form 4, 13D/G ownership, and risk-off GLD/SGOV-style defensive alpha. """ raw_weights = getattr(params, "orb_idle_sleeve_weights", {}) or {} weights = { str(k): max(0.0, float(v or 0.0)) for k, v in raw_weights.items() } max_positions = max(0, int(getattr(params, "orb_idle_sleeve_max_positions", 5) or 0)) if max_positions <= 0: return [] support_tickers = _idle_sleeve_support_tickers(params) orders: list[_IdleSleeveOrder] = [] market_ticker = str(getattr(params, "orb_idle_sleeve_market_ticker", "QQQ") or "QQQ").upper() market_stats = _idle_session_stats(bars_by_ticker.get(market_ticker, []), date_str, params) market_day_return = ( float(market_stats["return_pct"]) if market_stats is not None else None ) market_close_location = ( float(market_stats["close_location"]) if market_stats is not None else None ) min_market_return = getattr(params, "orb_idle_sleeve_min_market_day_return_pct", None) min_market_close_location = getattr(params, "orb_idle_sleeve_min_market_close_location", None) risk_on_allowed = ( min_market_return is None or ( market_day_return is not None and market_day_return >= float(min_market_return) ) ) and ( min_market_close_location is None or ( market_close_location is not None and market_close_location >= float(min_market_close_location) ) ) min_stock_market_return = getattr( params, "orb_idle_sleeve_min_market_day_return_for_stock_sleeves_pct", None, ) stock_sleeves_allowed = ( min_stock_market_return is None or ( market_day_return is not None and market_day_return >= float(min_stock_market_return) ) ) if weights.get("parking", 0.0) > 0: parking_mode = str(getattr(params, "orb_idle_sleeve_parking_mode", "best") or "best").lower() if parking_mode == "pead_like": parking_pick = _idle_pick_pead_like_parking( bars_by_ticker, date_str, params, market_stats=market_stats, market_day_return=market_day_return, risk_on_allowed=risk_on_allowed, ) elif risk_on_allowed: parking_pick = _idle_pick_best_symbol( list(getattr(params, "orb_idle_sleeve_parking_symbols", []) or []), bars_by_ticker, date_str, params, min_return_pct=min_market_return, ) else: parking_pick = None if parking_pick is not None: symbol, stats = parking_pick orders.append( _IdleSleeveOrder( ticker=symbol, sleeve="parking", weight=weights["parking"], score=float(stats["return_pct"]), entry_return_pct=float(stats["return_pct"]), entry_close_location=float(stats["close_location"]), market_return_pct=market_day_return, market_close_location=market_close_location, ) ) if weights.get("sector_rotation", 0.0) > 0 and risk_on_allowed: rotation_pick = _idle_pick_best_symbol( list(getattr(params, "orb_idle_sleeve_sector_rotation_symbols", []) or []), bars_by_ticker, date_str, params, min_return_pct=getattr( params, "orb_idle_sleeve_sector_rotation_min_day_return_pct", 0.0, ), min_close_location=getattr( params, "orb_idle_sleeve_sector_rotation_min_close_location", 0.55, ), ) if rotation_pick is not None: symbol, stats = rotation_pick selected_rotation_symbols = {order.ticker for order in orders} if symbol not in selected_rotation_symbols: orders.append( _IdleSleeveOrder( ticker=symbol, sleeve="sector_rotation", weight=weights["sector_rotation"], score=float(stats["return_pct"]), entry_return_pct=float(stats["return_pct"]), entry_close_location=float(stats["close_location"]), market_return_pct=market_day_return, market_close_location=market_close_location, ) ) stock_ranked: list[tuple[float, str]] = [] reclaim_ranked: list[tuple[float, str]] = [] stock_meta: dict[str, dict] = {} reclaim_meta: dict[str, dict] = {} form4_ranked: list[tuple[float, str]] = [] ownership_ranked: list[tuple[float, str]] = [] min_idle_ret = float(getattr(params, "orb_idle_sleeve_idle_min_day_return_pct", 0.01) or 0.0) min_idle_close_loc = float(getattr(params, "orb_idle_sleeve_idle_min_close_location", 0.60) or 0.0) min_idle_day_dv = float(getattr(params, "orb_idle_sleeve_idle_min_day_dollar_vol", 0.0) or 0.0) min_idle_avg_dv = float(getattr(params, "orb_idle_sleeve_idle_min_avg_dollar_vol", 0.0) or 0.0) min_reclaim_ret = float(getattr(params, "orb_idle_sleeve_reclaim_min_day_return_pct", 0.004) or 0.0) max_reclaim_early_ret = float( getattr(params, "orb_idle_sleeve_reclaim_max_early_return_pct", 0.004) or 0.0 ) min_reclaim_late_ret = float( getattr(params, "orb_idle_sleeve_reclaim_min_late_return_pct", 0.006) or 0.0 ) min_reclaim_close_loc = float( getattr(params, "orb_idle_sleeve_reclaim_min_close_location", 0.70) or 0.0 ) min_reclaim_day_dv = float( getattr(params, "orb_idle_sleeve_reclaim_min_day_dollar_vol", 0.0) or 0.0 ) min_reclaim_avg_dv = float( getattr(params, "orb_idle_sleeve_reclaim_min_avg_dollar_vol", 0.0) or 0.0 ) min_event_ret = float(getattr(params, "orb_idle_sleeve_event_min_day_return_pct", -0.005) or 0.0) max_idle_ret = getattr(params, "orb_idle_sleeve_idle_max_day_return_pct", None) min_idle_score = getattr(params, "orb_idle_sleeve_idle_min_score", None) min_reclaim_score = getattr(params, "orb_idle_sleeve_reclaim_min_score", None) min_event_close_loc = float(getattr(params, "orb_idle_sleeve_event_min_close_location", 0.50) or 0.0) min_event_day_dv = float(getattr(params, "orb_idle_sleeve_event_min_day_dollar_vol", 0.0) or 0.0) min_event_avg_dv = float(getattr(params, "orb_idle_sleeve_event_min_avg_dollar_vol", 0.0) or 0.0) if stock_sleeves_allowed: for ticker, ticker_bars in bars_by_ticker.items(): symbol = str(ticker).upper() if symbol in support_tickers: continue stats = _idle_session_stats(ticker_bars, date_str, params) if not stats: continue close_price = float(stats["close"]) if close_price < float(getattr(params, "min_price", 0.0) or 0.0): continue info = enrichment.get(symbol, {}).get(date_str, {}) avg_dollar_vol = float(info.get("avg_dollar_vol_30d") or 0.0) day_ret = float(stats["return_pct"]) close_location = float(stats["close_location"]) day_dollar_vol = float(stats["dollar_vol"]) if ( day_ret >= min_idle_ret and (max_idle_ret is None or day_ret <= float(max_idle_ret)) and close_location >= min_idle_close_loc and day_dollar_vol >= min_idle_day_dv and avg_dollar_vol >= min_idle_avg_dv and _idle_feature_filters_ok(info, params, "orb_idle_sleeve_idle") ): score = _idle_stock_score(stats, info) if min_idle_score is None or score >= float(min_idle_score): stock_ranked.append((score, symbol)) stock_meta[symbol] = stats reclaim_stats = _idle_reclaim_stats(ticker_bars, date_str, params) if reclaim_stats is not None: early_ret = float(reclaim_stats["early_return_pct"]) late_ret = float(reclaim_stats["late_return_pct"]) if ( day_ret >= min_reclaim_ret and early_ret <= max_reclaim_early_ret and late_ret >= min_reclaim_late_ret and close_location >= min_reclaim_close_loc and day_dollar_vol >= min_reclaim_day_dv and avg_dollar_vol >= min_reclaim_avg_dv and _idle_feature_filters_ok(info, params, "orb_idle_sleeve_reclaim") ): score = ( _idle_stock_score(stats, info) + 120.0 * late_ret - 30.0 * max(0.0, early_ret) ) if min_reclaim_score is None or score >= float(min_reclaim_score): reclaim_ranked.append((score, symbol)) reclaim_meta[symbol] = {**stats, **reclaim_stats} if ( day_ret >= min_event_ret and close_location >= min_event_close_loc and day_dollar_vol >= min_event_day_dv and avg_dollar_vol >= min_event_avg_dv and _idle_feature_filters_ok(info, params, "orb_idle_sleeve_event") ): if bool(info.get("form4_flag")) and _idle_event_age_ok(info, params, "form4_days_since"): bonus = ( 0.25 * float(info.get("form4_owner_count") or 0.0) + 0.35 * float(info.get("form4_c_suite_count") or 0.0) + 0.05 * math.log10(max(1.0, float(info.get("form4_total_value") or 0.0))) ) form4_ranked.append((_idle_stock_score(stats, info, event_bonus=bonus), symbol)) if bool(info.get("ownership_13dg_flag")) and _idle_event_age_ok( info, params, "ownership_13dg_days_since" ): bonus = ( 0.75 if bool(info.get("ownership_13dg_initial_flag")) else 0.0 ) + 0.10 * float(info.get("ownership_13dg_strength_score") or 0.0) ownership_ranked.append((_idle_stock_score(stats, info, event_bonus=bonus), symbol)) selected_symbols = {order.ticker for order in orders} if weights.get("close_reclaim", 0.0) > 0 and reclaim_ranked: for _score, symbol in sorted(reclaim_ranked, reverse=True): if symbol not in selected_symbols: orders.append( _IdleSleeveOrder( symbol, "close_reclaim", weights["close_reclaim"], _score, entry_return_pct=float(reclaim_meta.get(symbol, {}).get("return_pct") or 0.0), entry_close_location=float( reclaim_meta.get(symbol, {}).get("close_location") or 0.0 ), reclaim_early_return_pct=float( reclaim_meta.get(symbol, {}).get("early_return_pct") or 0.0 ), reclaim_late_return_pct=float( reclaim_meta.get(symbol, {}).get("late_return_pct") or 0.0 ), market_return_pct=market_day_return, market_close_location=market_close_location, ) ) selected_symbols.add(symbol) break if weights.get("idle_alpha", 0.0) > 0 and stock_ranked: for _score, symbol in sorted(stock_ranked, reverse=True): if symbol not in selected_symbols: meta = stock_meta.get(symbol, {}) orders.append( _IdleSleeveOrder( symbol, "idle_alpha", weights["idle_alpha"], _score, entry_return_pct=float(meta.get("return_pct") or 0.0), entry_close_location=float(meta.get("close_location") or 0.0), market_return_pct=market_day_return, market_close_location=market_close_location, ) ) selected_symbols.add(symbol) break if weights.get("form4", 0.0) > 0 and form4_ranked: for _score, symbol in sorted(form4_ranked, reverse=True): if symbol not in selected_symbols: orders.append(_IdleSleeveOrder(symbol, "form4", weights["form4"], _score)) selected_symbols.add(symbol) break if weights.get("ownership", 0.0) > 0 and ownership_ranked: for _score, symbol in sorted(ownership_ranked, reverse=True): if symbol not in selected_symbols: orders.append(_IdleSleeveOrder(symbol, "ownership", weights["ownership"], _score)) selected_symbols.add(symbol) break risk_off_weight = weights.get("risk_off_alpha", 0.0) risk_off_allowed = ( risk_off_weight > 0 and ( not risk_on_allowed or not orders or (market_day_return is not None and market_day_return < 0.0) ) ) if risk_off_allowed: risk_off_pick = _idle_pick_best_symbol( list(getattr(params, "orb_idle_sleeve_risk_off_symbols", []) or []), bars_by_ticker, date_str, params, min_return_pct=None, ) if risk_off_pick is not None: symbol, stats = risk_off_pick if symbol not in selected_symbols: orders.append( _IdleSleeveOrder( ticker=symbol, sleeve="risk_off_alpha", weight=risk_off_weight, score=float(stats["return_pct"]), entry_return_pct=float(stats["return_pct"]), entry_close_location=float(stats["close_location"]), market_return_pct=market_day_return, market_close_location=market_close_location, ) ) if not orders and bool(getattr(params, "orb_idle_sleeve_force_defensive_fallback", False)): defensive_pick = _idle_pick_defensive_symbol(bars_by_ticker, date_str, params) fallback_weight = max(weights.values()) if weights else 1.0 if defensive_pick is not None and fallback_weight > 0: symbol, stats = defensive_pick orders.append( _IdleSleeveOrder( ticker=symbol, sleeve="parking_defensive_fallback", weight=fallback_weight, score=float(stats["return_pct"]), entry_return_pct=float(stats["return_pct"]), entry_close_location=float(stats["close_location"]), market_return_pct=market_day_return, market_close_location=market_close_location, ) ) return sorted(orders, key=lambda order: (order.weight, order.score), reverse=True)[:max_positions] def _open_idle_sleeve_positions( bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], budget: float, next_date_str: str | None = None, ) -> list[dict]: if budget <= 0: return [] orders = _build_idle_sleeve_orders(bars_by_ticker, date_str, params, enrichment) total_weight = sum(order.weight for order in orders if order.weight > 0) if total_weight <= 0: return [] slippage = params.slippage_bps / 10_000.0 positions: list[dict] = [] used = 0.0 support_tickers = _idle_sleeve_support_tickers(params) exit_timing = str(getattr(params, "orb_idle_sleeve_exit_timing", "next_open") or "next_open").lower() normalize_weights = bool(getattr(params, "orb_idle_sleeve_normalize_weights", True)) max_positions = max(0, int(getattr(params, "orb_idle_sleeve_max_positions", 5) or 0)) def add_position(order: _IdleSleeveOrder, sleeve_budget: float) -> float: stats = _idle_session_stats(bars_by_ticker.get(order.ticker, []), date_str, params) if not stats: return 0.0 entry_raw = float(stats["close"]) if entry_raw <= 0: return 0.0 entry_price = _apply_slippage_entry(entry_raw, slippage) shares = math.floor(sleeve_budget / entry_price) if shares <= 0: return 0.0 cost = shares * entry_price entry_slippage = abs(entry_price - entry_raw) * shares info = enrichment.get(order.ticker, {}).get(date_str, {}) gap_pct = None try: prev_close = float(info.get("prev_close") or 0.0) today_open = float(info.get("today_open") or 0.0) if prev_close > 0 and today_open > 0: gap_pct = (today_open - prev_close) / prev_close except (TypeError, ValueError): gap_pct = None for existing in positions: if str(existing.get("ticker") or "").upper() != order.ticker: continue existing_shares = int(existing.get("shares") or 0) existing_cost = float(existing.get("cost") or 0.0) existing["shares"] = existing_shares + shares existing["cost"] = existing_cost + cost existing["entry_slippage"] = float(existing.get("entry_slippage") or 0.0) + entry_slippage return cost positions.append( { "entry_date": date_str, "ticker": order.ticker, "sleeve": order.sleeve, "entry_price": entry_price, "entry_price_raw": entry_raw, "entry_time": str(stats["timestamp"]), "shares": shares, "cost": cost, "entry_slippage": entry_slippage, "target_exit_date": next_date_str, "score": order.score, "gap_pct": gap_pct, "idle_entry_day_return_pct": order.entry_return_pct, "idle_entry_close_location": order.entry_close_location, "idle_reclaim_early_return_pct": order.reclaim_early_return_pct, "idle_reclaim_late_return_pct": order.reclaim_late_return_pct, "idle_market_day_return_pct": order.market_return_pct, "idle_market_close_location": order.market_close_location, } ) return cost for order in orders: if ( exit_timing not in {"next_close", "same_day_close", "day_close", "close"} and next_date_str and order.ticker not in support_tickers ): next_open = ( enrichment.get(order.ticker, {}).get(next_date_str, {}).get("today_open") ) try: next_open_ok = next_open is not None and float(next_open) > 0 except (TypeError, ValueError): next_open_ok = False if not next_open_ok: continue if normalize_weights: raw_budget = budget * (order.weight / total_weight) else: raw_budget = budget * min(1.0, max(0.0, order.weight)) remaining = max(0.0, budget - used) sleeve_budget = min(raw_budget, remaining) if sleeve_budget <= 0: break used += add_position(order, sleeve_budget) if bool(getattr(params, "orb_idle_sleeve_defensive_fill_unused_budget", False)): remaining = max(0.0, budget - used) if remaining > 0 and len(positions) < max_positions: defensive_pick = _idle_pick_defensive_symbol(bars_by_ticker, date_str, params) if defensive_pick is not None: symbol, stats = defensive_pick used += add_position( _IdleSleeveOrder( ticker=symbol, sleeve="defensive_fill", weight=1.0, score=float(stats["return_pct"]), entry_return_pct=float(stats["return_pct"]), entry_close_location=float(stats["close_location"]), ), remaining, ) return positions def _idle_exit_bar( bars: list[dict], exit_timing: str, ) -> dict | None: mkt_bars = filter_market_hours(bars) if not mkt_bars: return None if str(exit_timing or "next_open").lower() in { "next_close", "same_day_close", "day_close", "close", }: return mkt_bars[-1] return mkt_bars[0] def _idle_same_day_stop_fill( bars: list[dict], entry_time: str, entry_raw: float, stop_loss_pct: float | None, ) -> tuple[float, str] | None: if stop_loss_pct is None or stop_loss_pct >= 0 or entry_raw <= 0: return None try: entry_ts = _parse_ts(entry_time) except (TypeError, ValueError): return None stop_raw = entry_raw * (1.0 + float(stop_loss_pct)) if stop_raw <= 0: return None for bar in filter_market_hours(bars): bar_ts = _parse_ts(bar["timestamp"]) if bar_ts <= entry_ts: continue low = float(bar.get("low", 0.0) or 0.0) if low > 0 and low <= stop_raw: return stop_raw, str(bar.get("timestamp") or "") return None def _close_idle_sleeve_positions( pending_positions: list[dict], bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], ) -> tuple[list[IntradayTrade], list[dict]]: if not pending_positions: return [], [] slippage = params.slippage_bps / 10_000.0 closed: list[IntradayTrade] = [] still_open: list[dict] = [] exit_timing = str(getattr(params, "orb_idle_sleeve_exit_timing", "next_open") or "next_open").lower() same_day_exit = _idle_sleeve_same_day_exit(params) if same_day_exit: exit_reason = "idle_sleeve_same_day_close" elif exit_timing == "next_close": exit_reason = "idle_sleeve_next_close" else: exit_reason = "idle_sleeve_next_open" for pos in pending_positions: pos_exit_reason = exit_reason target_exit_date = str(pos.get("target_exit_date") or "") if target_exit_date and date_str < target_exit_date: still_open.append(pos) continue ticker = str(pos.get("ticker") or "").upper() exit_bar = _idle_exit_bar(bars_by_ticker.get(ticker, []), exit_timing) exit_ts = "" if exit_bar is not None: exit_raw = float(exit_bar.get("close" if same_day_exit or exit_timing == "next_close" else "open", 0) or 0) exit_ts = str(exit_bar.get("timestamp") or "") elif not same_day_exit and exit_timing != "next_close": # Fallback to the daily open so overnight sleeves exit on the next # trading day even when the ticker was not in that day's intraday # fetch set. This avoids accidentally turning a next-open sleeve # into a multi-day hold because of candidate-map sparsity. exit_raw = float( enrichment.get(ticker, {}).get(date_str, {}).get("today_open") or 0.0 ) exit_ts = _market_open_ts(date_str).isoformat() else: still_open.append(pos) continue if exit_raw <= 0: if not same_day_exit and exit_timing != "next_close" and target_exit_date and date_str >= target_exit_date: exit_raw = float(pos.get("entry_price_raw") or pos.get("entry_price") or 0.0) exit_ts = _market_open_ts(date_str).isoformat() pos_exit_reason = "idle_sleeve_missing_next_open" else: still_open.append(pos) continue shares = int(pos.get("shares") or 0) entry_price = float(pos.get("entry_price") or 0.0) entry_raw = float(pos.get("entry_price_raw") or entry_price) if shares <= 0 or entry_price <= 0: continue if same_day_exit: stop_fill = _idle_same_day_stop_fill( bars_by_ticker.get(ticker, []), str(pos.get("entry_time") or ""), entry_raw, getattr(params, "orb_idle_sleeve_same_day_stop_loss_pct", None), ) if stop_fill is not None: exit_raw, exit_ts = stop_fill pos_exit_reason = "idle_sleeve_same_day_stop_loss" exit_price = _apply_slippage_exit(exit_raw, slippage) pnl = (exit_price - entry_price) * shares exit_slippage = abs(exit_price - exit_raw) * shares entry_slippage = float(pos.get("entry_slippage") or 0.0) trade = IntradayTrade( date=date_str, ticker=ticker, entry_price=round(entry_price, 4), exit_price=round(exit_price, 4), entry_time=str(pos.get("entry_time") or ""), exit_time=exit_ts, shares=shares, pnl=round(pnl, 2), pnl_pct=round((exit_price - entry_price) / entry_price, 4), exit_reason=pos_exit_reason, slippage_cost=round(entry_slippage + exit_slippage, 2), trade_sleeve=f"orb_idle_{pos.get('sleeve') or 'unknown'}", trigger_type="orb_idle_sleeve", total_capital_deployed=round(float(pos.get("cost") or shares * entry_price), 2), candidate_score=round(float(pos.get("score") or 0.0), 4), orb_idle_sleeve_overnight=not same_day_exit, gap_pct=_round_optional(pos.get("gap_pct"), 6), idle_entry_day_return_pct=_round_optional(pos.get("idle_entry_day_return_pct"), 6), idle_entry_close_location=_round_optional(pos.get("idle_entry_close_location"), 6), idle_reclaim_early_return_pct=_round_optional(pos.get("idle_reclaim_early_return_pct"), 6), idle_reclaim_late_return_pct=_round_optional(pos.get("idle_reclaim_late_return_pct"), 6), idle_market_day_return_pct=_round_optional(pos.get("idle_market_day_return_pct"), 6), idle_market_close_location=_round_optional(pos.get("idle_market_close_location"), 6), ) closed.append(trade) return closed, still_open def _opening_breadth_stats( bars_by_ticker: dict[str, list[dict]], date_str: str, *, min_first_bar_dollar_vol: float | None, strong_close_location: float, ) -> dict[str, float | int | None]: """Point-in-time universe breadth from the first regular-session bar.""" market_open = _market_open_ts(date_str) total = 0 positive = 0 strong_close_location_count = 0 return_sum = 0.0 for bars in bars_by_ticker.values(): first_bar = _first_regular_bar(bars, market_open) first_bar_return = _bar_return_pct(first_bar) if first_bar_return is None: continue if min_first_bar_dollar_vol is not None: dollar_vol = _bar_dollar_volume(first_bar) if dollar_vol is None or dollar_vol < min_first_bar_dollar_vol: continue total += 1 return_sum += first_bar_return if first_bar_return > 0: positive += 1 close_location = _bar_close_location(first_bar) if ( close_location is not None and close_location >= strong_close_location ): strong_close_location_count += 1 if total <= 0: return { "total_count": 0, "positive_ratio": None, "avg_return_pct": None, "strong_close_location_ratio": None, } return { "total_count": total, "positive_ratio": positive / total, "avg_return_pct": return_sum / total, "strong_close_location_ratio": strong_close_location_count / total, } @dataclass class ORBSimulationState: """Rolling ORB simulation state for chunked backtests.""" equity: float peak_equity: float | None = None ticker_last_traded: dict[str, str] = field(default_factory=dict) settled_cash: float | None = None pending_settlements: list[tuple[str, float]] = field(default_factory=list) recent_daily_pnl: list[float] = field(default_factory=list) """Recent daily PnL history for cross-chunk rolling loss filter continuity.""" streak_outcomes: list[bool] = field(default_factory=list) """Recent trade outcomes for streak sizing continuity across chunks.""" pending_idle_sleeve_positions: list[dict] = field(default_factory=list) """Open overnight idle-sleeve positions awaiting a future session exit.""" @dataclass class _IdleSleeveOrder: ticker: str sleeve: str weight: float score: float entry_return_pct: float | None = None entry_close_location: float | None = None reclaim_early_return_pct: float | None = None reclaim_late_return_pct: float | None = None market_return_pct: float | None = None market_close_location: float | None = None # ── Bar Aggregation ─────────────────────────────────────────────────────── def _aggregate_bars(bars: list[dict], group_size: int) -> list[dict]: """Aggregate consecutive bars into larger intervals (e.g. 6 × 5-min → 30-min). Each output bar has: timestamp (from LAST bar in group — when the candle completes), OHLCV aggregated. Incomplete trailing groups are still emitted. """ if group_size <= 1: return bars result: list[dict] = [] for i in range(0, len(bars), group_size): group = bars[i : i + group_size] result.append({ "timestamp": group[-1]["timestamp"], # end of bar: when trader sees completed candle "open": group[0]["open"], "high": max(b["high"] for b in group), "low": min(b["low"] for b in group), "close": group[-1]["close"], "volume": sum(b.get("volume", 0) for b in group), }) return result # ── ORB Candle Classification ────────────────────────────────────────────── def classify_orb_candle(orb_bar: dict) -> str: """Classify the ORB candle as 'bullish', 'bearish', or 'doji'. Args: orb_bar: The first 5-min bar dict with 'open' and 'close' keys. Returns: 'bullish' if close > open (by more than doji threshold), 'bearish' if close < open (by more than doji threshold), 'doji' if close ≈ open. """ o = orb_bar.get("open", 0) c = orb_bar.get("close", 0) if o <= 0: return "doji" diff_pct = (c - o) / o if diff_pct > _DOJI_THRESHOLD: return "bullish" if diff_pct < -_DOJI_THRESHOLD: return "bearish" return "doji" # ── Composite Ranking ────────────────────────────────────────────────────── def _normalize_scores(values: list[float]) -> list[float]: """Min-max normalize a list to [0, 1]. Returns zeros if all values equal.""" if not values: return [] mn, mx = min(values), max(values) if mx <= mn: return [0.5] * len(values) return [(v - mn) / (mx - mn) for v in values] def _rank_percentiles(values: list[float | None]) -> list[float]: """Return low-to-high percentile ranks in [0, 1] with average ranks for ties.""" if not values: return [] if len(values) == 1: return [1.0] clean_values = [ float(v) if v is not None else 0.0 for v in values ] sorted_pairs = sorted(enumerate(clean_values), key=lambda item: item[1]) ranks = [0.0] * len(clean_values) i = 0 while i < len(sorted_pairs): j = i + 1 value = sorted_pairs[i][1] while j < len(sorted_pairs) and sorted_pairs[j][1] == value: j += 1 avg_rank = (i + j - 1) / 2.0 percentile = avg_rank / (len(sorted_pairs) - 1) for k in range(i, j): ranks[sorted_pairs[k][0]] = percentile i = j return ranks def _rank_percentiles_by_key( values: list[float | None], keys: list[str], *, min_bucket_size: int, fallback: list[float] | None = None, ) -> list[float]: """Rank within each key bucket, falling back to global ranks for thin buckets.""" if not values: return [] ranks = list(fallback) if fallback is not None else _rank_percentiles(values) bucket_indices: dict[str, list[int]] = {} for i, key in enumerate(keys): bucket_indices.setdefault(key, []).append(i) min_size = max(2, int(min_bucket_size or 2)) for indices in bucket_indices.values(): if len(indices) < min_size: continue bucket_ranks = _rank_percentiles([values[i] for i in indices]) for local_idx, rank in zip(indices, bucket_ranks, strict=False): ranks[local_idx] = rank return ranks def _candidate_price_bucket(cand: dict) -> str: """Stable coarse price buckets for contextual volume ranks.""" orb_bar = cand.get("orb_bar") or {} price = _float_or_none(orb_bar.get("open")) or 0.0 if price < 30.0: return "p000_030" if price < 75.0: return "p030_075" if price < 150.0: return "p075_150" return "p150_plus" def _timed_candidate_key( item: tuple[dt.datetime, dict, str, str, float | None], ) -> tuple[str, str, str]: entry_ts, cand, _direction, trigger_type, _forced_price = item return (str(cand.get("ticker") or ""), str(trigger_type), entry_ts.isoformat()) def _bar_dollar_volume(bar: dict | None) -> float: if not bar: return 0.0 volume = float(bar.get("volume", 0.0) or 0.0) price = ( _float_or_none(bar.get("close")) or _float_or_none(bar.get("open")) or 0.0 ) return max(0.0, volume * price) def _entry_participation_features( mkt_bars: list[dict], orb_bar: dict, entry_ts: dt.datetime, ) -> dict[str, float]: """Scale-free participation features available at reclaim signal close.""" entry_bar = next( (bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) == entry_ts), None, ) entry_bar_dollar_vol = _bar_dollar_volume(entry_bar) cumulative_dollar_vol = 0.0 for bar in mkt_bars: ts = _parse_ts(bar["timestamp"]) if ts > entry_ts: break cumulative_dollar_vol += _bar_dollar_volume(bar) entry_volume = float(entry_bar.get("volume", 0.0) or 0.0) if entry_bar else 0.0 orb_ts = _parse_ts(orb_bar["timestamp"]) prior_volumes = [ float(bar.get("volume", 0.0) or 0.0) for bar in mkt_bars if orb_ts < _parse_ts(bar["timestamp"]) < entry_ts ] avg_prior_volume = ( sum(prior_volumes) / len(prior_volumes) if prior_volumes else 0.0 ) entry_rel_volume = entry_volume / avg_prior_volume if avg_prior_volume > 0 else 0.0 return { "entry_bar_dollar_vol": entry_bar_dollar_vol, "entry_cumulative_dollar_vol": max(0.0, cumulative_dollar_vol), "entry_rel_volume": max(0.0, entry_rel_volume), } def _reclaim_entry_attention_trigger_allowed( params: ORBStrategyParams, trigger_type: str, ) -> bool: allowed = getattr(params, "reclaim_entry_attention_trigger_types", None) if allowed is None: return trigger_type in {"vwap_reclaim", "soft_day_vwap_reclaim"} allowed_set = {str(value) for value in allowed} return trigger_type in allowed_set def _compute_reclaim_entry_attention_by_key( timed_candidates: list[tuple[dt.datetime, dict, str, str, float | None]], params: ORBStrategyParams, ) -> dict[tuple[str, str, str], dict[str, float]]: if not bool(getattr(params, "reclaim_entry_attention_enabled", False)): return {} if not timed_candidates: return {} keys: list[tuple[str, str, str]] = [] features: list[dict[str, float]] = [] for item in timed_candidates: entry_ts, cand, _direction, _trigger_type, _forced_price = item keys.append(_timed_candidate_key(item)) features.append( _entry_participation_features( cand.get("mkt_bars") or [], cand.get("orb_bar") or {}, entry_ts, ) ) entry_bar_dollar_ranks = _rank_percentiles( [row["entry_bar_dollar_vol"] for row in features] ) cumulative_dollar_ranks = _rank_percentiles( [row["entry_cumulative_dollar_vol"] for row in features] ) entry_rel_volume_ranks = _rank_percentiles( [row["entry_rel_volume"] for row in features] ) w_bar = max( 0.0, float( getattr( params, "reclaim_entry_attention_weight_entry_bar_dollar_vol", 0.45, ) or 0.0 ), ) w_cum = max( 0.0, float( getattr( params, "reclaim_entry_attention_weight_cumulative_dollar_vol", 0.35, ) or 0.0 ), ) w_rel = max( 0.0, float( getattr( params, "reclaim_entry_attention_weight_entry_rel_vol", 0.20, ) or 0.0 ), ) weight_sum = w_bar + w_cum + w_rel if weight_sum <= 0.0: w_bar, w_cum, w_rel = 0.45, 0.35, 0.20 weight_sum = 1.0 out: dict[tuple[str, str, str], dict[str, float]] = {} for i, key in enumerate(keys): rank = ( entry_bar_dollar_ranks[i] * w_bar + cumulative_dollar_ranks[i] * w_cum + entry_rel_volume_ranks[i] * w_rel ) / weight_sum out[key] = { "reclaim_entry_attention_rank_pct": rank, "entry_bar_dollar_vol_rank_pct": entry_bar_dollar_ranks[i], "entry_cumulative_dollar_vol_rank_pct": cumulative_dollar_ranks[i], "entry_rel_volume_rank_pct": entry_rel_volume_ranks[i], "entry_rel_volume": features[i]["entry_rel_volume"], } return out def _apply_orb_basket_quality_floor( candidates: list[dict], params: ORBStrategyParams, ) -> list[dict]: """Prune weak residual ORB names whose score trails the day leader too far.""" floor = getattr(params, "basket_quality_relative_floor", None) if floor is None or not candidates: return candidates floor = float(floor) if floor <= 0: return candidates best_score = float(candidates[0].get("score") or 0.0) if best_score <= 0: return candidates keep_at_least = max(0, int(getattr(params, "basket_quality_min_count", 0) or 0)) cutoff = best_score * floor kept = [cand for cand in candidates if float(cand.get("score") or 0.0) >= cutoff] if len(kept) < keep_at_least: return candidates[:keep_at_least] return kept def _is_red_to_green_reserved_candidate(cand: dict, params: ORBStrategyParams) -> bool: """Return True when a candidate qualifies for the red-to-green reserved sleeve.""" if cand.get("direction") != "bullish": return False gap_pct = float(cand.get("gap_pct") or 0.0) if gap_pct >= 0.0: return False min_abs_gap = getattr(params, "red_to_green_min_abs_gap_pct", None) if min_abs_gap is not None and abs(gap_pct) < float(min_abs_gap): return False min_body = getattr(params, "red_to_green_min_body_ratio", None) if min_body is not None and float(cand.get("body_ratio") or 0.0) < float(min_body): return False min_close = getattr(params, "red_to_green_min_close_location", None) if min_close is not None and float(cand.get("close_location") or 0.0) < float(min_close): return False min_orb_return = getattr(params, "red_to_green_min_orb_return", None) if min_orb_return is not None and float(cand.get("orb_return") or 0.0) < float(min_orb_return): return False min_rvol = getattr(params, "red_to_green_min_rvol", None) if min_rvol is not None and float(cand.get("rvol") or 0.0) < float(min_rvol): return False return True def _is_candidate_seed_overlay_reserved_candidate(cand: dict) -> bool: """Return True when a candidate came from an explicit seed overlay.""" return bool(cand.get("candidate_seed_overlay")) def _is_broad_gapup_continuation_candidate(cand: dict) -> bool: """Return True when a candidate came from the high-gap continuation sleeve.""" return bool(cand.get("broad_gapup_continuation")) def _select_orb_candidates_with_overlays( raw_candidates: list[dict], params: ORBStrategyParams, ) -> list[dict]: """Apply final basket caps plus optional reserved candidate sleeves.""" max_candidates = max(0, int(getattr(params, "max_candidates", 0) or 0)) if max_candidates <= 0: return [] max_per_sector = getattr(params, "max_candidates_per_sector", None) max_small_gap_attention = getattr(params, "max_small_gap_attention_candidates", None) max_broad_gapup = getattr(params, "broad_gapup_continuation_max_candidates", None) broad_reserved_slots = ( max(0, int(max_broad_gapup)) if max_broad_gapup is not None and max_broad_gapup >= 0 else 0 ) reserved_slots = max(0, int(getattr(params, "red_to_green_reserved_slots", 0) or 0)) seed_overlay_reserved_slots = max( 0, int(getattr(params, "candidate_seed_overlay_reserved_slots", 0) or 0) ) has_caps = ( (max_per_sector is not None and max_per_sector > 0) or (max_small_gap_attention is not None and max_small_gap_attention >= 0) or (max_broad_gapup is not None and max_broad_gapup >= 0) ) if reserved_slots <= 0 and seed_overlay_reserved_slots <= 0 and not has_caps: return raw_candidates[:max_candidates] selected: list[dict] = [] selected_tickers: set[str] = set() sector_counts: dict[str, int] = {} small_gap_attention_count = 0 broad_gapup_count = 0 def try_add( cand: dict, *, red_to_green_reserved: bool, seed_overlay_reserved: bool, allow_extra_broad: bool = False, ) -> bool: nonlocal small_gap_attention_count, broad_gapup_count is_broad_candidate = _is_broad_gapup_continuation_candidate(cand) candidate_limit = max_candidates if allow_extra_broad and is_broad_candidate: candidate_limit = max_candidates + broad_reserved_slots if len(selected) >= candidate_limit: return False ticker = str(cand.get("ticker") or "") if ticker in selected_tickers: return False sector = str(cand.get("sector") or "UNKNOWN") if max_per_sector is not None and max_per_sector > 0: if sector_counts.get(sector, 0) >= max_per_sector: return False if ( max_small_gap_attention is not None and max_small_gap_attention >= 0 and cand.get("used_small_gap_attention_override") ): if small_gap_attention_count >= max_small_gap_attention: return False if ( max_broad_gapup is not None and max_broad_gapup >= 0 and is_broad_candidate ): if broad_gapup_count >= int(max_broad_gapup): return False cand["red_to_green_reserved"] = bool(red_to_green_reserved) cand["candidate_seed_overlay_reserved"] = bool(seed_overlay_reserved) selected.append(cand) selected_tickers.add(ticker) if max_per_sector is not None and max_per_sector > 0: sector_counts[sector] = sector_counts.get(sector, 0) + 1 if cand.get("used_small_gap_attention_override"): small_gap_attention_count += 1 if is_broad_candidate: broad_gapup_count += 1 return True if reserved_slots > 0: reserved_count = 0 for cand in raw_candidates: if not _is_red_to_green_reserved_candidate(cand, params): continue if try_add( cand, red_to_green_reserved=True, seed_overlay_reserved=False, ): reserved_count += 1 if reserved_count >= reserved_slots: break if seed_overlay_reserved_slots > 0: seed_overlay_reserved_count = 0 for cand in raw_candidates: if not _is_candidate_seed_overlay_reserved_candidate(cand): continue if try_add( cand, red_to_green_reserved=False, seed_overlay_reserved=True, ): seed_overlay_reserved_count += 1 if seed_overlay_reserved_count >= seed_overlay_reserved_slots: break for cand in raw_candidates: if broad_reserved_slots > 0 and _is_broad_gapup_continuation_candidate(cand): continue if ( try_add( cand, red_to_green_reserved=False, seed_overlay_reserved=False, ) and len(selected) >= max_candidates ): break if broad_reserved_slots > 0: for cand in raw_candidates: if not _is_broad_gapup_continuation_candidate(cand): continue try_add( cand, red_to_green_reserved=False, seed_overlay_reserved=False, allow_extra_broad=True, ) if broad_gapup_count >= broad_reserved_slots: break selected.sort( key=lambda c: ( _is_broad_gapup_continuation_candidate(c), -float(c.get("score") or 0.0), ) ) return selected def _orb_sector_confirmation_enabled(params: ORBStrategyParams) -> bool: return ( bool(getattr(params, "sector_confirmation_enabled", False)) or float(getattr(params, "sector_confirmation_score_weight", 0.0) or 0.0) != 0.0 or float(getattr(params, "sector_confirmation_size_scale", 1.0) or 1.0) != 1.0 or getattr(params, "sector_confirmation_liquid_min_premarket_dollar_vol", None) is not None or getattr(params, "sector_confirmation_liquid_size_scale", None) is not None or getattr(params, "sector_confirmation_illiquid_size_scale", None) is not None or float(getattr(params, "sector_confirmation_unconfirmed_size_scale", 1.0) or 1.0) != 1.0 ) def _signed_orb_return_for_direction(cand: dict) -> float: ret = float(cand.get("orb_return") or 0.0) return ret if cand.get("direction") == "bullish" else -ret def _apply_orb_sector_confirmation( raw_candidates: list[dict], params: ORBStrategyParams, ) -> None: """Annotate ORB candidates with same-sector opening confirmation. This is intentionally cross-sectional and lookahead-free: it only uses the first ORB candle features of candidates that already passed the day's candidate filters. """ for cand in raw_candidates: cand["sector_confirmation_active"] = False cand["sector_confirmation_member_count"] = 0 cand["sector_confirmation_avg_orb_return"] = 0.0 cand["sector_confirmation_total_first_bar_dollar_vol"] = 0.0 cand["sector_confirmation_score"] = 0.0 if not raw_candidates or not _orb_sector_confirmation_enabled(params): return groups: dict[tuple[str, str], list[dict]] = {} for cand in raw_candidates: sector = str(cand.get("sector") or "UNKNOWN").strip() if not sector or sector.upper() == "UNKNOWN": continue if _signed_orb_return_for_direction(cand) <= 0: continue key = (sector, str(cand.get("direction") or "")) groups.setdefault(key, []).append(cand) min_members = max(1, int(getattr(params, "sector_confirmation_min_members", 2) or 2)) min_avg_return = getattr(params, "sector_confirmation_min_avg_orb_return_pct", None) min_total_dollar_vol = getattr( params, "sector_confirmation_min_total_first_bar_dollar_vol", None, ) for members in groups.values(): member_count = len(members) avg_return = ( sum(_signed_orb_return_for_direction(cand) for cand in members) / member_count if member_count else 0.0 ) total_dollar_vol = sum( float(cand.get("first_bar_dollar_vol") or 0.0) for cand in members ) active = member_count >= min_members if min_avg_return is not None and avg_return < float(min_avg_return): active = False if min_total_dollar_vol is not None and total_dollar_vol < float(min_total_dollar_vol): active = False raw_score = member_count * max(avg_return, 0.0) if active else 0.0 for cand in members: cand["sector_confirmation_active"] = active cand["sector_confirmation_member_count"] = member_count cand["sector_confirmation_avg_orb_return"] = avg_return cand["sector_confirmation_total_first_bar_dollar_vol"] = total_dollar_vol cand["sector_confirmation_score"] = raw_score def _orb_sector_confirmation_size_scale( params: ORBStrategyParams, cand: dict, ) -> float: if not _orb_sector_confirmation_enabled(params): return 1.0 if cand.get("sector_confirmation_active"): raw_scale = getattr(params, "sector_confirmation_size_scale", 1.0) min_liquid_pm = getattr( params, "sector_confirmation_liquid_min_premarket_dollar_vol", None, ) if min_liquid_pm is not None: premarket_dollar_vol = float(cand.get("premarket_dollar_vol") or 0.0) if premarket_dollar_vol >= float(min_liquid_pm): raw_scale = getattr( params, "sector_confirmation_liquid_size_scale", raw_scale, ) else: raw_scale = getattr( params, "sector_confirmation_illiquid_size_scale", raw_scale, ) else: raw_scale = getattr(params, "sector_confirmation_unconfirmed_size_scale", 1.0) return max(0.0, min(2.0, float(1.0 if raw_scale is None else raw_scale))) def _orb_soft_day_sector_confirmation_override_allows( params: ORBStrategyParams, soft_day_reason: str | None, cand: dict, *, score_rank_pct: float, trigger_type: str, ) -> bool: if not getattr(params, "soft_day_sector_confirmation_override_enabled", False): return False if trigger_type == "soft_day_vwap_reclaim": return False allowed_triggers = getattr( params, "soft_day_sector_confirmation_override_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in set(allowed_triggers): return False if not cand.get("sector_confirmation_active"): return False allowed_parts = getattr( params, "soft_day_sector_confirmation_override_allowed_reason_parts", None, ) if allowed_parts is not None: reason_parts = set(str(soft_day_reason or "").split("+")) if not any(str(part) in reason_parts for part in allowed_parts): return False min_score = getattr( params, "soft_day_sector_confirmation_override_min_score_pct", None, ) if min_score is not None and score_rank_pct < float(min_score): return False min_pm_dollar_vol = getattr( params, "soft_day_sector_confirmation_override_min_premarket_dollar_vol", None, ) if min_pm_dollar_vol is not None: pm_dollar_vol = cand.get("premarket_dollar_vol") if pm_dollar_vol is None or float(pm_dollar_vol) < float(min_pm_dollar_vol): return False return True def _orb_soft_day_sector_confirmation_override_base_sizing( params: ORBStrategyParams, *, active: bool, sizing_cap: float, adjusted_sizing: float, ) -> tuple[float, float | None]: """Apply the optional soft-day sector override floor before risk overlays.""" if not active: return adjusted_sizing, None raw_floor = getattr( params, "soft_day_sector_confirmation_override_min_day_size_scale", None, ) if raw_floor is None: return adjusted_sizing, None floor = max(0.0, min(1.0, float(raw_floor))) return max(adjusted_sizing, sizing_cap * floor), floor def _market_return_at_entry_open( market_bars: list[dict] | None, entry_ts: dt.datetime, date_str: str, ) -> float | None: """Market return available at a candidate entry time without same-bar close lookahead.""" if not market_bars: return None market_open = _market_open_ts(date_str) first_bar = _first_regular_bar(market_bars, market_open) if not first_bar: return None session_open = float(first_bar.get("open") or 0.0) if session_open <= 0: return None latest_bar: dict | None = None latest_ts: dt.datetime | None = None for bar in market_bars: ts = _parse_ts(bar["timestamp"]) if ts < market_open or ts > entry_ts: continue if latest_ts is None or ts > latest_ts: latest_bar = bar latest_ts = ts if latest_bar is None or latest_ts is None: return None # If the candidate enters during this same bar, only the market bar open is # observable. Earlier bars can use close because they have completed. price = ( float(latest_bar.get("open") or 0.0) if latest_ts == entry_ts else float(latest_bar.get("close") or 0.0) ) if price <= 0: return None return (price - session_open) / session_open def _entry_market_guard_scale( params: ORBStrategyParams, market_bars: list[dict] | None, entry_ts: dt.datetime, date_str: str, is_soft_day: bool, ) -> tuple[bool, float | None, float]: if not bool(getattr(params, "entry_market_guard_enabled", False)): return False, None, 1.0 if is_soft_day and not bool(getattr(params, "entry_market_guard_apply_to_soft_day", True)): return False, None, 1.0 threshold = getattr(params, "entry_market_guard_min_return_pct", None) if threshold is None: return False, None, 1.0 market_return = _market_return_at_entry_open(market_bars, entry_ts, date_str) if market_return is None or market_return >= float(threshold): return False, market_return, 1.0 raw_scale = float(getattr(params, "entry_market_guard_size_scale", 1.0) or 0.0) return True, market_return, max(0.0, min(1.0, raw_scale)) def _compute_composite_score( rvol: float, gap_pct: float, first_bar_dollar_vol: float, params: ORBStrategyParams, rvol_list: list[float], gap_list: list[float], dolvol_list: list[float], idx: int, ) -> float: """Compute normalized composite ranking score for a single candidate. Uses pre-normalized lists (same index) to ensure cross-candidate normalization. """ # Clamp gap to positive (only care about gap-up for long-only) norm_rvol = rvol_list[idx] norm_gap = gap_list[idx] norm_dolvol = dolvol_list[idx] return ( norm_rvol * params.weight_rvol + norm_gap * params.weight_gap + norm_dolvol * params.weight_dollar_vol ) def _effective_orb_engine_family(params: ORBStrategyParams) -> str: family = getattr(params, "engine_family", "quality_breakout") or "quality_breakout" if family not in { "classic_breakout", "quality_breakout", "compression_breakout", "gainers_leader", "leader_followthrough", "stocks_in_play_dual_regime", "orb_pullback_v1", "vwap_reclaim_v1", "hypergap_failure_v1", }: return "quality_breakout" return family def _orb_broad_gapup_continuation_allows( params: ORBStrategyParams, *, direction: str, gap_pct: float, rvol: float | None, avg_dollar_vol: float | None, premarket_dollar_vol: float, first_bar_dollar_vol: float, body_ratio: float, close_location: float, ret_5d: float | None, gap_zscore_20d: float | None, ) -> bool: """Gate the high-gap continuation sleeve without relaxing base ORB filters.""" if not bool(getattr(params, "broad_gapup_continuation_enabled", False)): return False if direction != "bullish" or gap_pct <= 0: return False min_gap = getattr(params, "broad_gapup_continuation_min_gap_pct", None) if min_gap is not None and gap_pct < float(min_gap): return False max_gap = getattr(params, "broad_gapup_continuation_max_gap_pct", None) if max_gap is not None and gap_pct > float(max_gap): return False min_rvol = getattr(params, "broad_gapup_continuation_min_rvol", None) if min_rvol is not None and (rvol is None or float(rvol) < float(min_rvol)): return False min_avg_dollar_vol = getattr( params, "broad_gapup_continuation_min_avg_dollar_vol", None, ) if ( min_avg_dollar_vol is not None and ( avg_dollar_vol is None or float(avg_dollar_vol) < float(min_avg_dollar_vol) ) ): return False min_pm_dollar_vol = getattr( params, "broad_gapup_continuation_min_premarket_dollar_vol", None, ) if min_pm_dollar_vol is not None and premarket_dollar_vol < float(min_pm_dollar_vol): return False min_first_bar_dollar_vol = getattr( params, "broad_gapup_continuation_min_first_bar_dollar_vol", None, ) if ( min_first_bar_dollar_vol is not None and first_bar_dollar_vol < float(min_first_bar_dollar_vol) ): return False min_body = getattr(params, "broad_gapup_continuation_min_body_ratio", None) if min_body is not None and body_ratio < float(min_body): return False min_close = getattr(params, "broad_gapup_continuation_min_close_location", None) if min_close is not None and close_location < float(min_close): return False min_ret5 = getattr(params, "broad_gapup_continuation_min_ret_5d", None) if min_ret5 is not None and (ret_5d is None or float(ret_5d) < float(min_ret5)): return False max_ret5 = getattr(params, "broad_gapup_continuation_max_ret_5d", None) if max_ret5 is not None and (ret_5d is None or float(ret_5d) > float(max_ret5)): return False max_gap_zscore = getattr( params, "broad_gapup_continuation_max_gap_zscore_20d", None, ) if ( max_gap_zscore is not None and ( gap_zscore_20d is None or float(gap_zscore_20d) > float(max_gap_zscore) ) ): return False return True def _orb_market_thrust_liquid_continuation_allows( params: ORBStrategyParams, *, direction: str, gap_pct: float, rvol: float | None, avg_dollar_vol: float | None, first_bar_dollar_vol: float, first_bar_return_pct: float, body_ratio: float, close_location: float, ret_5d: float | None, ) -> bool: """Gate liquid normal-gap leaders for strong index-thrust days.""" if not bool(getattr(params, "market_thrust_liquid_continuation_enabled", False)): return False if direction != "bullish" or gap_pct <= 0: return False min_gap = getattr(params, "market_thrust_liquid_continuation_min_gap_pct", None) if min_gap is not None and gap_pct < float(min_gap): return False max_gap = getattr(params, "market_thrust_liquid_continuation_max_gap_pct", None) if max_gap is not None and gap_pct > float(max_gap): return False min_rvol = getattr(params, "market_thrust_liquid_continuation_min_rvol", None) if min_rvol is not None and (rvol is None or float(rvol) < float(min_rvol)): return False min_first_return = getattr( params, "market_thrust_liquid_continuation_min_first_bar_return_pct", None, ) if min_first_return is not None and first_bar_return_pct < float(min_first_return): return False min_first_bar_dollar_vol = getattr( params, "market_thrust_liquid_continuation_min_first_bar_dollar_vol", None, ) if ( min_first_bar_dollar_vol is not None and first_bar_dollar_vol < float(min_first_bar_dollar_vol) ): return False min_avg_dollar_vol = getattr( params, "market_thrust_liquid_continuation_min_avg_dollar_vol", None, ) if ( min_avg_dollar_vol is not None and ( avg_dollar_vol is None or float(avg_dollar_vol) < float(min_avg_dollar_vol) ) ): return False min_body = getattr(params, "market_thrust_liquid_continuation_min_body_ratio", None) if min_body is not None and body_ratio < float(min_body): return False min_close = getattr(params, "market_thrust_liquid_continuation_min_close_location", None) if min_close is not None and close_location < float(min_close): return False min_ret5 = getattr(params, "market_thrust_liquid_continuation_min_ret_5d", None) if min_ret5 is not None and (ret_5d is None or float(ret_5d) < float(min_ret5)): return False max_ret5 = getattr(params, "market_thrust_liquid_continuation_max_ret_5d", None) if max_ret5 is not None and (ret_5d is None or float(ret_5d) > float(max_ret5)): return False return True def _orb_market_thrust_liquid_rank_key(cand: dict, rank_mode: str | None) -> tuple[float, ...]: mode = str(rank_mode or "").strip().lower() if mode in {"opening_impulse", "first_bar_return", "orb_return"}: return ( float(cand.get("orb_return") or 0.0), float(cand.get("first_bar_dollar_vol") or 0.0), float(cand.get("score") or 0.0), ) return (float(cand.get("score") or 0.0),) def _orb_market_thrust_opening_impulse_reclaim_allows( params: ORBStrategyParams, *, direction: str, gap_pct: float, avg_dollar_vol: float | None, first_bar_dollar_vol: float, first_bar_return_pct: float, body_ratio: float, close_location: float, ret_5d: float | None, ) -> bool: """Gate small/negative-gap opening impulse candidates on index-thrust days.""" if not bool(getattr(params, "market_thrust_opening_impulse_reclaim_enabled", False)): return False if direction != "bullish": return False min_gap = getattr(params, "market_thrust_opening_impulse_reclaim_min_gap_pct", None) if min_gap is not None and gap_pct < float(min_gap): return False max_gap = getattr(params, "market_thrust_opening_impulse_reclaim_max_gap_pct", None) if max_gap is not None and gap_pct > float(max_gap): return False min_first_return = getattr( params, "market_thrust_opening_impulse_reclaim_min_first_bar_return_pct", None, ) if min_first_return is not None and first_bar_return_pct < float(min_first_return): return False min_first_bar_dollar_vol = getattr( params, "market_thrust_opening_impulse_reclaim_min_first_bar_dollar_vol", None, ) if ( min_first_bar_dollar_vol is not None and first_bar_dollar_vol < float(min_first_bar_dollar_vol) ): return False min_avg_dollar_vol = getattr( params, "market_thrust_opening_impulse_reclaim_min_avg_dollar_vol", None, ) if ( min_avg_dollar_vol is not None and ( avg_dollar_vol is None or float(avg_dollar_vol) < float(min_avg_dollar_vol) ) ): return False min_body = getattr(params, "market_thrust_opening_impulse_reclaim_min_body_ratio", None) if min_body is not None and body_ratio < float(min_body): return False min_close = getattr(params, "market_thrust_opening_impulse_reclaim_min_close_location", None) if min_close is not None and close_location < float(min_close): return False min_ret5 = getattr(params, "market_thrust_opening_impulse_reclaim_min_ret_5d", None) if min_ret5 is not None and (ret_5d is None or float(ret_5d) < float(min_ret5)): return False max_ret5 = getattr(params, "market_thrust_opening_impulse_reclaim_max_ret_5d", None) if max_ret5 is not None and (ret_5d is None or float(ret_5d) > float(max_ret5)): return False return True def _intraday_continuation_reclaim_signal( mkt_bars: list[dict], params: ORBStrategyParams, ) -> dict | None: """Return a lookahead-safe continuation signal using only early session bars.""" if not bool(getattr(params, "intraday_continuation_reclaim_enabled", False)): return None if not mkt_bars: return None signal_minutes = max( 5, int(getattr(params, "intraday_continuation_reclaim_signal_minutes", 30) or 30), ) signal_bars = max(1, signal_minutes // 5) if len(mkt_bars) <= signal_bars: return None window = mkt_bars[:signal_bars] entry_bar = mkt_bars[signal_bars] open_price = float(window[0].get("open", 0.0) or 0.0) close = float(window[-1].get("close", 0.0) or 0.0) if open_price <= 0 or close <= 0: return None signal_return = (close - open_price) / open_price window_high = max(float(bar.get("high", 0.0) or 0.0) for bar in window) window_low = min(float(bar.get("low", 0.0) or 0.0) for bar in window) signal_close_location = ( (close - window_low) / (window_high - window_low) if window_high > window_low else 0.5 ) signal_dollar_vol = sum( float(bar.get("volume", 0.0) or 0.0) * float(bar.get("close", bar.get("open", 0.0)) or 0.0) for bar in window ) min_signal_return = getattr( params, "intraday_continuation_reclaim_min_signal_return_pct", None, ) if min_signal_return is not None and signal_return < float(min_signal_return): return None min_signal_dollar_vol = getattr( params, "intraday_continuation_reclaim_min_signal_dollar_vol", None, ) if ( min_signal_dollar_vol is not None and signal_dollar_vol < float(min_signal_dollar_vol) ): return None min_signal_close_location = getattr( params, "intraday_continuation_reclaim_min_signal_close_location", None, ) if ( min_signal_close_location is not None and signal_close_location < float(min_signal_close_location) ): return None signal_ts = _parse_ts(window[-1]["timestamp"]) signal_vwap = _compute_running_vwap(mkt_bars, signal_ts) if ( bool(getattr(params, "intraday_continuation_reclaim_require_signal_above_vwap", True)) and signal_vwap is not None and close <= signal_vwap ): return None entry_price = float(entry_bar.get("open", 0.0) or 0.0) if entry_price <= 0: return None return { "signal_return": signal_return, "signal_dollar_vol": signal_dollar_vol, "signal_close_location": signal_close_location, "signal_ts": signal_ts, "entry_ts": _parse_ts(entry_bar["timestamp"]), "entry_price": entry_price, } def _orb_intraday_continuation_reclaim_allows( params: ORBStrategyParams, *, direction: str, signal: dict | None, gap_pct: float, atr_pct: float | None, avg_dollar_vol: float | None, first_bar_dollar_vol: float, first_bar_return_pct: float, ret_5d: float | None, gap_zscore_20d: float | None, ) -> bool: """Gate late-morning intraday top-gainer continuation candidates.""" if signal is None: return False if direction != "bullish": return False min_gap = getattr(params, "intraday_continuation_reclaim_min_gap_pct", None) if min_gap is not None and gap_pct < float(min_gap): return False max_gap = getattr(params, "intraday_continuation_reclaim_max_gap_pct", None) if max_gap is not None and gap_pct > float(max_gap): return False min_atr_pct = getattr(params, "intraday_continuation_reclaim_min_atr_pct", None) if min_atr_pct is not None and ( atr_pct is None or float(atr_pct) < float(min_atr_pct) ): return False max_atr_pct = getattr(params, "intraday_continuation_reclaim_max_atr_pct", None) if max_atr_pct is not None and ( atr_pct is None or float(atr_pct) > float(max_atr_pct) ): return False min_avg_dollar_vol = getattr( params, "intraday_continuation_reclaim_min_avg_dollar_vol", None, ) if ( min_avg_dollar_vol is not None and ( avg_dollar_vol is None or float(avg_dollar_vol) < float(min_avg_dollar_vol) ) ): return False min_first_return = getattr( params, "intraday_continuation_reclaim_min_first_bar_return_pct", None, ) if min_first_return is not None and first_bar_return_pct < float(min_first_return): return False min_first_dollar_vol = getattr( params, "intraday_continuation_reclaim_min_first_bar_dollar_vol", None, ) if ( min_first_dollar_vol is not None and first_bar_dollar_vol < float(min_first_dollar_vol) ): return False min_ret5 = getattr(params, "intraday_continuation_reclaim_min_ret_5d", None) if min_ret5 is not None and (ret_5d is None or float(ret_5d) < float(min_ret5)): return False max_ret5 = getattr(params, "intraday_continuation_reclaim_max_ret_5d", None) if max_ret5 is not None and (ret_5d is None or float(ret_5d) > float(max_ret5)): return False max_gap_zscore = getattr( params, "intraday_continuation_reclaim_max_gap_zscore_20d", None, ) if ( max_gap_zscore is not None and ( gap_zscore_20d is None or float(gap_zscore_20d) > float(max_gap_zscore) ) ): return False return True def _compute_premarket_dollar_vol(all_bars: list[dict], date_str: str) -> float: """Premarket dollar volume proxy from 04:00-09:30 ET bars on the trade date.""" total = 0.0 for bar in all_bars: ts = _parse_ts(bar["timestamp"]).astimezone(_ET) if ts.date().isoformat() != date_str: continue if not (_PREMARKET_OPEN <= ts.time() < _MARKET_OPEN): continue price = bar.get("close") or bar.get("open") or 0.0 volume = bar.get("volume", 0) or 0.0 if price > 0 and volume > 0: total += price * volume return total # ── ORB Candidate Selection ──────────────────────────────────────────────── def compute_orb_candidates( bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, ticker_sectors: dict[str, str] | None = None, _stats_out: dict | None = None, _suppress_empty_log: bool = False, overlay_tickers: set[str] | None = None, iex_live_mode: bool = False, ) -> list[dict]: """Identify and rank ORB candidates for a given trading day. Pipeline per ticker: 1. Get market-hours bars, require >= n_orb_bars + 1 2. Extract ORB candle (first bar = 9:30–9:35 ET bar) 3. Filter by direction: bullish only (long-only V1) 4. Apply quality filters from enrichment: price, ATR, dollar_vol 5. Compute approximate RVOL; filter by min_rvol 6. Compute gap% from prev_close 7. Rank by composite score: RVOL × w + gap × w + dollar_vol × w 8. Return top max_candidates Args: bars_by_ticker: {ticker: [bar_dict, ...]} for today. date_str: Today's date as 'YYYY-MM-DD'. params: ORB strategy parameters. enrichment: {ticker: {date: features}} from enrich_daily_bars(). blacklisted_tickers: Tickers in cooldown period. spy_bars: SPY intraday bars for market regime filter. Returns: List of candidate dicts, sorted by composite score descending, capped at max_candidates. Each dict: {ticker, orb_bar, direction, rvol, gap_pct, atr, first_bar_dollar_vol, score, mkt_bars} """ market_open = _market_open_ts(date_str) engine_family = _effective_orb_engine_family(params) raw_candidates: list[dict] = [] # Filter stats — populated only when no candidates found (for diagnostics) _f_no_bars = _f_late = _f_price = _f_dir = _f_atr = _f_dolvol = _f_rvol = _f_gap = 0 _f_crowded_gap = 0 _f_countertrend_gap = 0 _f_distressed_reclaim = 0 _f_hot_reclaim = 0 _f_weak_downside_reclaim = 0 _f_quiet_downside_reclaim = 0 _f_stale_obv_reversal = 0 _f_stalled_gap_up = 0 _f_liquid_stalled_gap_up = 0 _f_thin_gap_up_loss_cap = 0 _f_moderate_downside_loss_cap = 0 _f_gap_up_fill_exit = 0 _f_intraday_continuation_reclaim = 0 for ticker, all_bars in bars_by_ticker.items(): if blacklisted_tickers and ticker in blacklisted_tickers: continue mkt_bars = filter_market_hours(all_bars) # Build ORB candle: aggregate first N 5-min bars per orb_minutes setting. # e.g. orb_minutes=10 → merge bars 0 and 1 into a single 10-min ORB candle. n_orb_bars = max(1, params.orb_minutes // 5) if len(mkt_bars) < n_orb_bars + 1: _f_no_bars += 1 continue # not enough bars to have both ORB window and at least one trading bar # Verify first bar is near market open (allow data irregularities up to 10 min) first_bar_ts = _parse_ts(mkt_bars[0]["timestamp"]) _late_diff = abs((first_bar_ts - market_open).total_seconds() / 60) if _late_diff > 10: _f_late += 1 continue orb_bars_raw = mkt_bars[:n_orb_bars] if n_orb_bars == 1: orb_bar = orb_bars_raw[0] else: orb_bar = { "timestamp": orb_bars_raw[-1]["timestamp"], # end of ORB window "open": orb_bars_raw[0]["open"], "high": max(b["high"] for b in orb_bars_raw), "low": min(b["low"] for b in orb_bars_raw), "close": orb_bars_raw[-1]["close"], "volume": sum(b.get("volume", 0) or 0 for b in orb_bars_raw), } orb_vwap = None orb_vwap_num = 0.0 orb_vwap_den = 0.0 for bar in orb_bars_raw: bar_vwap = bar.get("vwap") bar_volume = float(bar.get("volume", 0) or 0) if bar_vwap is None or bar_volume <= 0: continue orb_vwap_num += float(bar_vwap) * bar_volume orb_vwap_den += bar_volume if orb_vwap_den > 0: orb_vwap = orb_vwap_num / orb_vwap_den intraday_continuation_signal = _intraday_continuation_reclaim_signal( mkt_bars, params, ) # Price filter (use ORB candle open as current price) open_price = orb_bar.get("open", 0) if open_price < params.min_price: _f_price += 1 continue # Direction filter (uses aggregated ORB candle open/close) direction = classify_orb_candle(orb_bar) followthrough_engine = engine_family in { "gainers_leader", "leader_followthrough", "stocks_in_play_dual_regime", "orb_pullback_v1", "vwap_reclaim_v1", "hypergap_failure_v1", } allow_doji_breakout = ( followthrough_engine and bool(getattr(params, "allow_doji_breakout", False)) ) allow_red_to_green_breakout = ( followthrough_engine and bool(getattr(params, "allow_red_to_green_breakout", False)) ) allow_intraday_continuation_direction = intraday_continuation_signal is not None direction_allowed_by_intraday_continuation = False if params.entry_direction == "long_only": if ( direction == "bearish" and not allow_red_to_green_breakout and not allow_intraday_continuation_direction ): _f_dir += 1 continue if direction == "bearish" and ( allow_red_to_green_breakout or allow_intraday_continuation_direction ): direction = "bullish" direction_allowed_by_intraday_continuation = bool( allow_intraday_continuation_direction and not allow_red_to_green_breakout ) if ( direction == "doji" and not allow_doji_breakout and not allow_intraday_continuation_direction ): _f_dir += 1 continue if direction == "doji" and ( allow_doji_breakout or allow_intraday_continuation_direction ): direction = "bullish" direction_allowed_by_intraday_continuation = bool( allow_intraday_continuation_direction and not allow_doji_breakout ) elif params.entry_direction == "short_only": # Gap failure: only trade bearish ORB candles (gap held, then sold off in first bar) if direction in ("bullish", "doji"): _f_dir += 1 continue elif direction == "doji": _f_dir += 1 continue # Enrichment features (all computed from PRIOR bars → no lookahead) ticker_enrich = enrichment.get(ticker, {}).get(date_str, {}) atr = ticker_enrich.get("atr_14") avg_dollar_vol = ticker_enrich.get("avg_dollar_vol_30d") avg_daily_vol = ticker_enrich.get("avg_daily_vol_14d") prev_close = ticker_enrich.get("prev_close") premarket_dollar_vol = _compute_premarket_dollar_vol(all_bars, date_str) # IEX live mode: the Oracle /alpaca/intraday/today endpoint uses the IEX # feed, which captures only ~2–5% of actual US equity volume (IEX is a # single exchange vs. the full SIP tape). This creates a fundamental # mismatch with two filters that are calibrated on SIP historical data: # # 1. min_premarket_dollar_vol — Pre-market IEX bars are extremely sparse; # many liquid gappers show $0 IEX pre-market vol even when true SIP # pre-market vol exceeds $2.5M+. A fixed scaling factor (e.g. ×25) # is unworkable because IEX market share varies by ticker/time and # pre-market IEX bars are often entirely absent. # # 2. min_rvol — RVOL is computed as (IEX first-bar volume) / # (avg_daily_vol_14d from SIP). Since the numerator is IEX (~3% of # SIP) and the denominator is full SIP volume, the computed RVOL is # roughly 30–50× lower than the true RVOL. A stock with genuine # RVOL of 5× would register as ~0.15× here, failing min_rvol=3.0. # # The live engine already enforces liquidity through SIP-sourced fields # from enrichment (min_avg_dollar_volume from 30-day historical daily bars, # min_price, ATR guards) and from snapshot data (min_abs_gap_pct). Those # filters are unaffected by the IEX vs. SIP mismatch. We therefore bypass # the two IEX-incompatible volume filters in live mode at the filter site, # while preserving the raw IEX value for diagnostics/ranking. # ATR filter base_atr_pct_filter_failed = False atr_ratio = None if atr is None or atr < params.min_atr_14: _f_atr += 1 continue if prev_close and prev_close > 0: atr_ratio = atr / prev_close if params.min_atr_pct is not None and atr_ratio < params.min_atr_pct: base_atr_pct_filter_failed = True if params.max_atr_pct is not None and atr_ratio > params.max_atr_pct: _f_atr += 1 continue # Dollar volume filter if avg_dollar_vol is None or avg_dollar_vol < params.min_avg_dollar_volume: _f_dolvol += 1 continue # Leader/liquid overlay tickers bypass gapper-specific filters (rvol, gap, premarket). # They have their own quality gates applied upstream in the overlay function. is_overlay = overlay_tickers is not None and ticker in overlay_tickers orb_vol = orb_bar.get("volume", 0) or 0 raw_rvol = compute_rvol_approx(orb_vol, avg_daily_vol) if avg_daily_vol else None rvol = raw_rvol # Gap % gap_pct = 0.0 if prev_close and prev_close > 0: gap_pct = (open_price - prev_close) / prev_close abs_gap_pct = abs(gap_pct) raw_first_bar_dollar_vol = orb_vol * open_price first_bar_dollar_vol = raw_first_bar_dollar_vol raw_premarket_dollar_vol = premarket_dollar_vol if iex_live_mode: iex_multiplier = max( 1.0, float(getattr(params, "iex_live_intraday_volume_multiplier", 1.0) or 1.0), ) if iex_multiplier > 1.0: first_bar_dollar_vol = raw_first_bar_dollar_vol * iex_multiplier premarket_dollar_vol = raw_premarket_dollar_vol * iex_multiplier if raw_rvol is not None: rvol = raw_rvol * iex_multiplier # ORB candle directional conviction: computed before max_gap_pct so a # bounded high-gap sleeve can inspect structure without relaxing the # normal ORB gap cap globally. orb_range = orb_bar["high"] - orb_bar["low"] orb_close = orb_bar["close"] orb_open_price = orb_bar["open"] if orb_range > 0: if direction == "bullish": body_ratio = max((orb_close - orb_open_price) / orb_range, 0.0) else: body_ratio = max((orb_open_price - orb_close) / orb_range, 0.0) close_location = (orb_close - orb_bar["low"]) / orb_range else: body_ratio = 0.0 close_location = 0.5 ret_5d = ticker_enrich.get("ret_5d") gap_zscore_20d = ticker_enrich.get("gap_zscore_20d") broad_gapup_continuation_active = False first_bar_return_pct = ( (orb_close - orb_open_price) / orb_open_price if orb_open_price > 0 else 0.0 ) market_thrust_liquid_continuation_active = ( _orb_market_thrust_liquid_continuation_allows( params, direction=direction, gap_pct=gap_pct, rvol=rvol, avg_dollar_vol=avg_dollar_vol, first_bar_dollar_vol=first_bar_dollar_vol, first_bar_return_pct=first_bar_return_pct, body_ratio=body_ratio, close_location=close_location, ret_5d=_float_or_none(ret_5d), ) ) market_thrust_opening_impulse_reclaim_active = ( _orb_market_thrust_opening_impulse_reclaim_allows( params, direction=direction, gap_pct=gap_pct, avg_dollar_vol=avg_dollar_vol, first_bar_dollar_vol=first_bar_dollar_vol, first_bar_return_pct=first_bar_return_pct, body_ratio=body_ratio, close_location=close_location, ret_5d=_float_or_none(ret_5d), ) ) intraday_continuation_reclaim_active = ( _orb_intraday_continuation_reclaim_allows( params, direction=direction, signal=intraday_continuation_signal, gap_pct=gap_pct, atr_pct=_float_or_none(atr_ratio), avg_dollar_vol=avg_dollar_vol, first_bar_dollar_vol=first_bar_dollar_vol, first_bar_return_pct=first_bar_return_pct, ret_5d=_float_or_none(ret_5d), gap_zscore_20d=_float_or_none(gap_zscore_20d), ) ) if base_atr_pct_filter_failed and not intraday_continuation_reclaim_active: _f_atr += 1 continue if direction_allowed_by_intraday_continuation and not intraday_continuation_reclaim_active: _f_dir += 1 continue used_small_gap_attention_override = False if not is_overlay: if ( params.min_abs_gap_pct is not None and abs_gap_pct < params.min_abs_gap_pct and not market_thrust_liquid_continuation_active and not market_thrust_opening_impulse_reclaim_active and not intraday_continuation_reclaim_active ): small_gap_attention_override = ( followthrough_engine and getattr(params, "small_gap_attention_override_premarket_dollar_vol", None) is not None and premarket_dollar_vol >= float(getattr(params, "small_gap_attention_override_premarket_dollar_vol")) ) if small_gap_attention_override: small_gap_rvol_min = getattr(params, "small_gap_attention_override_rvol", None) if small_gap_rvol_min is not None and (rvol is None or rvol < float(small_gap_rvol_min)): _f_gap += 1 continue used_small_gap_attention_override = True else: _f_gap += 1 continue # Optional max gap filter. Useful for classical ORB continuation, but typically # disabled in gainers/leader style engines that explicitly seek outsized movers. if params.max_gap_pct is not None and gap_pct > params.max_gap_pct: broad_gapup_continuation_active = _orb_broad_gapup_continuation_allows( params, direction=direction, gap_pct=gap_pct, rvol=rvol, avg_dollar_vol=avg_dollar_vol, premarket_dollar_vol=premarket_dollar_vol, first_bar_dollar_vol=first_bar_dollar_vol, body_ratio=body_ratio, close_location=close_location, ret_5d=_float_or_none(ret_5d), gap_zscore_20d=_float_or_none(gap_zscore_20d), ) if ( not broad_gapup_continuation_active and not market_thrust_opening_impulse_reclaim_active and not intraday_continuation_reclaim_active ): _f_gap += 1 continue if ( not iex_live_mode # see iex_live_mode block above: IEX RVOL ≈ 3% of SIP RVOL and params.min_rvol is not None and not broad_gapup_continuation_active and not market_thrust_liquid_continuation_active and not market_thrust_opening_impulse_reclaim_active and not intraday_continuation_reclaim_active and (rvol is None or rvol < params.min_rvol) ): _f_rvol += 1 continue if ( params.min_premarket_dollar_vol is not None and not iex_live_mode # see iex_live_mode block above: IEX pre-market volume is sparse and not broad_gapup_continuation_active and not market_thrust_liquid_continuation_active and not market_thrust_opening_impulse_reclaim_active and not intraday_continuation_reclaim_active and premarket_dollar_vol < params.min_premarket_dollar_vol ): _f_dolvol += 1 continue # ORB range quality filter: skip if range is too narrow or too wide relative to ATR if atr > 0 and orb_range > 0: orb_range_atr_ratio = orb_range / atr if params.orb_range_atr_min is not None and orb_range_atr_ratio < params.orb_range_atr_min: _f_dir += 1 continue if params.orb_range_atr_max is not None and orb_range_atr_ratio > params.orb_range_atr_max: _f_dir += 1 continue if engine_family != "classic_breakout" and body_ratio < getattr(params, "min_body_ratio", 0.0): _f_dir += 1 continue if engine_family == "leader_followthrough" and close_location < getattr(params, "min_close_location", 0.0): _f_dir += 1 continue # 5-day prior momentum in the direction of the breakout. # For longs: positive ret_5d = stock already trending up (momentum alignment). # For shorts: negative ret_5d = stock already trending down. if ret_5d is not None: momentum = ret_5d if direction == "bullish" else -ret_5d else: momentum = 0.0 if intraday_continuation_reclaim_active and intraday_continuation_signal: momentum = max( momentum, float(intraday_continuation_signal.get("signal_return") or 0.0), ) obv_slope_20 = ticker_enrich.get("obv_slope_20") obv_slope_5 = ticker_enrich.get("obv_slope_5") crowded_gap_requires_confirmation = False crowded_gap_size_scale = 1.0 countertrend_gap_requires_confirmation = False countertrend_gap_size_scale = 1.0 distressed_reclaim_requires_confirmation = False distressed_reclaim_size_scale = 1.0 distressed_reclaim_skip_trade = False distressed_reclaim_reserve_cash = False distressed_reclaim_reserve_full_cash = False distressed_reclaim_triggered = False distressed_reclaim_loss_cap_pct = None hot_reclaim_requires_confirmation = False hot_reclaim_size_scale = 1.0 hot_reclaim_skip_trade = False hot_reclaim_reserve_cash = False hot_reclaim_reserve_full_cash = False hot_reclaim_loss_cap_pct = None weak_downside_reclaim_requires_confirmation = False weak_downside_reclaim_size_scale = 1.0 weak_downside_reclaim_skip_trade = False weak_downside_reclaim_reserve_cash = False weak_downside_reclaim_reserve_full_cash = False weak_downside_reclaim_loss_cap_pct = None quiet_downside_reclaim_requires_confirmation = False quiet_downside_reclaim_size_scale = 1.0 quiet_downside_reclaim_skip_trade = False quiet_downside_reclaim_reserve_cash = False quiet_downside_reclaim_reserve_full_cash = False quiet_downside_reclaim_loss_cap_pct = None stale_obv_reversal_requires_confirmation = False stale_obv_reversal_size_scale = 1.0 stale_obv_reversal_skip_trade = False stale_obv_reversal_reserve_cash = False stale_obv_reversal_reserve_full_cash = False stale_obv_reversal_loss_cap_pct = None stalled_gap_up_requires_confirmation = False stalled_gap_up_size_scale = 1.0 stalled_gap_up_skip_trade = False stalled_gap_up_reserve_cash = False stalled_gap_up_reserve_full_cash = False stalled_gap_up_loss_cap_pct = None liquid_stalled_gap_up_requires_confirmation = False liquid_stalled_gap_up_size_scale = 1.0 liquid_stalled_gap_up_skip_trade = False liquid_stalled_gap_up_reserve_cash = False liquid_stalled_gap_up_reserve_full_cash = False liquid_stalled_gap_up_loss_cap_pct = None thin_gap_up_loss_cap_active = False thin_gap_up_loss_cap_pct = None moderate_downside_loss_cap_active = False moderate_downside_loss_cap_pct = None gap_up_fill_exit_active = False crowded_gap_min_gap = getattr(params, "crowded_gap_reject_min_gap_pct", None) crowded_gap_min_ret5 = getattr(params, "crowded_gap_reject_min_ret_5d", None) crowded_gap_min_body = getattr(params, "crowded_gap_reject_min_body_ratio", None) crowded_gap_min_close = getattr(params, "crowded_gap_reject_min_close_location", None) crowded_gap_max_premarket = getattr( params, "crowded_gap_max_premarket_dollar_vol", None ) if ( direction == "bullish" and crowded_gap_min_gap is not None and crowded_gap_min_ret5 is not None and crowded_gap_min_body is not None and crowded_gap_min_close is not None and ret_5d is not None and gap_pct >= crowded_gap_min_gap and ret_5d >= crowded_gap_min_ret5 and body_ratio >= crowded_gap_min_body and close_location >= crowded_gap_min_close and ( crowded_gap_max_premarket is None or premarket_dollar_vol <= float(crowded_gap_max_premarket) ) ): _f_crowded_gap += 1 crowded_gap_action = str(getattr(params, "crowded_gap_action", "reject") or "reject") if crowded_gap_action in ("confirm", "confirm_scale"): crowded_gap_requires_confirmation = True if crowded_gap_action in ("scale", "confirm_scale"): crowded_gap_size_scale = max( 0.0, min(1.0, float(getattr(params, "crowded_gap_size_scale", 1.0) or 1.0)), ) if crowded_gap_action not in ("confirm", "scale", "confirm_scale"): continue countertrend_gap_min_gap = getattr(params, "countertrend_gap_min_gap_pct", None) countertrend_gap_max_ret5 = getattr(params, "countertrend_gap_max_ret_5d", None) countertrend_gap_min_body = getattr(params, "countertrend_gap_min_body_ratio", None) countertrend_gap_min_close = getattr(params, "countertrend_gap_min_close_location", None) if ( direction == "bullish" and countertrend_gap_min_gap is not None and countertrend_gap_max_ret5 is not None and countertrend_gap_min_body is not None and countertrend_gap_min_close is not None and ret_5d is not None and gap_pct >= countertrend_gap_min_gap and ret_5d <= countertrend_gap_max_ret5 and body_ratio >= countertrend_gap_min_body and close_location >= countertrend_gap_min_close ): _f_countertrend_gap += 1 countertrend_gap_action = str( getattr(params, "countertrend_gap_action", "reject") or "reject" ) if countertrend_gap_action in ("confirm", "confirm_scale"): countertrend_gap_requires_confirmation = True if countertrend_gap_action in ("scale", "confirm_scale"): countertrend_gap_size_scale = max( 0.0, min(1.0, float(getattr(params, "countertrend_gap_size_scale", 1.0) or 1.0)), ) if countertrend_gap_action not in ("confirm", "scale", "confirm_scale"): continue distressed_min_abs_gap = getattr(params, "distressed_reclaim_min_abs_gap_pct", None) distressed_max_ret5 = getattr(params, "distressed_reclaim_max_ret_5d", None) distressed_min_premarket = getattr( params, "distressed_reclaim_min_premarket_dollar_vol", None ) distressed_max_close = getattr(params, "distressed_reclaim_max_close_location", None) distressed_max_obv20 = getattr(params, "distressed_reclaim_max_obv_slope_20d", None) distressed_max_obv5 = getattr(params, "distressed_reclaim_max_obv_slope_5d", None) if ( direction == "bullish" and distressed_min_abs_gap is not None and distressed_max_ret5 is not None and distressed_min_premarket is not None and distressed_max_close is not None and ret_5d is not None and ( distressed_max_obv20 is None or (obv_slope_20 is not None and obv_slope_20 <= float(distressed_max_obv20)) ) and ( distressed_max_obv5 is None or (obv_slope_5 is not None and obv_slope_5 <= float(distressed_max_obv5)) ) and gap_pct <= -float(distressed_min_abs_gap) and ret_5d <= float(distressed_max_ret5) and premarket_dollar_vol >= float(distressed_min_premarket) and close_location <= float(distressed_max_close) ): _f_distressed_reclaim += 1 distressed_reclaim_triggered = True distressed_action = str( getattr(params, "distressed_reclaim_action", "reject") or "reject" ) if distressed_action in ("confirm", "confirm_scale", "confirm_scale_reserve"): distressed_reclaim_requires_confirmation = True if distressed_action in ("scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve"): distressed_reclaim_size_scale = max( 0.0, min(1.0, float(getattr(params, "distressed_reclaim_size_scale", 1.0) or 1.0)), ) if distressed_action in ( "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): distressed_reclaim_skip_trade = True if distressed_action in ("skip_reserve", "reserve_skip"): distressed_reclaim_reserve_cash = True if distressed_action in ("scale_reserve", "confirm_scale_reserve"): distressed_reclaim_reserve_full_cash = True if getattr(params, "distressed_reclaim_loss_cap_pct", None) is not None: distressed_reclaim_loss_cap_pct = max( 0.0, float(getattr(params, "distressed_reclaim_loss_cap_pct") or 0.0), ) if distressed_action not in ( "confirm", "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", "loss_cap", "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): continue weak_downside_min_abs_gap = getattr( params, "weak_downside_reclaim_min_abs_gap_pct", None ) weak_downside_min_ret5 = getattr(params, "weak_downside_reclaim_min_ret_5d", None) weak_downside_max_ret5 = getattr(params, "weak_downside_reclaim_max_ret_5d", None) weak_downside_max_premarket = getattr( params, "weak_downside_reclaim_max_premarket_dollar_vol", None ) weak_downside_max_body = getattr( params, "weak_downside_reclaim_max_body_ratio", None ) weak_downside_max_close = getattr( params, "weak_downside_reclaim_max_close_location", None ) weak_downside_max_obv5 = getattr( params, "weak_downside_reclaim_max_obv_slope_5d", None ) if ( direction == "bullish" and weak_downside_min_abs_gap is not None and weak_downside_max_close is not None and ret_5d is not None and gap_pct <= -float(weak_downside_min_abs_gap) and ( weak_downside_min_ret5 is None or ret_5d >= float(weak_downside_min_ret5) ) and ( weak_downside_max_ret5 is None or ret_5d <= float(weak_downside_max_ret5) ) and ( weak_downside_max_premarket is None or premarket_dollar_vol <= float(weak_downside_max_premarket) ) and ( weak_downside_max_body is None or body_ratio <= float(weak_downside_max_body) ) and close_location <= float(weak_downside_max_close) and ( weak_downside_max_obv5 is None or (obv_slope_5 is not None and obv_slope_5 <= float(weak_downside_max_obv5)) ) ): _f_weak_downside_reclaim += 1 weak_downside_action = str( getattr(params, "weak_downside_reclaim_action", "reject") or "reject" ) if weak_downside_action in ("confirm", "confirm_scale", "confirm_scale_reserve"): weak_downside_reclaim_requires_confirmation = True if weak_downside_action in ( "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", ): weak_downside_reclaim_size_scale = max( 0.0, min( 1.0, float( getattr(params, "weak_downside_reclaim_size_scale", 1.0) or 1.0 ), ), ) if weak_downside_action in ( "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): weak_downside_reclaim_skip_trade = True if weak_downside_action in ("skip_reserve", "reserve_skip"): weak_downside_reclaim_reserve_cash = True if weak_downside_action in ("scale_reserve", "confirm_scale_reserve"): weak_downside_reclaim_reserve_full_cash = True if getattr(params, "weak_downside_reclaim_loss_cap_pct", None) is not None: weak_downside_reclaim_loss_cap_pct = max( 0.0, float(getattr(params, "weak_downside_reclaim_loss_cap_pct") or 0.0), ) if weak_downside_action not in ( "confirm", "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", "loss_cap", "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): continue quiet_downside_min_abs_gap = getattr( params, "quiet_downside_reclaim_min_abs_gap_pct", None ) quiet_downside_min_ret5 = getattr(params, "quiet_downside_reclaim_min_ret_5d", None) quiet_downside_max_ret5 = getattr(params, "quiet_downside_reclaim_max_ret_5d", None) quiet_downside_max_premarket = getattr( params, "quiet_downside_reclaim_max_premarket_dollar_vol", None ) quiet_downside_max_body = getattr( params, "quiet_downside_reclaim_max_body_ratio", None ) quiet_downside_max_close = getattr( params, "quiet_downside_reclaim_max_close_location", None ) quiet_downside_max_obv5 = getattr( params, "quiet_downside_reclaim_max_obv_slope_5d", None ) if ( direction == "bullish" and quiet_downside_min_abs_gap is not None and quiet_downside_max_close is not None and ret_5d is not None and gap_pct <= -float(quiet_downside_min_abs_gap) and ( quiet_downside_min_ret5 is None or ret_5d >= float(quiet_downside_min_ret5) ) and ( quiet_downside_max_ret5 is None or ret_5d <= float(quiet_downside_max_ret5) ) and ( quiet_downside_max_premarket is None or premarket_dollar_vol <= float(quiet_downside_max_premarket) ) and ( quiet_downside_max_body is None or body_ratio <= float(quiet_downside_max_body) ) and close_location <= float(quiet_downside_max_close) and ( quiet_downside_max_obv5 is None or (obv_slope_5 is not None and obv_slope_5 <= float(quiet_downside_max_obv5)) ) ): _f_quiet_downside_reclaim += 1 quiet_downside_action = str( getattr(params, "quiet_downside_reclaim_action", "reject") or "reject" ) if quiet_downside_action in ("confirm", "confirm_scale", "confirm_scale_reserve"): quiet_downside_reclaim_requires_confirmation = True if quiet_downside_action in ( "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", ): quiet_downside_reclaim_size_scale = max( 0.0, min( 1.0, float( getattr(params, "quiet_downside_reclaim_size_scale", 1.0) or 1.0 ), ), ) if quiet_downside_action in ( "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): quiet_downside_reclaim_skip_trade = True if quiet_downside_action in ("skip_reserve", "reserve_skip"): quiet_downside_reclaim_reserve_cash = True if quiet_downside_action in ("scale_reserve", "confirm_scale_reserve"): quiet_downside_reclaim_reserve_full_cash = True if getattr(params, "quiet_downside_reclaim_loss_cap_pct", None) is not None: quiet_downside_reclaim_loss_cap_pct = max( 0.0, float(getattr(params, "quiet_downside_reclaim_loss_cap_pct") or 0.0), ) if quiet_downside_action not in ( "confirm", "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", "loss_cap", "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): continue stale_obv_max_obv20 = getattr( params, "stale_obv_reversal_max_obv_slope_20d", None ) stale_obv_min_ret5 = getattr(params, "stale_obv_reversal_min_ret_5d", None) stale_obv_max_ret5 = getattr(params, "stale_obv_reversal_max_ret_5d", None) stale_obv_min_gap = getattr(params, "stale_obv_reversal_min_gap_pct", None) stale_obv_max_rvol = getattr(params, "stale_obv_reversal_max_rvol", None) stale_obv_max_body = getattr(params, "stale_obv_reversal_max_body_ratio", None) stale_obv_max_close = getattr( params, "stale_obv_reversal_max_close_location", None ) stale_obv_max_orb_return = getattr( params, "stale_obv_reversal_max_orb_return", None ) if ( direction == "bullish" and stale_obv_max_obv20 is not None and obv_slope_20 is not None and ret_5d is not None and obv_slope_20 <= float(stale_obv_max_obv20) and ( stale_obv_min_ret5 is None or ret_5d >= float(stale_obv_min_ret5) ) and ( stale_obv_max_ret5 is None or ret_5d <= float(stale_obv_max_ret5) ) and ( stale_obv_min_gap is None or (gap_pct is not None and gap_pct >= float(stale_obv_min_gap)) ) and ( stale_obv_max_rvol is None or (rvol is not None and rvol <= float(stale_obv_max_rvol)) ) and ( stale_obv_max_body is None or (body_ratio is not None and body_ratio <= float(stale_obv_max_body)) ) and ( stale_obv_max_close is None or close_location <= float(stale_obv_max_close) ) and ( stale_obv_max_orb_return is None or ( first_bar_return_pct is not None and first_bar_return_pct <= float(stale_obv_max_orb_return) ) ) ): _f_stale_obv_reversal += 1 stale_obv_action = str( getattr(params, "stale_obv_reversal_action", "reject") or "reject" ) if stale_obv_action in ("confirm", "confirm_scale", "confirm_scale_reserve"): stale_obv_reversal_requires_confirmation = True if stale_obv_action in ( "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", ): stale_obv_reversal_size_scale = max( 0.0, min( 1.0, float( getattr(params, "stale_obv_reversal_size_scale", 1.0) or 1.0 ), ), ) if stale_obv_action in ( "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): stale_obv_reversal_skip_trade = True if stale_obv_action in ("skip_reserve", "reserve_skip"): stale_obv_reversal_reserve_cash = True if stale_obv_action in ("scale_reserve", "confirm_scale_reserve"): stale_obv_reversal_reserve_full_cash = True if getattr(params, "stale_obv_reversal_loss_cap_pct", None) is not None: stale_obv_reversal_loss_cap_pct = max( 0.0, float(getattr(params, "stale_obv_reversal_loss_cap_pct") or 0.0), ) if stale_obv_action not in ( "confirm", "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", "loss_cap", "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): continue hot_min_abs_gap = getattr(params, "hot_reclaim_min_abs_gap_pct", None) hot_min_ret5 = getattr(params, "hot_reclaim_min_ret_5d", None) hot_max_premarket = getattr(params, "hot_reclaim_max_premarket_dollar_vol", None) hot_min_body = getattr(params, "hot_reclaim_min_body_ratio", None) hot_min_close = getattr(params, "hot_reclaim_min_close_location", None) if ( direction == "bullish" and hot_min_abs_gap is not None and hot_min_ret5 is not None and hot_min_body is not None and hot_min_close is not None and ret_5d is not None and gap_pct <= -float(hot_min_abs_gap) and ret_5d >= float(hot_min_ret5) and body_ratio >= float(hot_min_body) and close_location >= float(hot_min_close) and ( hot_max_premarket is None or premarket_dollar_vol <= float(hot_max_premarket) ) ): _f_hot_reclaim += 1 hot_action = str(getattr(params, "hot_reclaim_action", "reject") or "reject") if hot_action in ("confirm", "confirm_scale", "confirm_scale_reserve"): hot_reclaim_requires_confirmation = True if hot_action in ("scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve"): hot_reclaim_size_scale = max( 0.0, min(1.0, float(getattr(params, "hot_reclaim_size_scale", 1.0) or 1.0)), ) if hot_action in ( "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): hot_reclaim_skip_trade = True if hot_action in ("skip_reserve", "reserve_skip"): hot_reclaim_reserve_cash = True if hot_action in ("scale_reserve", "confirm_scale_reserve"): hot_reclaim_reserve_full_cash = True if getattr(params, "hot_reclaim_loss_cap_pct", None) is not None: hot_reclaim_loss_cap_pct = max( 0.0, float(getattr(params, "hot_reclaim_loss_cap_pct") or 0.0), ) if hot_action not in ( "confirm", "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", "loss_cap", "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): continue stalled_min_gap = getattr(params, "stalled_gap_up_min_gap_pct", None) stalled_min_ret5 = getattr(params, "stalled_gap_up_min_ret_5d", None) stalled_max_ret5 = getattr(params, "stalled_gap_up_max_ret_5d", None) stalled_max_premarket = getattr( params, "stalled_gap_up_max_premarket_dollar_vol", None ) stalled_max_close = getattr(params, "stalled_gap_up_max_close_location", None) stalled_max_obv5 = getattr(params, "stalled_gap_up_max_obv_slope_5d", None) if ( direction == "bullish" and stalled_min_gap is not None and stalled_max_close is not None and ret_5d is not None and gap_pct >= float(stalled_min_gap) and ( stalled_min_ret5 is None or ret_5d >= float(stalled_min_ret5) ) and ( stalled_max_ret5 is None or ret_5d <= float(stalled_max_ret5) ) and close_location <= float(stalled_max_close) and ( stalled_max_premarket is None or premarket_dollar_vol <= float(stalled_max_premarket) ) and ( stalled_max_obv5 is None or (obv_slope_5 is not None and obv_slope_5 <= float(stalled_max_obv5)) ) ): _f_stalled_gap_up += 1 stalled_action = str( getattr(params, "stalled_gap_up_action", "reject") or "reject" ) if stalled_action in ("confirm", "confirm_scale", "confirm_scale_reserve"): stalled_gap_up_requires_confirmation = True if stalled_action in ("scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve"): stalled_gap_up_size_scale = max( 0.0, min(1.0, float(getattr(params, "stalled_gap_up_size_scale", 1.0) or 1.0)), ) if stalled_action in ( "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): stalled_gap_up_skip_trade = True if stalled_action in ("skip_reserve", "reserve_skip"): stalled_gap_up_reserve_cash = True if stalled_action in ("scale_reserve", "confirm_scale_reserve"): stalled_gap_up_reserve_full_cash = True if getattr(params, "stalled_gap_up_loss_cap_pct", None) is not None: stalled_gap_up_loss_cap_pct = max( 0.0, float(getattr(params, "stalled_gap_up_loss_cap_pct") or 0.0), ) if stalled_action not in ( "confirm", "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", "loss_cap", "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): continue liquid_stalled_min_gap = getattr( params, "liquid_stalled_gap_up_min_gap_pct", None ) liquid_stalled_min_ret5 = getattr( params, "liquid_stalled_gap_up_min_ret_5d", None ) liquid_stalled_max_ret5 = getattr( params, "liquid_stalled_gap_up_max_ret_5d", None ) liquid_stalled_min_premarket = getattr( params, "liquid_stalled_gap_up_min_premarket_dollar_vol", None ) liquid_stalled_max_premarket = getattr( params, "liquid_stalled_gap_up_max_premarket_dollar_vol", None ) liquid_stalled_max_body = getattr( params, "liquid_stalled_gap_up_max_body_ratio", None ) liquid_stalled_max_close = getattr( params, "liquid_stalled_gap_up_max_close_location", None ) liquid_stalled_max_obv5 = getattr( params, "liquid_stalled_gap_up_max_obv_slope_5d", None ) if ( direction == "bullish" and liquid_stalled_min_gap is not None and liquid_stalled_max_close is not None and ret_5d is not None and gap_pct >= float(liquid_stalled_min_gap) and ( liquid_stalled_min_ret5 is None or ret_5d >= float(liquid_stalled_min_ret5) ) and ( liquid_stalled_max_ret5 is None or ret_5d <= float(liquid_stalled_max_ret5) ) and ( liquid_stalled_min_premarket is None or premarket_dollar_vol >= float(liquid_stalled_min_premarket) ) and ( liquid_stalled_max_premarket is None or premarket_dollar_vol <= float(liquid_stalled_max_premarket) ) and ( liquid_stalled_max_body is None or body_ratio <= float(liquid_stalled_max_body) ) and close_location <= float(liquid_stalled_max_close) and ( liquid_stalled_max_obv5 is None or ( obv_slope_5 is not None and obv_slope_5 <= float(liquid_stalled_max_obv5) ) ) ): _f_liquid_stalled_gap_up += 1 liquid_stalled_action = str( getattr(params, "liquid_stalled_gap_up_action", "reject") or "reject" ) if liquid_stalled_action in ( "confirm", "confirm_scale", "confirm_scale_reserve", ): liquid_stalled_gap_up_requires_confirmation = True if liquid_stalled_action in ( "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", ): liquid_stalled_gap_up_size_scale = max( 0.0, min( 1.0, float( getattr(params, "liquid_stalled_gap_up_size_scale", 1.0) or 1.0 ), ), ) if liquid_stalled_action in ( "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): liquid_stalled_gap_up_skip_trade = True if liquid_stalled_action in ("skip_reserve", "reserve_skip"): liquid_stalled_gap_up_reserve_cash = True if liquid_stalled_action in ("scale_reserve", "confirm_scale_reserve"): liquid_stalled_gap_up_reserve_full_cash = True if getattr(params, "liquid_stalled_gap_up_loss_cap_pct", None) is not None: liquid_stalled_gap_up_loss_cap_pct = max( 0.0, float(getattr(params, "liquid_stalled_gap_up_loss_cap_pct") or 0.0), ) if liquid_stalled_action not in ( "confirm", "scale", "confirm_scale", "scale_reserve", "confirm_scale_reserve", "loss_cap", "skip", "no_backfill", "skip_trade", "skip_reserve", "reserve_skip", ): continue thin_gap_up_loss_cap_min_gap = getattr( params, "thin_gap_up_loss_cap_min_gap_pct", None ) thin_gap_up_loss_cap_max_premarket = getattr( params, "thin_gap_up_loss_cap_max_premarket_dollar_vol", None ) thin_gap_up_loss_cap_min_body = getattr( params, "thin_gap_up_loss_cap_min_body_ratio", None ) thin_gap_up_loss_cap_min_close = getattr( params, "thin_gap_up_loss_cap_min_close_location", None ) thin_gap_up_loss_cap_min_ret5 = getattr( params, "thin_gap_up_loss_cap_min_ret_5d", None ) thin_gap_up_loss_cap_max_ret5 = getattr( params, "thin_gap_up_loss_cap_max_ret_5d", None ) if ( direction == "bullish" and thin_gap_up_loss_cap_min_gap is not None and thin_gap_up_loss_cap_max_premarket is not None and thin_gap_up_loss_cap_min_body is not None and thin_gap_up_loss_cap_min_close is not None and getattr(params, "thin_gap_up_loss_cap_pct", None) is not None and ret_5d is not None and gap_pct >= float(thin_gap_up_loss_cap_min_gap) and ( thin_gap_up_loss_cap_min_ret5 is None or ret_5d >= float(thin_gap_up_loss_cap_min_ret5) ) and ( thin_gap_up_loss_cap_max_ret5 is None or ret_5d <= float(thin_gap_up_loss_cap_max_ret5) ) and premarket_dollar_vol <= float(thin_gap_up_loss_cap_max_premarket) and body_ratio >= float(thin_gap_up_loss_cap_min_body) and close_location >= float(thin_gap_up_loss_cap_min_close) ): _f_thin_gap_up_loss_cap += 1 thin_gap_up_loss_cap_active = True thin_gap_up_loss_cap_pct = max( 0.0, float(getattr(params, "thin_gap_up_loss_cap_pct") or 0.0), ) moderate_downside_loss_cap_min_abs_gap = getattr( params, "moderate_downside_loss_cap_min_abs_gap_pct", None ) moderate_downside_loss_cap_max_abs_gap = getattr( params, "moderate_downside_loss_cap_max_abs_gap_pct", None ) moderate_downside_loss_cap_min_ret5 = getattr( params, "moderate_downside_loss_cap_min_ret_5d", None ) moderate_downside_loss_cap_max_ret5 = getattr( params, "moderate_downside_loss_cap_max_ret_5d", None ) moderate_downside_loss_cap_max_premarket = getattr( params, "moderate_downside_loss_cap_max_premarket_dollar_vol", None ) moderate_downside_loss_cap_max_body = getattr( params, "moderate_downside_loss_cap_max_body_ratio", None ) moderate_downside_loss_cap_max_close = getattr( params, "moderate_downside_loss_cap_max_close_location", None ) if ( direction == "bullish" and moderate_downside_loss_cap_min_abs_gap is not None and moderate_downside_loss_cap_max_abs_gap is not None and moderate_downside_loss_cap_max_body is not None and moderate_downside_loss_cap_max_close is not None and getattr(params, "moderate_downside_loss_cap_pct", None) is not None and ret_5d is not None and gap_pct <= -float(moderate_downside_loss_cap_min_abs_gap) and gap_pct >= -float(moderate_downside_loss_cap_max_abs_gap) and ( moderate_downside_loss_cap_min_ret5 is None or ret_5d >= float(moderate_downside_loss_cap_min_ret5) ) and ( moderate_downside_loss_cap_max_ret5 is None or ret_5d <= float(moderate_downside_loss_cap_max_ret5) ) and ( moderate_downside_loss_cap_max_premarket is None or premarket_dollar_vol <= float(moderate_downside_loss_cap_max_premarket) ) and body_ratio <= float(moderate_downside_loss_cap_max_body) and close_location <= float(moderate_downside_loss_cap_max_close) ): _f_moderate_downside_loss_cap += 1 moderate_downside_loss_cap_active = True moderate_downside_loss_cap_pct = max( 0.0, float(getattr(params, "moderate_downside_loss_cap_pct") or 0.0), ) gap_up_fill_exit_min_gap = getattr(params, "gap_up_fill_exit_min_gap_pct", None) if ( direction == "bullish" and gap_up_fill_exit_min_gap is not None and gap_pct >= float(gap_up_fill_exit_min_gap) and ( getattr(params, "gap_up_fill_exit_max_gap_pct", None) is None or gap_pct <= float(getattr(params, "gap_up_fill_exit_max_gap_pct")) ) and ( getattr(params, "gap_up_fill_exit_min_ret_5d", None) is None or (ret_5d is not None and ret_5d >= float(getattr(params, "gap_up_fill_exit_min_ret_5d"))) ) and ( getattr(params, "gap_up_fill_exit_max_ret_5d", None) is None or (ret_5d is not None and ret_5d <= float(getattr(params, "gap_up_fill_exit_max_ret_5d"))) ) and ( getattr(params, "gap_up_fill_exit_max_premarket_dollar_vol", None) is None or premarket_dollar_vol <= float(getattr(params, "gap_up_fill_exit_max_premarket_dollar_vol")) ) and ( getattr(params, "gap_up_fill_exit_min_body_ratio", None) is None or body_ratio >= float(getattr(params, "gap_up_fill_exit_min_body_ratio")) ) and ( getattr(params, "gap_up_fill_exit_max_body_ratio", None) is None or body_ratio <= float(getattr(params, "gap_up_fill_exit_max_body_ratio")) ) and ( getattr(params, "gap_up_fill_exit_max_close_location", None) is None or close_location <= float(getattr(params, "gap_up_fill_exit_max_close_location")) ) ): _f_gap_up_fill_exit += 1 gap_up_fill_exit_active = True entropy_20d = ticker_enrich.get("entropy_20d") atr_ratio_10_60 = ticker_enrich.get("atr_ratio_10_60") range_compression_10_60 = ticker_enrich.get("range_compression_10_60") event_flag = bool(ticker_enrich.get("event_flag")) raw_event_types = ticker_enrich.get("event_types") or [] event_types = [str(v) for v in raw_event_types if str(v)] event_score = float(ticker_enrich.get("event_score") or 0.0) attention_wiki_spike_10d = ticker_enrich.get("attention_wiki_spike_10d") attention_wiki_zscore_20d = ticker_enrich.get("attention_wiki_zscore_20d") attention_article_count_3d = int(ticker_enrich.get("attention_article_count_3d") or 0) attention_us_article_count_3d = int(ticker_enrich.get("attention_us_article_count_3d") or 0) attention_resolver_confidence = float(ticker_enrich.get("attention_resolver_confidence") or 0.0) ownership_lookback_days = int(getattr(params, "ownership_13dg_lookback_days", 0) or 0) ownership_days_since = _float_or_none(ticker_enrich.get("ownership_13dg_days_since")) ownership_within_lookback = ( ownership_lookback_days > 0 and ownership_days_since is not None and ownership_days_since <= ownership_lookback_days ) ownership_13dg_flag = bool(ticker_enrich.get("ownership_13dg_flag")) and ownership_within_lookback ownership_13dg_initial_flag = ( bool(ticker_enrich.get("ownership_13dg_initial_flag")) and ownership_within_lookback ) ownership_13dg_score = ( float(ticker_enrich.get("ownership_13dg_score") or 1.0) if ownership_13dg_flag else 0.0 ) ownership_13dg_initial_score = 1.0 if ownership_13dg_initial_flag else 0.0 ownership_13dg_strength_score = ( _float_or_none(ticker_enrich.get("ownership_13dg_strength_score")) if ownership_13dg_flag else None ) form4_lookback_days = int(getattr(params, "form4_lookback_days", 0) or 0) form4_days_since = _float_or_none(ticker_enrich.get("form4_days_since")) form4_within_lookback = ( form4_lookback_days > 0 and form4_days_since is not None and form4_days_since <= form4_lookback_days ) form4_flag = bool(ticker_enrich.get("form4_flag")) and form4_within_lookback form4_total_value = ( _float_or_none(ticker_enrich.get("form4_total_value")) if form4_flag else None ) form4_owner_count = ( int(ticker_enrich.get("form4_owner_count") or 0) if form4_flag else 0 ) form4_c_suite_count = ( int(ticker_enrich.get("form4_c_suite_count") or 0) if form4_flag else 0 ) form4_role_weight_score = ( _float_or_none(ticker_enrich.get("form4_role_weight_score")) if form4_flag else None ) allowed_event_types = {str(v).lower() for v in getattr(params, "allowed_event_types", []) if str(v)} if allowed_event_types and event_flag: if not any(str(event_type).lower() in allowed_event_types for event_type in event_types): event_flag = False event_score = 0.0 event_types = [] if engine_family == "compression_breakout": min_entropy = getattr(params, "min_entropy", None) max_entropy = getattr(params, "max_entropy", None) compression_ratio_max = getattr(params, "compression_ratio_max", None) if min_entropy is not None and (entropy_20d is None or entropy_20d < min_entropy): _f_rvol += 1 continue if max_entropy is not None and (entropy_20d is None or entropy_20d > max_entropy): _f_rvol += 1 continue if compression_ratio_max is not None and ( range_compression_10_60 is None or range_compression_10_60 > compression_ratio_max ): _f_rvol += 1 continue if engine_family in {"gainers_leader", "orb_pullback_v1", "vwap_reclaim_v1"}: max_gzs = getattr(params, "max_gap_zscore_20d", None) if max_gzs is not None and (gap_zscore_20d is None or gap_zscore_20d > max_gzs): _f_rvol += 1 continue conditional_gzs = getattr(params, "conditional_gap_zscore_reject_above", None) conditional_ret5 = getattr(params, "conditional_gap_zscore_reject_ret_5d_below", None) if ( conditional_gzs is not None and conditional_ret5 is not None and gap_zscore_20d is not None and ret_5d is not None and gap_zscore_20d > conditional_gzs and ret_5d < conditional_ret5 ): _f_rvol += 1 continue min_obs = getattr(params, "min_obv_slope_20d", None) if min_obs is not None and (obv_slope_20 is None or obv_slope_20 < min_obs): _f_rvol += 1 continue if engine_family == "stocks_in_play_dual_regime": if getattr(params, "require_event_flag", False) and not event_flag: _f_gap += 1 continue if ( getattr(params, "attention_min_wiki_spike_10d", None) is not None and ( attention_wiki_spike_10d is None or attention_wiki_spike_10d < float(getattr(params, "attention_min_wiki_spike_10d")) ) ): _f_gap += 1 continue if ( getattr(params, "attention_min_wiki_zscore_20d", None) is not None and ( attention_wiki_zscore_20d is None or attention_wiki_zscore_20d < float(getattr(params, "attention_min_wiki_zscore_20d")) ) ): _f_gap += 1 continue if ( getattr(params, "attention_min_article_count_3d", None) is not None and attention_article_count_3d < int(getattr(params, "attention_min_article_count_3d")) ): _f_gap += 1 continue if ( getattr(params, "attention_min_us_article_count_3d", None) is not None and attention_us_article_count_3d < int(getattr(params, "attention_min_us_article_count_3d")) ): _f_gap += 1 continue if ( getattr(params, "attention_min_resolver_confidence", None) is not None and attention_resolver_confidence < float(getattr(params, "attention_min_resolver_confidence")) ): _f_gap += 1 continue if direction == "bullish": if close_location < getattr(params, "min_close_location", 0.0): _f_dir += 1 continue if ( getattr(params, "require_vwap_confirmation", False) and orb_vwap is not None and orb_close < orb_vwap ): _f_dir += 1 continue elif direction == "bearish": if not getattr(params, "allow_failed_orb_short", False): _f_dir += 1 continue if gap_pct <= 0: _f_gap += 1 continue if close_location > getattr(params, "max_close_location_short", 1.0): _f_dir += 1 continue if ( getattr(params, "require_vwap_confirmation", False) and orb_vwap is not None and orb_close > orb_vwap ): _f_dir += 1 continue else: _f_dir += 1 continue raw_candidates.append({ "ticker": ticker, "sector": (ticker_sectors or {}).get(ticker, "UNKNOWN"), "orb_bar": orb_bar, "direction": direction, "rvol": raw_rvol, "filter_rvol": rvol, "gap_pct": gap_pct, "abs_gap_pct": abs_gap_pct, "atr": atr, "first_bar_dollar_vol": raw_first_bar_dollar_vol, "filter_first_bar_dollar_vol": first_bar_dollar_vol, "premarket_dollar_vol": raw_premarket_dollar_vol, "filter_premarket_dollar_vol": premarket_dollar_vol, "body_ratio": body_ratio, "close_location": close_location, "momentum": momentum, "entropy_20d": entropy_20d or 0.0, "obv_slope_20": obv_slope_20 if obv_slope_20 is not None else 0.0, "obv_slope_5": obv_slope_5 if obv_slope_5 is not None else 0.0, "atr_ratio_10_60": atr_ratio_10_60 or 0.0, "range_compression_10_60": range_compression_10_60, "gap_zscore_20d": gap_zscore_20d or 0.0, "event_flag": event_flag, "event_types": event_types, "event_score": event_score, "attention_wiki_spike_10d": attention_wiki_spike_10d or 0.0, "attention_article_count_3d": attention_article_count_3d, "attention_us_article_count_3d": attention_us_article_count_3d, "attention_resolver_confidence": attention_resolver_confidence, "ownership_13dg_flag": ownership_13dg_flag, "ownership_13dg_initial_flag": ownership_13dg_initial_flag, "ownership_13dg_score": ownership_13dg_score, "ownership_13dg_initial_score": ownership_13dg_initial_score, "ownership_13dg_days_since": int(ownership_days_since) if ownership_days_since is not None else None, "ownership_13dg_strength_score": ownership_13dg_strength_score, "form4_flag": form4_flag, "form4_days_since": int(form4_days_since) if form4_days_since is not None else None, "form4_total_value": form4_total_value, "form4_owner_count": form4_owner_count, "form4_c_suite_count": form4_c_suite_count, "form4_role_weight_score": form4_role_weight_score, "orb_vwap": orb_vwap, "used_small_gap_attention_override": used_small_gap_attention_override, "crowded_gap_requires_confirmation": crowded_gap_requires_confirmation, "crowded_gap_size_scale": crowded_gap_size_scale, "countertrend_gap_requires_confirmation": countertrend_gap_requires_confirmation, "countertrend_gap_size_scale": countertrend_gap_size_scale, "distressed_reclaim_requires_confirmation": distressed_reclaim_requires_confirmation, "distressed_reclaim_size_scale": distressed_reclaim_size_scale, "distressed_reclaim_skip_trade": distressed_reclaim_skip_trade, "distressed_reclaim_reserve_cash": distressed_reclaim_reserve_cash, "distressed_reclaim_reserve_full_cash": distressed_reclaim_reserve_full_cash, "distressed_reclaim_triggered": distressed_reclaim_triggered, "distressed_reclaim_loss_cap_pct": distressed_reclaim_loss_cap_pct, "hot_reclaim_requires_confirmation": hot_reclaim_requires_confirmation, "hot_reclaim_size_scale": hot_reclaim_size_scale, "hot_reclaim_skip_trade": hot_reclaim_skip_trade, "hot_reclaim_reserve_cash": hot_reclaim_reserve_cash, "hot_reclaim_reserve_full_cash": hot_reclaim_reserve_full_cash, "hot_reclaim_loss_cap_pct": hot_reclaim_loss_cap_pct, "weak_downside_reclaim_requires_confirmation": weak_downside_reclaim_requires_confirmation, "weak_downside_reclaim_size_scale": weak_downside_reclaim_size_scale, "weak_downside_reclaim_skip_trade": weak_downside_reclaim_skip_trade, "weak_downside_reclaim_reserve_cash": weak_downside_reclaim_reserve_cash, "weak_downside_reclaim_reserve_full_cash": weak_downside_reclaim_reserve_full_cash, "weak_downside_reclaim_loss_cap_pct": weak_downside_reclaim_loss_cap_pct, "quiet_downside_reclaim_requires_confirmation": ( quiet_downside_reclaim_requires_confirmation ), "quiet_downside_reclaim_size_scale": quiet_downside_reclaim_size_scale, "quiet_downside_reclaim_skip_trade": quiet_downside_reclaim_skip_trade, "quiet_downside_reclaim_reserve_cash": quiet_downside_reclaim_reserve_cash, "quiet_downside_reclaim_reserve_full_cash": ( quiet_downside_reclaim_reserve_full_cash ), "quiet_downside_reclaim_loss_cap_pct": quiet_downside_reclaim_loss_cap_pct, "stale_obv_reversal_requires_confirmation": ( stale_obv_reversal_requires_confirmation ), "stale_obv_reversal_size_scale": stale_obv_reversal_size_scale, "stale_obv_reversal_skip_trade": stale_obv_reversal_skip_trade, "stale_obv_reversal_reserve_cash": stale_obv_reversal_reserve_cash, "stale_obv_reversal_reserve_full_cash": ( stale_obv_reversal_reserve_full_cash ), "stale_obv_reversal_loss_cap_pct": stale_obv_reversal_loss_cap_pct, "stalled_gap_up_requires_confirmation": stalled_gap_up_requires_confirmation, "stalled_gap_up_size_scale": stalled_gap_up_size_scale, "stalled_gap_up_skip_trade": stalled_gap_up_skip_trade, "stalled_gap_up_reserve_cash": stalled_gap_up_reserve_cash, "stalled_gap_up_reserve_full_cash": stalled_gap_up_reserve_full_cash, "stalled_gap_up_loss_cap_pct": stalled_gap_up_loss_cap_pct, "liquid_stalled_gap_up_requires_confirmation": ( liquid_stalled_gap_up_requires_confirmation ), "liquid_stalled_gap_up_size_scale": liquid_stalled_gap_up_size_scale, "liquid_stalled_gap_up_skip_trade": liquid_stalled_gap_up_skip_trade, "liquid_stalled_gap_up_reserve_cash": liquid_stalled_gap_up_reserve_cash, "liquid_stalled_gap_up_reserve_full_cash": ( liquid_stalled_gap_up_reserve_full_cash ), "liquid_stalled_gap_up_loss_cap_pct": liquid_stalled_gap_up_loss_cap_pct, "thin_gap_up_loss_cap_active": thin_gap_up_loss_cap_active, "thin_gap_up_loss_cap_pct": thin_gap_up_loss_cap_pct, "moderate_downside_loss_cap_active": moderate_downside_loss_cap_active, "moderate_downside_loss_cap_pct": moderate_downside_loss_cap_pct, "gap_up_fill_exit_active": gap_up_fill_exit_active, "candidate_seed_overlay": is_overlay, "broad_gapup_continuation": broad_gapup_continuation_active, "broad_gapup_continuation_size_scale": ( max( 0.0, min( 1.0, float( getattr( params, "broad_gapup_continuation_size_scale", 1.0, ) or 0.0 ), ), ) if broad_gapup_continuation_active else 1.0 ), "market_thrust_liquid_continuation": ( market_thrust_liquid_continuation_active ), "market_thrust_liquid_continuation_size_scale": ( max( 0.0, min( 1.0, float( getattr( params, "market_thrust_liquid_continuation_size_scale", 1.0, ) or 0.0 ), ), ) if market_thrust_liquid_continuation_active else 1.0 ), "market_thrust_opening_impulse_reclaim": ( market_thrust_opening_impulse_reclaim_active ), "market_thrust_opening_impulse_reclaim_size_scale": ( max( 0.0, min( 1.0, float( getattr( params, "market_thrust_opening_impulse_reclaim_size_scale", 1.0, ) or 0.0 ), ), ) if market_thrust_opening_impulse_reclaim_active else 1.0 ), "intraday_continuation_reclaim": intraday_continuation_reclaim_active, "intraday_continuation_reclaim_size_scale": ( max( 0.0, min( 1.0, float( getattr( params, "intraday_continuation_reclaim_size_scale", 1.0, ) or 0.0 ), ), ) if intraday_continuation_reclaim_active else 1.0 ), "intraday_continuation_signal_return": ( intraday_continuation_signal.get("signal_return") if intraday_continuation_signal else None ), "intraday_continuation_signal_dollar_vol": ( intraday_continuation_signal.get("signal_dollar_vol") if intraday_continuation_signal else None ), "intraday_continuation_signal_close_location": ( intraday_continuation_signal.get("signal_close_location") if intraday_continuation_signal else None ), "intraday_continuation_entry_ts": ( intraday_continuation_signal.get("entry_ts") if intraday_continuation_signal else None ), "intraday_continuation_entry_price": ( intraday_continuation_signal.get("entry_price") if intraday_continuation_signal else None ), "orb_return": first_bar_return_pct, "mkt_bars": mkt_bars, }) _filter_stats = { "gap": _f_gap, "rvol": _f_rvol, "atr": _f_atr, "dolvol": _f_dolvol, "dir": _f_dir, "no_bars": _f_no_bars, "late": _f_late, "price": _f_price, "crowded_gap": _f_crowded_gap, "countertrend_gap": _f_countertrend_gap, "distressed_reclaim": _f_distressed_reclaim, "hot_reclaim": _f_hot_reclaim, "weak_downside_reclaim": _f_weak_downside_reclaim, "quiet_downside_reclaim": _f_quiet_downside_reclaim, "stale_obv_reversal": _f_stale_obv_reversal, "stalled_gap_up": _f_stalled_gap_up, "liquid_stalled_gap_up": _f_liquid_stalled_gap_up, "thin_gap_up_loss_cap": _f_thin_gap_up_loss_cap, "moderate_downside_loss_cap": _f_moderate_downside_loss_cap, "gap_up_fill_exit": _f_gap_up_fill_exit, "broad_gapup_continuation": sum( 1 for cand in raw_candidates if cand.get("broad_gapup_continuation") ), "market_thrust_liquid_continuation": sum( 1 for cand in raw_candidates if cand.get("market_thrust_liquid_continuation") ), "market_thrust_opening_impulse_reclaim": sum( 1 for cand in raw_candidates if cand.get("market_thrust_opening_impulse_reclaim") ), "intraday_continuation_reclaim": sum( 1 for cand in raw_candidates if cand.get("intraday_continuation_reclaim") ), } if _stats_out is not None: _stats_out.update(_filter_stats) if not raw_candidates: total = len(bars_by_ticker) if not _suppress_empty_log: import sys print( f" [{date_str}] 0 ORB candidates from {total} tickers — " f"bearish/doji:{_f_dir} atr:{_f_atr} dolvol:{_f_dolvol} " f"rvol:{_f_rvol} gap>{params.max_gap_pct and f'{params.max_gap_pct*100:.0f}%' or '?'}:{_f_gap} " f"bars:{_f_no_bars} late:{_f_late} price:{_f_price}", file=sys.stderr, ) return [] if engine_family == "stocks_in_play_dual_regime": sector_returns: dict[tuple[str, str], list[float]] = {} for cand in raw_candidates: key = (str(cand.get("sector") or "UNKNOWN"), str(cand["direction"])) sector_returns.setdefault(key, []).append(float(cand.get("orb_return") or 0.0)) filtered_candidates: list[dict] = [] for cand in raw_candidates: key = (str(cand.get("sector") or "UNKNOWN"), str(cand["direction"])) sector_avg = ( sum(sector_returns.get(key, [0.0])) / len(sector_returns.get(key, [0.0])) if sector_returns.get(key) else 0.0 ) sector_relative_strength = float(cand.get("orb_return") or 0.0) - sector_avg cand["sector_relative_strength"] = sector_relative_strength if ( cand["direction"] == "bullish" and getattr(params, "min_sector_relative_strength", None) is not None and sector_relative_strength < float(getattr(params, "min_sector_relative_strength")) ): continue filtered_candidates.append(cand) raw_candidates = filtered_candidates if not raw_candidates: return [] rvol_values = [_float_or_none(c.get("rvol")) or 0.0 for c in raw_candidates] opening_dollar_values = [ _float_or_none(c.get("first_bar_dollar_vol")) or 0.0 for c in raw_candidates ] premarket_dollar_values = [ _float_or_none(c.get("premarket_dollar_vol")) or 0.0 for c in raw_candidates ] rvol_rank_pct = _rank_percentiles(rvol_values) opening_dollar_rank_pct = _rank_percentiles(opening_dollar_values) premarket_dollar_rank_pct = _rank_percentiles(premarket_dollar_values) context_min_bucket_size = int( getattr(params, "volume_attention_context_min_bucket_size", 3) or 3 ) sector_keys = [str(c.get("sector") or "UNKNOWN") for c in raw_candidates] price_keys = [_candidate_price_bucket(c) for c in raw_candidates] sector_rvol_rank_pct = _rank_percentiles_by_key( rvol_values, sector_keys, min_bucket_size=context_min_bucket_size, fallback=rvol_rank_pct, ) sector_opening_dollar_rank_pct = _rank_percentiles_by_key( opening_dollar_values, sector_keys, min_bucket_size=context_min_bucket_size, fallback=opening_dollar_rank_pct, ) sector_premarket_dollar_rank_pct = _rank_percentiles_by_key( premarket_dollar_values, sector_keys, min_bucket_size=context_min_bucket_size, fallback=premarket_dollar_rank_pct, ) price_rvol_rank_pct = _rank_percentiles_by_key( rvol_values, price_keys, min_bucket_size=context_min_bucket_size, fallback=rvol_rank_pct, ) price_opening_dollar_rank_pct = _rank_percentiles_by_key( opening_dollar_values, price_keys, min_bucket_size=context_min_bucket_size, fallback=opening_dollar_rank_pct, ) price_premarket_dollar_rank_pct = _rank_percentiles_by_key( premarket_dollar_values, price_keys, min_bucket_size=context_min_bucket_size, fallback=premarket_dollar_rank_pct, ) attn_w_rvol = max( 0.0, float(getattr(params, "volume_attention_rank_weight_rvol", 0.45) or 0.0), ) attn_w_open = max( 0.0, float(getattr(params, "volume_attention_rank_weight_opening_dollar_vol", 0.45) or 0.0), ) attn_w_pm = max( 0.0, float(getattr(params, "volume_attention_rank_weight_premarket_dollar_vol", 0.10) or 0.0), ) attn_weight_sum = attn_w_rvol + attn_w_open + attn_w_pm if attn_weight_sum <= 0: attn_w_rvol, attn_w_open, attn_w_pm = 0.45, 0.45, 0.10 attn_weight_sum = 1.0 context_w_global = max( 0.0, float(getattr(params, "volume_attention_rank_weight_global_context", 1.0) or 0.0), ) context_w_sector = max( 0.0, float(getattr(params, "volume_attention_rank_weight_sector_context", 0.0) or 0.0), ) context_w_price = max( 0.0, float(getattr(params, "volume_attention_rank_weight_price_context", 0.0) or 0.0), ) context_weight_sum = context_w_global + context_w_sector + context_w_price if context_weight_sum <= 0: context_w_global = 1.0 context_weight_sum = 1.0 for i, cand in enumerate(raw_candidates): cand["rvol_rank_pct"] = rvol_rank_pct[i] cand["opening_dollar_vol_rank_pct"] = opening_dollar_rank_pct[i] cand["premarket_dollar_vol_rank_pct"] = premarket_dollar_rank_pct[i] global_attention_rank = ( rvol_rank_pct[i] * attn_w_rvol + opening_dollar_rank_pct[i] * attn_w_open + premarket_dollar_rank_pct[i] * attn_w_pm ) / attn_weight_sum sector_attention_rank = ( sector_rvol_rank_pct[i] * attn_w_rvol + sector_opening_dollar_rank_pct[i] * attn_w_open + sector_premarket_dollar_rank_pct[i] * attn_w_pm ) / attn_weight_sum price_attention_rank = ( price_rvol_rank_pct[i] * attn_w_rvol + price_opening_dollar_rank_pct[i] * attn_w_open + price_premarket_dollar_rank_pct[i] * attn_w_pm ) / attn_weight_sum cand["volume_attention_global_rank_pct"] = global_attention_rank cand["volume_attention_sector_rank_pct"] = sector_attention_rank cand["volume_attention_price_rank_pct"] = price_attention_rank cand["volume_attention_rank_pct"] = ( global_attention_rank * context_w_global + sector_attention_rank * context_w_sector + price_attention_rank * context_w_price ) / context_weight_sum min_volume_attention_rank = getattr(params, "min_volume_attention_rank_pct", None) if min_volume_attention_rank is not None: min_volume_attention_rank_f = float(min_volume_attention_rank) raw_candidates = [ cand for cand in raw_candidates if float(cand.get("volume_attention_rank_pct") or 0.0) >= min_volume_attention_rank_f ] if not raw_candidates: return [] _apply_orb_sector_confirmation(raw_candidates, params) # Normalize and score rvol_vals = [c["rvol"] for c in raw_candidates] if engine_family in {"gainers_leader", "leader_followthrough", "vwap_reclaim_v1", "hypergap_failure_v1"}: gap_vals = [c["abs_gap_pct"] for c in raw_candidates] else: gap_vals = [max(c["gap_pct"], 0.0) for c in raw_candidates] # clip negative gaps dolvol_vals = [c["first_bar_dollar_vol"] for c in raw_candidates] premarket_dolvol_vals = [c["premarket_dollar_vol"] for c in raw_candidates] body_vals = [c["body_ratio"] for c in raw_candidates] close_location_vals = [c["close_location"] for c in raw_candidates] momentum_vals = [max(c["momentum"], 0.0) for c in raw_candidates] # only reward aligned momentum event_vals = [c["event_score"] for c in raw_candidates] red_to_green_gap_vals = [max(-c["gap_pct"], 0.0) for c in raw_candidates] attention_wiki_vals = [c["attention_wiki_spike_10d"] for c in raw_candidates] attention_news_vals = [ max(c["attention_article_count_3d"], c["attention_us_article_count_3d"]) for c in raw_candidates ] ownership_vals = [c["ownership_13dg_score"] for c in raw_candidates] ownership_initial_vals = [c["ownership_13dg_initial_score"] for c in raw_candidates] entropy_vals = [c["entropy_20d"] for c in raw_candidates] obv_slope_vals = [c["obv_slope_20"] for c in raw_candidates] obv_slope5_vals = [c["obv_slope_5"] for c in raw_candidates] atr_ratio_vals = [c["atr_ratio_10_60"] for c in raw_candidates] gap_zscore_vals = [c["gap_zscore_20d"] for c in raw_candidates] sector_confirmation_vals = [ c["sector_confirmation_score"] for c in raw_candidates ] volume_attention_rank_vals = [ c["volume_attention_rank_pct"] for c in raw_candidates ] structure_vals = [ c["close_location"] if c["direction"] == "bullish" else 1.0 - c["close_location"] for c in raw_candidates ] norm_rvol = _normalize_scores(rvol_vals) norm_gap = _normalize_scores(gap_vals) norm_dolvol = _normalize_scores(dolvol_vals) norm_premarket_dolvol = _normalize_scores(premarket_dolvol_vals) norm_body = _normalize_scores(body_vals) norm_close_location = _normalize_scores(close_location_vals) norm_structure = _normalize_scores(structure_vals) norm_momentum = _normalize_scores(momentum_vals) norm_event = _normalize_scores(event_vals) norm_red_to_green_gap = _normalize_scores(red_to_green_gap_vals) norm_attention_wiki = _normalize_scores(attention_wiki_vals) norm_attention_news = _normalize_scores(attention_news_vals) norm_ownership = _normalize_scores(ownership_vals) norm_ownership_initial = _normalize_scores(ownership_initial_vals) norm_entropy = _normalize_scores(entropy_vals) norm_obv_slope = _normalize_scores(obv_slope_vals) norm_obv_slope5 = _normalize_scores(obv_slope5_vals) norm_atr_ratio = _normalize_scores(atr_ratio_vals) norm_gap_zscore = _normalize_scores(gap_zscore_vals) norm_sector_confirmation = _normalize_scores(sector_confirmation_vals) norm_volume_attention_rank = _normalize_scores(volume_attention_rank_vals) for i, cand in enumerate(raw_candidates): score = ( norm_rvol[i] * params.weight_rvol + norm_gap[i] * params.weight_gap + norm_dolvol[i] * params.weight_dollar_vol + norm_premarket_dolvol[i] * params.weight_premarket_dollar_vol + norm_volume_attention_rank[i] * params.weight_volume_attention_rank ) if engine_family != "classic_breakout": score += norm_body[i] * params.weight_body_ratio score += norm_momentum[i] * params.weight_momentum if engine_family in { "gainers_leader", "leader_followthrough", "stocks_in_play_dual_regime", "orb_pullback_v1", "vwap_reclaim_v1", "hypergap_failure_v1", }: score += norm_structure[i] * params.weight_close_location score += norm_gap_zscore[i] * params.weight_gap_zscore if engine_family in {"stocks_in_play_dual_regime", "gainers_leader", "orb_pullback_v1"}: score += norm_event[i] * params.weight_event_catalyst if engine_family in { "gainers_leader", "leader_followthrough", "orb_pullback_v1", "vwap_reclaim_v1", }: score += norm_red_to_green_gap[i] * params.weight_red_to_green_gap if engine_family in { "stocks_in_play_dual_regime", "gainers_leader", "leader_followthrough", "orb_pullback_v1", "vwap_reclaim_v1", }: score += norm_attention_wiki[i] * params.weight_attention_wiki score += norm_attention_news[i] * params.weight_attention_news score += norm_ownership[i] * params.weight_ownership_13dg score += norm_ownership_initial[i] * params.weight_ownership_initial_13dg if engine_family in { "compression_breakout", "gainers_leader", "leader_followthrough", "orb_pullback_v1", "stocks_in_play_dual_regime", "vwap_reclaim_v1", "hypergap_failure_v1", }: score += norm_entropy[i] * params.weight_entropy score += norm_obv_slope[i] * params.weight_obv_slope score += norm_obv_slope5[i] * params.weight_obv_slope_5 score += norm_atr_ratio[i] * params.weight_atr_ratio if engine_family == "compression_breakout": # gap_zscore only added here for compression_breakout; # gainers_leader/leader_followthrough already add it above score += norm_gap_zscore[i] * params.weight_gap_zscore score += ( norm_sector_confirmation[i] * float(getattr(params, "sector_confirmation_score_weight", 0.0) or 0.0) ) cand["score"] = score # Sort by score descending, take top N raw_candidates.sort(key=lambda c: c["score"], reverse=True) selected = _select_orb_candidates_with_overlays(raw_candidates, params) return _apply_orb_basket_quality_floor(selected, params) # ── Breakout Detection (for chronological ordering) ────────────────────── def _find_breakout_time( mkt_bars: list[dict], orb_bar: dict, direction: str, params: ORBStrategyParams, date_str: str, ) -> dt.datetime | None: """Find the breakout time for a candidate without running the full simulation. Returns the timestamp of the bar where breakout occurs, or None if no breakout before timeout. Used to sort candidates chronologically before allocating capital. """ group_size = max(1, params.sim_bar_minutes // 5) if group_size > 1: orb_ts_raw = _parse_ts(orb_bar["timestamp"]) post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw] post_bars = _aggregate_bars(post_bars, group_size) else: orb_ts_raw = _parse_ts(orb_bar["timestamp"]) post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw] market_open = _market_open_ts(date_str) timeout_ts = market_open + dt.timedelta(minutes=params.order_timeout_minutes) breakout_level = orb_bar["high"] if direction == "long" else orb_bar["low"] for b in post_bars: ts = _parse_ts(b["timestamp"]) if ts > timeout_ts: return None if direction == "long" and b["high"] >= breakout_level: return ts if direction == "short" and b["low"] <= breakout_level: return ts return None def _find_momentum_confirm_time( mkt_bars: list[dict], orb_bar: dict, params: ORBStrategyParams, ) -> tuple[dt.datetime, float] | None: """Find the momentum confirmation entry time for a candidate (hybrid dual-trigger). Confirmation logic: - Evaluate the first two post-ORB bars (09:40 and 09:45 close for 5-min bars). - Morning gain: (close_0945 - open) / open must be in [momo_min_morning_gain_pct, momo_max_morning_gain_pct]. - Confirmation return: (close_0945 - close_0940) / close_0940 >= momo_min_confirmation_return_pct. - Window: confirmation bar must end within momo_confirm_window_minutes after ORB end (09:35). Returns (entry_timestamp, entry_price) or None if conditions not met. Only active when params.dual_trigger_enabled is True. """ if not getattr(params, "dual_trigger_enabled", False): return None if not mkt_bars: return None open_price = mkt_bars[0]["open"] if not open_price: return None orb_ts_raw = _parse_ts(orb_bar["timestamp"]) window_end = orb_ts_raw + dt.timedelta(minutes=params.momo_confirm_window_minutes) post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw] if len(post_bars) < 2: return None bar_0940 = post_bars[0] bar_0945 = post_bars[1] confirm_ts = _parse_ts(bar_0945["timestamp"]) if confirm_ts > window_end: return None close_0940 = bar_0940.get("close") or 0.0 close_0945 = bar_0945.get("close") or 0.0 if close_0940 <= 0 or close_0945 <= 0: return None morning_gain = (close_0945 - open_price) / open_price if morning_gain < params.momo_min_morning_gain_pct: return None if morning_gain > params.momo_max_morning_gain_pct: return None confirm_return = (close_0945 - close_0940) / close_0940 if confirm_return < params.momo_min_confirmation_return_pct: return None return (confirm_ts, float(close_0945)) def _find_vwap_reclaim_time( mkt_bars: list[dict], orb_bar: dict, direction: str, params: ORBStrategyParams, date_str: str, ) -> tuple[dt.datetime, float] | None: """Find the first valid VWAP-reclaim entry for no-fill fallback paths.""" if not mkt_bars: return None if direction not in {"long", "short"}: return None market_open = _market_open_ts(date_str) orb_ts = _parse_ts(orb_bar["timestamp"]) window_start = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_start_min) window_end = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_end_min) post_orb_vols_so_far: list[float] = [] if params.vwap_reclaim_require_prior_dip: had_dip = False for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts >= window_start: break if ts <= orb_ts: continue running_vwap = _compute_running_vwap(mkt_bars, ts) if running_vwap is None: continue if direction == "long" and b["close"] < running_vwap: had_dip = True break if direction == "short" and b["close"] > running_vwap: had_dip = True break if not had_dip: return None for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= orb_ts: continue bar_vol = float(b.get("volume", 0) or 0) if ts < window_start: post_orb_vols_so_far.append(bar_vol) continue if ts >= window_end: break running_vwap = _compute_running_vwap(mkt_bars, ts) if running_vwap is None: post_orb_vols_so_far.append(bar_vol) continue clearance = params.vwap_reclaim_min_clearance_pct if direction == "long": reclaim_ok = b["close"] > running_vwap * (1 + clearance) if reclaim_ok and params.vwap_reclaim_require_orb_open_retake: reclaim_ok = b["close"] >= orb_bar["open"] else: reclaim_ok = b["close"] < running_vwap * (1 - clearance) if reclaim_ok and params.vwap_reclaim_require_orb_open_retake: reclaim_ok = b["close"] <= orb_bar["open"] if reclaim_ok: vol_gate = params.vwap_reclaim_confirm_rel_vol if vol_gate is not None: avg_post_orb_vol = ( sum(post_orb_vols_so_far) / len(post_orb_vols_so_far) if post_orb_vols_so_far else 0.0 ) reclaim_rvol = bar_vol / avg_post_orb_vol if avg_post_orb_vol > 0 else 0.0 if reclaim_rvol < vol_gate: post_orb_vols_so_far.append(bar_vol) continue return (ts, float(b["close"])) post_orb_vols_so_far.append(bar_vol) return None def _find_late_breakout_time( mkt_bars: list[dict], orb_bar: dict, direction: str, params: ORBStrategyParams, date_str: str, ) -> tuple[dt.datetime, float] | None: """Find a late, close-confirmed ORB breakout after the primary timeout.""" if not mkt_bars: return None if direction not in {"long", "short"}: return None market_open = _market_open_ts(date_str) orb_ts = _parse_ts(orb_bar["timestamp"]) window_start = market_open + dt.timedelta( minutes=max(0, int(getattr(params, "late_breakout_window_start_min", 30) or 30)) ) window_end = market_open + dt.timedelta( minutes=max(0, int(getattr(params, "late_breakout_window_end_min", 150) or 150)) ) if window_end <= window_start: return None breakout_level = float(orb_bar["high"] if direction == "long" else orb_bar["low"]) clearance = max(0.0, float(getattr(params, "late_breakout_min_clearance_pct", 0.0) or 0.0)) rel_vol_gate = getattr(params, "late_breakout_confirm_rel_vol", None) require_vwap = bool(getattr(params, "late_breakout_require_vwap_confirmation", True)) post_orb_vols_so_far: list[float] = [] for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= orb_ts: continue bar_vol = float(b.get("volume", 0) or 0) if ts < window_start: post_orb_vols_so_far.append(bar_vol) continue if ts >= window_end: break close = float(b.get("close", 0.0) or 0.0) if close <= 0: post_orb_vols_so_far.append(bar_vol) continue if direction == "long": breakout_ok = close >= breakout_level * (1.0 + clearance) else: breakout_ok = close <= breakout_level * (1.0 - clearance) if not breakout_ok: post_orb_vols_so_far.append(bar_vol) continue if require_vwap: running_vwap = _compute_running_vwap(mkt_bars, ts) if running_vwap is None: post_orb_vols_so_far.append(bar_vol) continue if direction == "long" and close <= running_vwap: post_orb_vols_so_far.append(bar_vol) continue if direction == "short" and close >= running_vwap: post_orb_vols_so_far.append(bar_vol) continue if rel_vol_gate is not None: avg_post_orb_vol = ( sum(post_orb_vols_so_far) / len(post_orb_vols_so_far) if post_orb_vols_so_far else 0.0 ) late_rel_vol = bar_vol / avg_post_orb_vol if avg_post_orb_vol > 0 else 0.0 if late_rel_vol < float(rel_vol_gate): post_orb_vols_so_far.append(bar_vol) continue return (ts, close) return None def _find_opening_followthrough_time( mkt_bars: list[dict], orb_bar: dict, direction: str, params: ORBStrategyParams, ) -> tuple[dt.datetime, float] | None: """Confirm the first post-ORB bar, then enter at the next bar open.""" if not mkt_bars or direction not in {"long", "short"}: return None orb_ts = _parse_ts(orb_bar["timestamp"]) post_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) > orb_ts] if len(post_bars) < 2: return None signal_bar = post_bars[0] entry_bar = post_bars[1] signal_return = _bar_return_pct(signal_bar) if signal_return is None: return None close_location = _bar_close_location(signal_bar) if close_location is None: return None min_return = getattr( params, "market_thrust_liquid_continuation_followthrough_min_return_pct", None, ) if min_return is not None: if direction == "long" and signal_return < float(min_return): return None if direction == "short" and signal_return > -float(min_return): return None min_close = getattr( params, "market_thrust_liquid_continuation_followthrough_min_close_location", None, ) if min_close is not None: if direction == "long" and close_location < float(min_close): return None if direction == "short" and (1.0 - close_location) < float(min_close): return None if getattr( params, "market_thrust_liquid_continuation_followthrough_require_orb_breakout", True, ): signal_close = float(signal_bar.get("close", 0.0) or 0.0) if direction == "long" and signal_close < float(orb_bar["high"]): return None if direction == "short" and signal_close > float(orb_bar["low"]): return None entry_price = float(entry_bar.get("open", 0.0) or 0.0) if entry_price <= 0: return None return (_parse_ts(entry_bar["timestamp"]), entry_price) # ── Single Trade Simulation ──────────────────────────────────────────────── def simulate_orb_trade( mkt_bars: list[dict], orb_bar: dict, direction: str, atr: float, rvol: float, gap_pct: float, params: ORBStrategyParams, equity: float, date_str: str, ticker: str, available_cash: float | None = None, sizing_capital: float | None = None, score_rank_pct: float = 0.0, prev_close: float | None = None, entry_after_ts: dt.datetime | None = None, spy_bars: list[dict] | None = None, is_soft_day: bool = False, trigger_type: str = "orb", forced_entry_price: float | None = None, forced_entry_bar: dict | None = None, reject_reason_out: dict[str, str] | None = None, pyramid_allowed: bool = True, candidate_score: float | None = None, ) -> IntradayTrade | None: """Simulate a single ORB trade with ATR-based stops. Entry: - breakout_level = orb_bar["high"] (long) or orb_bar["low"] (short) - Iterate bars after ORB bar until breakout or timeout - Fill at max(breakout_level, bar.open) — conservative: if bar gaps above breakout, pay open price (worse than breakout_level) - If no fill by order_timeout_minutes: return None - trigger_type="momentum_confirm": skip breakout loop; enter at forced_entry_price/bar - trigger_type="broad_gapup_continuation": use the normal ORB breakout path, but tag sizing/diagnostics as the high-gap continuation sleeve. Position sizing (risk-based): - risk_dollars = equity × risk_per_trade_pct - stop_distance = atr × atr_stop_multiplier - shares = risk_dollars / stop_distance - cap: shares × entry_price ≤ equity × max_position_pct Stop management (bar iteration after entry): - initial_stop = entry_raw - stop_distance (long) - At +1R (breakeven_at_r): move stop to entry_raw - At +2R (trailing_at_r): activate trailing stop using last 3 bar lows - Trailing: current_stop = max(current_stop, max of last 3 bar lows) If sim_bar_minutes > 5, post-ORB bars are aggregated (e.g. 30-min) before iteration. Returns: IntradayTrade or None if no breakout fill before timeout. """ def _none(reason: str) -> None: if reject_reason_out is not None: reject_reason_out["reason"] = reason return None if atr <= 0: return _none("atr_nonpositive") effective_atr_stop_mult = ( params.atr_stop_multiplier_weak if (is_soft_day and params.atr_stop_multiplier_weak is not None) else params.atr_stop_multiplier ) effective_breakeven_at_r = ( params.breakeven_at_r_weak if (is_soft_day and params.breakeven_at_r_weak is not None) else params.breakeven_at_r ) stop_distance = atr * effective_atr_stop_mult if stop_distance <= 0: return _none("stop_distance_nonpositive") # Aggregate bars if sim_bar_minutes > 5 (e.g. 30-min bars). # The ORB bar (first 5-min bar) is always kept as-is; only post-ORB bars # are aggregated. This keeps the ORB classification on the original 5-min # candle while using larger bars for breakout detection and stop management. group_size = max(1, params.sim_bar_minutes // 5) raw_post_bars: list[dict] = [] # original 5-min post-ORB bars (for entry fill price) if group_size > 1: orb_ts_raw = _parse_ts(orb_bar["timestamp"]) pre_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) <= orb_ts_raw] post_bars = [b for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts_raw] raw_post_bars = list(post_bars) # save before aggregation post_bars = _aggregate_bars(post_bars, group_size) mkt_bars = pre_bars + post_bars market_open = _market_open_ts(date_str) orb_ts = _parse_ts(orb_bar["timestamp"]) timeout_ts = market_open + dt.timedelta(minutes=params.order_timeout_minutes) # Exit time: market close - exit_minutes_before_close market_close = market_open.replace(hour=16, minute=0) exit_target = market_close - dt.timedelta(minutes=params.exit_minutes_before_close) slippage = params.slippage_bps # For long: breakout above ORB high; for short: below ORB low if direction == "long": breakout_level = orb_bar["high"] else: breakout_level = orb_bar["low"] # --- Phase 1: Wait for breakout (or use forced alternate-trigger entry) --- entry_bar: dict | None = None entry_price_raw = 0.0 _sim_engine_family = _effective_orb_engine_family(params) if ( trigger_type in { "momentum_confirm", "vwap_reclaim", "soft_day_vwap_reclaim", "late_breakout", "broad_gapup_continuation", "market_thrust_opening_burst", "market_thrust_opening_followthrough", "market_thrust_opening_impulse_reclaim", "intraday_continuation_reclaim", } and forced_entry_price is not None and forced_entry_bar is not None ): # Alternate-trigger path: entry price and bar pre-determined by the caller. # Skip the breakout loop entirely. Stop/trail logic is unchanged. entry_price_raw = forced_entry_price entry_bar = forced_entry_bar elif _sim_engine_family == "vwap_reclaim_v1" or trigger_type in {"vwap_reclaim", "soft_day_vwap_reclaim"}: # VWAP Reclaim path: skip ORB breakout, scan for first bar closing above session VWAP # in the late-morning window [window_start_min, window_end_min] from market open. _vr_start = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_start_min) _vr_end = market_open + dt.timedelta(minutes=params.vwap_reclaim_window_end_min) _post_orb_vols_so_far: list[float] = [] # Optional: require prior dip below VWAP (true reclaim, not a drift-above entry) if params.vwap_reclaim_require_prior_dip: _had_dip = False for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts >= _vr_start: break _vwap_pre = _compute_running_vwap(mkt_bars, ts) if _vwap_pre is None: continue if direction == "long" and b["close"] < _vwap_pre: _had_dip = True break elif direction == "short" and b["close"] > _vwap_pre: _had_dip = True break if not _had_dip: return _none("vwap_no_prior_dip") for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= orb_ts: continue if ts < _vr_start: _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0)) continue if ts >= _vr_end or ts >= exit_target: break _vwap = _compute_running_vwap(mkt_bars, ts) if _vwap is None: _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0)) continue _clearance = params.vwap_reclaim_min_clearance_pct if direction == "long" and b["close"] > _vwap * (1 + _clearance): if params.vwap_reclaim_require_orb_open_retake and b["close"] < orb_bar["open"]: _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0)) continue _vol_gate = params.vwap_reclaim_confirm_rel_vol if _vol_gate is not None: _avg_post_orb_vol = ( sum(_post_orb_vols_so_far) / len(_post_orb_vols_so_far) if _post_orb_vols_so_far else 0.0 ) _bar_vol = float(b.get("volume", 0) or 0) _reclaim_rvol = _bar_vol / _avg_post_orb_vol if _avg_post_orb_vol > 0 else 0.0 if _reclaim_rvol < _vol_gate: _post_orb_vols_so_far.append(_bar_vol) continue entry_price_raw = b["close"] entry_bar = b break elif direction == "short" and b["close"] < _vwap * (1 - _clearance): if params.vwap_reclaim_require_orb_open_retake and b["close"] > orb_bar["open"]: _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0)) continue _vol_gate = params.vwap_reclaim_confirm_rel_vol if _vol_gate is not None: _avg_post_orb_vol = ( sum(_post_orb_vols_so_far) / len(_post_orb_vols_so_far) if _post_orb_vols_so_far else 0.0 ) _bar_vol = float(b.get("volume", 0) or 0) _reclaim_rvol = _bar_vol / _avg_post_orb_vol if _avg_post_orb_vol > 0 else 0.0 if _reclaim_rvol < _vol_gate: _post_orb_vols_so_far.append(_bar_vol) continue entry_price_raw = b["close"] entry_bar = b break _post_orb_vols_so_far.append(float(b.get("volume", 0) or 0)) else: for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= orb_ts: continue # skip ORB bar and anything before it # Re-entry mode: skip bars before the previous exit if entry_after_ts is not None and ts <= entry_after_ts: continue # Check timeout (disabled for re-entries — they happen later in the day) if entry_after_ts is None and ts > timeout_ts: return _none("breakout_timeout") # Check breakout # entry_on_bar_close: require bar CLOSE above/below level (filters wick-only touches) use_bar_close_entry = params.entry_on_bar_close if direction == "long": bar_triggered = ( b["close"] >= breakout_level if use_bar_close_entry else b["high"] >= breakout_level ) else: bar_triggered = ( b["close"] <= breakout_level if use_bar_close_entry else b["low"] <= breakout_level ) if bar_triggered and direction == "long": if group_size > 1: # Signal is only known at the END of the aggregated bar. # Fill at the first 5-min bar's open after the signal bar ends — # the aggregated bar's open (pre-signal) is unavailable to the trader. agg_ts = _parse_ts(b["timestamp"]) fill_raw = next( (r for r in raw_post_bars if _parse_ts(r["timestamp"]) > agg_ts), None ) if fill_raw is None: return _none("aggregate_signal_no_next_fill_bar") entry_price_raw = max(breakout_level, fill_raw["open"]) entry_bar = fill_raw # entry_ts and entry_time use the fill bar elif use_bar_close_entry: # Enter at bar close — trader waits for bar to complete entry_price_raw = b["close"] entry_bar = b else: entry_price_raw = max(breakout_level, b["open"]) entry_bar = b break elif bar_triggered and direction == "short": if group_size > 1: agg_ts = _parse_ts(b["timestamp"]) fill_raw = next( (r for r in raw_post_bars if _parse_ts(r["timestamp"]) > agg_ts), None ) if fill_raw is None: return _none("aggregate_signal_no_next_fill_bar") entry_price_raw = min(breakout_level, fill_raw["open"]) entry_bar = fill_raw elif use_bar_close_entry: entry_price_raw = b["close"] entry_bar = b else: entry_price_raw = min(breakout_level, b["open"]) entry_bar = b break if entry_bar is None: return _none("no_entry_bar") # --- Pullback continuation entry --- # Instead of entering on the breakout, wait for a pullback and continuation. # 1. Record the breakout, then look for a bar that retraces from the post-breakout peak # 2. After the pullback, look for continuation (new bar making progress) # 3. Enter at the continuation bar close with stop at pullback extreme if params.pullback_entry: initial_breakout_bar = entry_bar initial_breakout_ts = _parse_ts(initial_breakout_bar["timestamp"]) # Reset entry — we'll find a better one after pullback entry_bar = None entry_price_raw = 0.0 post_breakout_peak = breakout_level pullback_extreme = breakout_level # lowest point during pullback (long) pullback_found = False bars_after_breakout = 0 # orb_pullback_v1: volume tracking for contraction check _impulse_vols: list[float] = [] _pullback_vols: list[float] = [] # orb_pullback_v1: impulse window cutoff — peak must form by X min from open _impulse_window_cutoff: dt.datetime | None = None if params.pullback_impulse_window_end_min is not None: _tdate = initial_breakout_ts.astimezone(_ET).date() _impulse_window_cutoff = dt.datetime( _tdate.year, _tdate.month, _tdate.day, 9, 30, tzinfo=_ET ) + dt.timedelta(minutes=params.pullback_impulse_window_end_min) for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= initial_breakout_ts: continue if ts >= exit_target: break # too late in the day bars_after_breakout += 1 if bars_after_breakout > params.pullback_max_bars: break if direction == "long": post_breakout_peak = max(post_breakout_peak, b["high"]) move_from_breakout = post_breakout_peak - breakout_level if not pullback_found: _impulse_vols.append(float(b.get("volume", 0) or 0)) # Impulse window expired — abort if peak not yet confirmed if _impulse_window_cutoff is not None and ts > _impulse_window_cutoff: break # Look for pullback: price retraces from peak if move_from_breakout > 0: # Minimum impulse size gate if ( params.pullback_impulse_min_move_atr is not None and move_from_breakout < params.pullback_impulse_min_move_atr * atr ): continue retracement = (post_breakout_peak - b["low"]) / move_from_breakout depth_ok = retracement >= params.pullback_min_retracement_pct if depth_ok and params.pullback_depth_max_pct is not None: depth_ok = retracement <= params.pullback_depth_max_pct if depth_ok: pullback_found = True pullback_extreme = b["low"] continue # Pullback found — track the low and look for continuation _pullback_vols.append(float(b.get("volume", 0) or 0)) pullback_extreme = min(pullback_extreme, b["low"]) # VWAP floor: abort if pullback breaches VWAP too deeply if params.pullback_vwap_floor: _running_vwap = _compute_running_vwap(mkt_bars, ts) if _running_vwap is not None: if b["low"] < _running_vwap * (1 - params.pullback_vwap_floor_tolerance_pct): break # Continuation: bar closes green and shows real progress out of the pullback. if b["close"] > b["open"] and b["close"] > pullback_extreme: if params.pullback_require_breakout_retake: _reclaim_clearance = max( float(getattr(params, "pullback_breakout_retake_clearance_pct", 0.0) or 0.0), 0.0, ) if b["close"] < breakout_level * (1 + _reclaim_clearance): continue # Volume contraction gate if params.pullback_volume_contraction_ratio is not None: if _impulse_vols and _pullback_vols: avg_imp = sum(_impulse_vols) / len(_impulse_vols) avg_pb = sum(_pullback_vols) / len(_pullback_vols) if avg_pb >= avg_imp * params.pullback_volume_contraction_ratio: break # no volume contraction — skip setup # Reclaim rel-vol confirmation if params.pullback_reclaim_confirm_rel_vol is not None: _post_orb_avg = ( sum(_impulse_vols + _pullback_vols) / len(_impulse_vols + _pullback_vols) if (_impulse_vols or _pullback_vols) else 0.0 ) _bar_vol = float(b.get("volume", 0) or 0) _reclaim_rvol = _bar_vol / _post_orb_avg if _post_orb_avg > 0 else 0.0 if _reclaim_rvol < params.pullback_reclaim_confirm_rel_vol: continue entry_price_raw = b["close"] entry_bar = b # Stop assignment: legacy pullback_stop_at_low if params.pullback_stop_at_low and pullback_extreme < entry_price_raw: stop_distance = entry_price_raw - pullback_extreme # Override: vwap_lower stop mode if params.pullback_stop_mode == "vwap_lower": _sv = _compute_running_vwap(mkt_bars, ts) if _sv is not None and _sv < entry_price_raw: _computed_sd = entry_price_raw - _sv * (1 - params.pullback_stop_vwap_buffer_pct) if _computed_sd > 0: stop_distance = _computed_sd elif params.pullback_stop_mode == "pullback_low" and pullback_extreme < entry_price_raw: stop_distance = entry_price_raw - pullback_extreme break else: # short post_breakout_peak = min(post_breakout_peak, b["low"]) # trough move_from_breakout = breakout_level - post_breakout_peak if not pullback_found: _impulse_vols.append(float(b.get("volume", 0) or 0)) if _impulse_window_cutoff is not None and ts > _impulse_window_cutoff: break if move_from_breakout > 0: if ( params.pullback_impulse_min_move_atr is not None and move_from_breakout < params.pullback_impulse_min_move_atr * atr ): continue retracement = (b["high"] - post_breakout_peak) / move_from_breakout depth_ok = retracement >= params.pullback_min_retracement_pct if depth_ok and params.pullback_depth_max_pct is not None: depth_ok = retracement <= params.pullback_depth_max_pct if depth_ok: pullback_found = True pullback_extreme = b["high"] continue _pullback_vols.append(float(b.get("volume", 0) or 0)) pullback_extreme = max(pullback_extreme, b["high"]) if params.pullback_vwap_floor: _running_vwap = _compute_running_vwap(mkt_bars, ts) if _running_vwap is not None: if b["high"] > _running_vwap * (1 + params.pullback_vwap_floor_tolerance_pct): break if b["close"] < b["open"] and b["close"] < pullback_extreme: if params.pullback_require_breakout_retake: _reclaim_clearance = max( float(getattr(params, "pullback_breakout_retake_clearance_pct", 0.0) or 0.0), 0.0, ) if b["close"] > breakout_level * (1 - _reclaim_clearance): continue if params.pullback_volume_contraction_ratio is not None: if _impulse_vols and _pullback_vols: avg_imp = sum(_impulse_vols) / len(_impulse_vols) avg_pb = sum(_pullback_vols) / len(_pullback_vols) if avg_pb >= avg_imp * params.pullback_volume_contraction_ratio: break if params.pullback_reclaim_confirm_rel_vol is not None: _post_orb_avg = ( sum(_impulse_vols + _pullback_vols) / len(_impulse_vols + _pullback_vols) if (_impulse_vols or _pullback_vols) else 0.0 ) _bar_vol = float(b.get("volume", 0) or 0) _reclaim_rvol = _bar_vol / _post_orb_avg if _post_orb_avg > 0 else 0.0 if _reclaim_rvol < params.pullback_reclaim_confirm_rel_vol: continue entry_price_raw = b["close"] entry_bar = b if params.pullback_stop_at_low and pullback_extreme > entry_price_raw: stop_distance = pullback_extreme - entry_price_raw if params.pullback_stop_mode == "vwap_lower": _sv = _compute_running_vwap(mkt_bars, ts) if _sv is not None and _sv > entry_price_raw: _computed_sd = _sv * (1 + params.pullback_stop_vwap_buffer_pct) - entry_price_raw if _computed_sd > 0: stop_distance = _computed_sd elif params.pullback_stop_mode == "pullback_low" and pullback_extreme > entry_price_raw: stop_distance = pullback_extreme - entry_price_raw break if entry_bar is None: return _none("pullback_no_continuation") # --- VWAP-based stop override for vwap_reclaim_v1 --- # Tighter structural stop: distance from entry to VWAP floor instead of ATR multiple. # More shares per unit risk on high-gap stocks where VWAP is naturally a support floor. if ( (_sim_engine_family == "vwap_reclaim_v1" or trigger_type in {"vwap_reclaim", "soft_day_vwap_reclaim"}) and params.vwap_reclaim_stop_mode == "vwap" ): _entry_ts = _parse_ts(entry_bar["timestamp"]) _sv = _compute_running_vwap(mkt_bars, _entry_ts) if _sv is not None and direction == "long" and _sv < entry_price_raw: _buf = params.vwap_reclaim_stop_vwap_buffer_pct _vwap_sd = entry_price_raw - _sv * (1 - _buf) if _vwap_sd > 0: stop_distance = _vwap_sd elif _sv is not None and direction == "short" and _sv > entry_price_raw: _buf = params.vwap_reclaim_stop_vwap_buffer_pct _vwap_sd = _sv * (1 + _buf) - entry_price_raw if _vwap_sd > 0: stop_distance = _vwap_sd # --- Breakout volume confirmation --- # Reject breakouts on thin volume (low conviction, likely to fail). # Skip for momentum_confirm trigger — volume confirmation is already embedded in # the morning_gain + confirmation_return gates of _find_momentum_confirm_time. if ( trigger_type in {"orb", "broad_gapup_continuation"} and forced_entry_bar is None and params.min_breakout_rel_vol is not None ): entry_vol = entry_bar.get("volume", 0) or 0 # Average volume of all post-ORB bars (excluding ORB bar itself) post_orb_vols = [ b.get("volume", 0) or 0 for b in mkt_bars if _parse_ts(b["timestamp"]) > orb_ts ] avg_bar_vol = sum(post_orb_vols) / len(post_orb_vols) if post_orb_vols else 0 if avg_bar_vol > 0 and entry_vol < avg_bar_vol * params.min_breakout_rel_vol: return _none("breakout_rel_vol") # --- Position sizing (must happen before stop check so shares are known) --- initial_stop = ( entry_price_raw - stop_distance if direction == "long" else entry_price_raw + stop_distance ) # --- Confirmation bar requirement (lookahead-free) --- # After breakout, wait one bar. If confirmation bar closes in the right direction, # enter at the confirmation bar's close (the price available AFTER seeing confirmation). # This avoids retroactive cancellation bias — unconfirmed trades simply don't enter. if params.require_confirmation_bar: confirm_bar = None for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= _parse_ts(entry_bar["timestamp"]): continue confirm_bar = b break if confirm_bar is None: return _none("confirmation_no_next_bar") if direction == "long" and confirm_bar["close"] < entry_price_raw: return _none("confirmation_failed_long") elif direction == "short" and confirm_bar["close"] > entry_price_raw: return _none("confirmation_failed_short") # Confirmation passed — shift entry to confirmation bar's close # (the price available to the trader AFTER observing the confirmation) entry_price_raw = confirm_bar["close"] entry_bar = confirm_bar entry_ts = _parse_ts(confirm_bar["timestamp"]) # Recalculate stop with new entry price initial_stop = ( entry_price_raw - stop_distance if direction == "long" else entry_price_raw + stop_distance ) fixed_loss_per_share = params.fixed_loss_dollars if params.fixed_loss_pct is not None: fixed_loss_pct = max(0.0, float(params.fixed_loss_pct)) fixed_loss_per_share = entry_price_raw * fixed_loss_pct if fixed_loss_pct > 0 else None if fixed_loss_per_share is not None and fixed_loss_per_share > 0: if direction == "long": initial_stop = max(initial_stop, entry_price_raw - fixed_loss_per_share) else: initial_stop = min(initial_stop, entry_price_raw + fixed_loss_per_share) fixed_loss_preserve_trailing = bool(getattr(params, "fixed_loss_preserve_trailing", False)) enters_after_bar_close = trigger_type in { "momentum_confirm", "vwap_reclaim", "soft_day_vwap_reclaim", "late_breakout", } same_bar_stop_allowed = ( group_size == 1 and not params.entry_on_bar_close and not enters_after_bar_close ) same_bar_stop_confirmation_enabled = bool( getattr(params, "same_bar_stop_confirmation_enabled", False) ) same_bar_stop_confirmation_allowed_triggers = getattr( params, "same_bar_stop_confirmation_allowed_trigger_types", None, ) if ( same_bar_stop_confirmation_enabled and same_bar_stop_confirmation_allowed_triggers ): same_bar_stop_confirmation_enabled = ( trigger_type in set(same_bar_stop_confirmation_allowed_triggers) ) same_bar_stop_confirmation_active = ( same_bar_stop_confirmation_enabled and same_bar_stop_allowed and ( (direction == "long" and entry_bar["low"] <= initial_stop) or (direction == "short" and entry_bar["high"] >= initial_stop) ) ) if same_bar_stop_confirmation_active: confirm_bar = None for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= _parse_ts(entry_bar["timestamp"]): continue confirm_bar = b break if confirm_bar is None: return _none("same_bar_stop_confirmation_no_next_bar") if direction == "long" and confirm_bar["close"] < entry_price_raw: return _none("same_bar_stop_confirmation_failed_long") if direction == "short" and confirm_bar["close"] > entry_price_raw: return _none("same_bar_stop_confirmation_failed_short") entry_price_raw = confirm_bar["close"] entry_bar = confirm_bar initial_stop = ( entry_price_raw - stop_distance if direction == "long" else entry_price_raw + stop_distance ) fixed_loss_per_share = params.fixed_loss_dollars if params.fixed_loss_pct is not None: fixed_loss_pct = max(0.0, float(params.fixed_loss_pct)) fixed_loss_per_share = ( entry_price_raw * fixed_loss_pct if fixed_loss_pct > 0 else None ) if fixed_loss_per_share is not None and fixed_loss_per_share > 0: if direction == "long": initial_stop = max(initial_stop, entry_price_raw - fixed_loss_per_share) else: initial_stop = min(initial_stop, entry_price_raw + fixed_loss_per_share) same_bar_stop_allowed = False # Use sizing_capital for position sizing (simple/compound mode). # sizing_capital = initial_capital when compound_returns=False, else current equity. cap = sizing_capital if sizing_capital is not None else equity # Score-based position sizing. # score_rank_pct: 1.0 = top rank, 0.0 = bottom rank. sizing_mult = 1.0 if params.score_sizing_floor is not None: floor = max(0.0, min(1.0, float(params.score_sizing_floor))) sizing_mult *= floor + score_rank_pct * (1.0 - floor) if params.score_sizing_multiplier is not None and params.score_sizing_multiplier > 1.0: sizing_mult *= 1.0 + score_rank_pct * (params.score_sizing_multiplier - 1.0) risk_dollars = cap * params.risk_per_trade_pct * sizing_mult # When fixed dollar stop is set, use it as the per-share risk for sizing effective_stop_for_sizing = fixed_loss_per_share if fixed_loss_per_share is not None else stop_distance shares_from_risk = risk_dollars / effective_stop_for_sizing max_shares_by_capital = (cap * params.max_position_pct) / entry_price_raw # GFV / cash account constraint: cannot deploy more than available settled cash. # Unsettled proceeds can buy but not same-day sell; since ORB always exits same day, # only settled cash is usable for new positions. if available_cash is not None: if available_cash <= 0: return _none("cash_unavailable") max_shares_by_cash = available_cash / entry_price_raw max_shares_by_capital = min(max_shares_by_capital, max_shares_by_cash) shares = int(min(shares_from_risk, max_shares_by_capital)) # whole shares only if shares <= 0: return _none("zero_shares") entry_price_filled = ( _apply_slippage_entry(entry_price_raw, slippage) if direction == "long" else _apply_slippage_exit(entry_price_raw, slippage) ) entry_ts = _parse_ts(entry_bar["timestamp"]) # Same-bar stop: breakout AND stop both triggered within the same bar. # Only apply for 5-min bars (group_size == 1). For 30-min (or larger) bars, # we skip same-bar stop detection — the user only checks every N minutes, # so the stop is evaluated at the NEXT bar's open, not within the entry bar. # Also skip when entry_on_bar_close — trader enters at bar close, not exposed to intra-bar action. if same_bar_stop_allowed and direction == "long" and entry_bar["low"] <= initial_stop: exit_price_raw = initial_stop exit_price = _apply_slippage_exit(exit_price_raw, slippage) pnl_pct = (exit_price - entry_price_filled) / entry_price_filled pnl = pnl_pct * (shares * entry_price_filled) slippage_cost = ( abs(entry_price_filled - entry_price_raw) * shares + abs(exit_price - exit_price_raw) * shares ) return IntradayTrade( date=date_str, ticker=ticker, entry_price=round(entry_price_filled, 4), exit_price=round(exit_price, 4), entry_time=entry_bar["timestamp"], exit_time=entry_bar["timestamp"], shares=round(shares, 4), pnl=round(pnl, 4), pnl_pct=round(pnl_pct, 6), exit_reason="stop_loss", morning_gain_pct=round(gap_pct, 6), slippage_cost=round(slippage_cost, 4), orb_direction=direction, rvol=round(rvol, 3), atr_at_entry=round(atr, 4), r_multiple_at_exit=-1.0, stop_level_at_exit="initial", trigger_type=trigger_type, total_capital_deployed=round(shares * entry_price_filled, 4), ) if same_bar_stop_allowed and direction == "short" and entry_bar["high"] >= initial_stop: exit_price_raw = initial_stop exit_price = _apply_slippage_entry(exit_price_raw, slippage) pnl_pct = (entry_price_filled - exit_price) / entry_price_filled pnl = pnl_pct * (shares * entry_price_filled) slippage_cost = ( abs(entry_price_filled - entry_price_raw) * shares + abs(exit_price - exit_price_raw) * shares ) return IntradayTrade( date=date_str, ticker=ticker, entry_price=round(entry_price_filled, 4), exit_price=round(exit_price, 4), entry_time=entry_bar["timestamp"], exit_time=entry_bar["timestamp"], shares=round(shares, 4), pnl=round(pnl, 4), pnl_pct=round(pnl_pct, 6), exit_reason="stop_loss", morning_gain_pct=round(gap_pct, 6), slippage_cost=round(slippage_cost, 4), orb_direction=direction, rvol=round(rvol, 3), atr_at_entry=round(atr, 4), r_multiple_at_exit=-1.0, stop_level_at_exit="initial", trigger_type=trigger_type, total_capital_deployed=round(shares * entry_price_filled, 4), ) # --- Phase 2: Manage position --- current_stop = initial_stop trailing_active = False # Fixed exit price levels — per share (e.g. +$2/share profit, -$1/share stop) fixed_pt_price: float | None = None fixed_sl_price: float | None = None if direction == "long": if params.fixed_profit_dollars is not None: fixed_pt_price = entry_price_raw + params.fixed_profit_dollars if fixed_loss_per_share is not None: fixed_sl_price = entry_price_raw - fixed_loss_per_share else: if params.fixed_profit_dollars is not None: fixed_pt_price = entry_price_raw - params.fixed_profit_dollars if fixed_loss_per_share is not None: fixed_sl_price = entry_price_raw + fixed_loss_per_share swing_low_window: deque[float] = deque(maxlen=3) peak_price = entry_price_raw # tracks running high (long) or low (short) for ATR trailing exit_price_raw = entry_price_raw exit_time_str = entry_bar["timestamp"] exit_reason = "close" final_r = 0.0 stop_level = "initial" # 'initial' | 'breakeven' | 'trailing' — for diagnostics use_atr_trail = params.trailing_stop_atr_multiplier > 0 # VWAP exit setup use_vwap_exit = params.vwap_exit_mode in ("exit", "floor") vwap_exit_buffer = atr * params.vwap_exit_buffer_atr # Max hold time exit max_hold_exit_ts: dt.datetime | None = None if params.max_hold_minutes is not None: max_hold_exit_ts = entry_ts + dt.timedelta(minutes=params.max_hold_minutes) # Conditional time stop: unlike max_hold, only exits stalled trades that # have not reached enough favorable excursion. time_stop_exit_ts: dt.datetime | None = None if params.time_stop_minutes is not None: time_stop_exit_ts = entry_ts + dt.timedelta(minutes=params.time_stop_minutes) early_failure_exit_ts: dt.datetime | None = None early_failure_trigger_types = getattr( params, "early_failure_exit_trigger_types", None, ) early_failure_enabled = params.early_failure_exit_minutes is not None if early_failure_enabled and early_failure_trigger_types is not None: early_failure_enabled = trigger_type in {str(t) for t in early_failure_trigger_types} if early_failure_enabled: early_failure_exit_ts = entry_ts + dt.timedelta( minutes=max(0, int(params.early_failure_exit_minutes or 0)) ) def _early_failure_support_level(ts: dt.datetime) -> float | None: level_mode = str( getattr(params, "early_failure_exit_level", "breakout") or "breakout" ).lower() if level_mode == "entry": return entry_price_raw if level_mode == "vwap": return _compute_running_vwap(mkt_bars, ts) return breakout_level # Gap fill: track previous close for emergency exit use_gap_fill_exit = params.exit_on_gap_fill and prev_close is not None and prev_close > 0 # Track peak R for VWAP activation threshold peak_r = 0.0 conviction_runner_trail_active = False if ( getattr(params, "conviction_runner_trail_tighten_at_r", None) is not None or getattr(params, "conviction_runner_trail_gap_atr_multiplier", None) is not None ): conviction_runner_trail_active = True allowed_runner_triggers = getattr( params, "conviction_runner_trail_allowed_trigger_types", None, ) if allowed_runner_triggers is not None: conviction_runner_trail_active = trigger_type in { str(t) for t in allowed_runner_triggers } min_runner_gap = getattr(params, "conviction_runner_trail_min_abs_gap_pct", None) if ( conviction_runner_trail_active and min_runner_gap is not None and abs(float(gap_pct)) < float(min_runner_gap) ): conviction_runner_trail_active = False min_runner_score = getattr( params, "conviction_runner_trail_min_candidate_score", None, ) if conviction_runner_trail_active and min_runner_score is not None: if candidate_score is None or float(candidate_score) < float(min_runner_score): conviction_runner_trail_active = False min_runner_rank = getattr( params, "conviction_runner_trail_min_score_rank_pct", None, ) if ( conviction_runner_trail_active and min_runner_rank is not None and score_rank_pct < float(min_runner_rank) ): conviction_runner_trail_active = False effective_trailing_tighten_at_r = params.trailing_tighten_at_r effective_gap_trail_wide_threshold = params.gap_trail_wide_threshold effective_gap_trail_wide_atr_multiplier = params.gap_trail_wide_atr_multiplier if conviction_runner_trail_active: runner_tighten_at_r = getattr( params, "conviction_runner_trail_tighten_at_r", None, ) if runner_tighten_at_r is not None: effective_trailing_tighten_at_r = float(runner_tighten_at_r) runner_gap_atr = getattr( params, "conviction_runner_trail_gap_atr_multiplier", None, ) if runner_gap_atr is not None: effective_gap_trail_wide_atr_multiplier = float(runner_gap_atr) min_runner_gap = getattr( params, "conviction_runner_trail_min_abs_gap_pct", None, ) if effective_gap_trail_wide_threshold is None and min_runner_gap is not None: effective_gap_trail_wide_threshold = float(min_runner_gap) # Time-decay trailing: precompute decay schedule use_time_decay = ( params.time_decay_start_minutes is not None and use_atr_trail ) if use_time_decay: decay_start_ts = market_open + dt.timedelta(minutes=params.time_decay_start_minutes) decay_end_ts = exit_target # decay completes at exit time decay_span = (decay_end_ts - decay_start_ts).total_seconds() else: decay_start_ts = decay_end_ts = None decay_span = 0.0 # SPY intraday guard: precompute SPY open price for intraday comparison spy_guard_active = ( params.spy_intraday_guard_pct is not None and spy_bars is not None and len(spy_bars) > 0 ) spy_open = 0.0 if spy_guard_active: spy_open = spy_bars[0].get("open", 0.0) if spy_bars else 0.0 # Partial exit state (Option B: blended single trade result) original_shares = shares remaining_shares = shares partial_exited = False partial_pnl = 0.0 partial_exit_r_val: float | None = None partial_exit_slippage = 0.0 # Pyramid state: track add-on legs separately for PnL pyramid_count = 0 pyramid_legs: list[tuple[int, float, float]] = [] # (shares, entry_filled, entry_raw) pyramid_total_shares = 0 pyramid_allowed_triggers = ( set(params.pyramid_allowed_trigger_types) if params.pyramid_allowed_trigger_types is not None else None ) pyramid_setup_allowed = params.pyramid_at_r is not None and pyramid_allowed if pyramid_setup_allowed and pyramid_allowed_triggers is not None: pyramid_setup_allowed = trigger_type in pyramid_allowed_triggers if ( pyramid_setup_allowed and params.pyramid_min_score_rank_pct is not None and score_rank_pct < params.pyramid_min_score_rank_pct ): pyramid_setup_allowed = False if ( pyramid_setup_allowed and params.pyramid_min_rvol is not None and rvol < params.pyramid_min_rvol ): pyramid_setup_allowed = False if ( pyramid_setup_allowed and params.pyramid_max_rvol is not None and rvol > params.pyramid_max_rvol ): pyramid_setup_allowed = False def _cap_pyramid_add_shares(desired_shares: int, add_entry_price: float) -> int: if desired_shares <= 0: return 0 if available_cash is None: return desired_shares deployed_so_far = original_shares * entry_price_filled + sum( s * e for s, e, _ in pyramid_legs ) cash_left = available_cash - deployed_so_far if cash_left <= 0 or add_entry_price <= 0: return 0 return min(desired_shares, int(cash_left / add_entry_price)) for b in mkt_bars: ts = _parse_ts(b["timestamp"]) if ts <= entry_ts: continue bar_open = b["open"] bar_high = b["high"] bar_low = b["low"] bar_close = b["close"] if direction == "long": # ── Step 0: Fixed exits can either replace or coexist with trailing. ── if ( fixed_sl_price is not None and not fixed_loss_preserve_trailing and bar_low <= fixed_sl_price ): exit_price_raw = bar_open if bar_open <= fixed_sl_price else fixed_sl_price exit_time_str = b["timestamp"] exit_reason = "stop_loss" final_r = (exit_price_raw - entry_price_raw) / stop_distance break if fixed_pt_price is not None and bar_high >= fixed_pt_price: exit_price_raw = fixed_pt_price exit_time_str = b["timestamp"] exit_reason = "profit_target" final_r = (exit_price_raw - entry_price_raw) / stop_distance break # ── Step 1: Stop check FIRST (broker stop order model) ── # Check against PREVIOUS bar's stop level. If bar_low touched # the stop at any point, the broker fills the stop order. if (fixed_sl_price is None or fixed_loss_preserve_trailing) and bar_low <= current_stop: # Gap-through: bar opened below stop → fill at bar_open (worse) # Normal: price crossed stop during bar → fill at stop level exit_price_raw = bar_open if bar_open <= current_stop else current_stop exit_time_str = b["timestamp"] exit_reason = "trailing_stop" if trailing_active else "stop_loss" final_r = (exit_price_raw - entry_price_raw) / stop_distance break # ── Step 1b: Gap fill protection ── if use_gap_fill_exit and bar_close < prev_close: exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "gap_fill" final_r = (exit_price_raw - entry_price_raw) / stop_distance break # ── Step 1c: Max hold time exit ── if max_hold_exit_ts is not None and ts >= max_hold_exit_ts: exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "max_hold" final_r = (exit_price_raw - entry_price_raw) / stop_distance break # ── Step 2: Update peak using actual bar high ── peak_price = max(peak_price, bar_high) # ── Step 3: R-multiple from close (trader sees close to decide adjustments) ── current_r = (bar_close - entry_price_raw) / stop_distance peak_r = max(peak_r, current_r) # ── Step 3a: Early structural failure exit ── if early_failure_exit_ts is not None and ts <= early_failure_exit_ts: max_peak_r = getattr(params, "early_failure_exit_max_peak_r", None) if max_peak_r is None or peak_r <= float(max_peak_r): support_level = _early_failure_support_level(ts) if support_level is not None and support_level > 0: buffer = max( 0.0, float( getattr( params, "early_failure_exit_buffer_pct", 0.0, ) or 0.0 ), ) if bar_close < support_level * (1.0 - buffer): exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "early_failure" final_r = current_r break # ── Step 3b: Conditional time stop ── if ( time_stop_exit_ts is not None and ts >= time_stop_exit_ts and current_r <= params.time_stop_exit_below_r and ( params.time_stop_max_peak_r is None or peak_r <= params.time_stop_max_peak_r ) ): exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "time_stop" final_r = current_r break # ── Step 3c: VWAP exit check ── if use_vwap_exit and peak_r >= params.vwap_exit_after_r: running_vwap = _compute_running_vwap(mkt_bars, ts) if running_vwap is not None: vwap_level = running_vwap - vwap_exit_buffer if params.vwap_exit_mode == "exit" and bar_close < vwap_level: exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "vwap_exit" final_r = current_r break elif params.vwap_exit_mode == "floor" and trailing_active: # VWAP as trailing stop floor if vwap_level > current_stop: current_stop = vwap_level # ── Step 3d: Profit target exit ── if params.profit_target_r is not None and current_r >= params.profit_target_r: exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "profit_target" final_r = current_r break # Partial exit: lock in profits at configured R-multiple if ( params.partial_exit_at_r is not None and not partial_exited and current_r >= params.partial_exit_at_r ): p_shares = int(original_shares * params.partial_exit_pct) if p_shares > 0 and p_shares < remaining_shares: p_exit_raw = bar_close p_exit = _apply_slippage_exit(p_exit_raw, slippage) partial_pnl = (p_exit - entry_price_filled) * p_shares partial_exit_slippage = abs(p_exit - p_exit_raw) * p_shares remaining_shares -= p_shares partial_exited = True partial_exit_r_val = current_r # Protect remainder: move stop to breakeven if not already if current_stop < entry_price_raw: current_stop = entry_price_raw stop_level = "breakeven" # Pyramiding: add to winning position at configured R-multiple if ( pyramid_setup_allowed and pyramid_count < params.pyramid_max_adds and current_r >= params.pyramid_at_r * (1 + pyramid_count) ): add_shares = int(original_shares * params.pyramid_add_pct) if add_shares > 0: p_entry_raw = bar_close p_entry_filled = _apply_slippage_entry(p_entry_raw, slippage) add_shares = _cap_pyramid_add_shares(add_shares, p_entry_filled) if add_shares > 0: pyramid_legs.append((add_shares, p_entry_filled, p_entry_raw)) pyramid_total_shares += add_shares pyramid_count += 1 # Move stop to breakeven at configured R-multiple if current_r >= effective_breakeven_at_r and current_stop < entry_price_raw: current_stop = entry_price_raw stop_level = "breakeven" # Activate trailing stop at configured R-multiple if current_r >= params.trailing_at_r: trailing_active = True stop_level = "trailing" # ── Step 4: Update trailing stop for NEXT bar ── if trailing_active: if use_atr_trail: # Two-stage trailing: wider trail initially, tightens at a higher R atr_mult = params.trailing_stop_atr_multiplier # Gap-adaptive trailing: override base multiplier based on gap size if effective_gap_trail_wide_threshold is not None: if abs(gap_pct) > effective_gap_trail_wide_threshold: atr_mult = effective_gap_trail_wide_atr_multiplier elif params.gap_trail_tight_atr_multiplier is not None: atr_mult = params.gap_trail_tight_atr_multiplier if ( effective_trailing_tighten_at_r is not None and current_r >= effective_trailing_tighten_at_r and params.trailing_stop_atr_multiplier_tight > 0 ): atr_mult = params.trailing_stop_atr_multiplier_tight # Time-decay: linearly shrink trail width toward close if use_time_decay and ts >= decay_start_ts and decay_span > 0: elapsed = min((ts - decay_start_ts).total_seconds(), decay_span) decay_pct = elapsed / decay_span # 0 → 1 atr_mult *= 1.0 - decay_pct * (1.0 - params.time_decay_factor) # SPY intraday guard: tighten trail when SPY drops from open if spy_guard_active and spy_bars: spy_bar = next( (sb for sb in spy_bars if sb.get("timestamp") == b.get("timestamp")), None, ) if spy_bar is not None and spy_open > 0: spy_change = (spy_bar["close"] - spy_open) / spy_open if spy_change < params.spy_intraday_guard_pct: atr_mult *= params.spy_intraday_guard_tighten candidate_stop = peak_price - atr * atr_mult else: swing_low_window.append(bar_close) candidate_stop = max(swing_low_window) if candidate_stop > current_stop: current_stop = candidate_stop else: # short # ── Step 0: Fixed exits can either replace or coexist with trailing. ── if ( fixed_sl_price is not None and not fixed_loss_preserve_trailing and bar_high >= fixed_sl_price ): exit_price_raw = bar_open if bar_open >= fixed_sl_price else fixed_sl_price exit_time_str = b["timestamp"] exit_reason = "stop_loss" final_r = (entry_price_raw - exit_price_raw) / stop_distance break if fixed_pt_price is not None and bar_low <= fixed_pt_price: exit_price_raw = fixed_pt_price exit_time_str = b["timestamp"] exit_reason = "profit_target" final_r = (entry_price_raw - exit_price_raw) / stop_distance break # ── Step 1: Stop check FIRST ── if (fixed_sl_price is None or fixed_loss_preserve_trailing) and bar_high >= current_stop: exit_price_raw = bar_open if bar_open >= current_stop else current_stop exit_time_str = b["timestamp"] exit_reason = "trailing_stop" if trailing_active else "stop_loss" final_r = (entry_price_raw - exit_price_raw) / stop_distance break # ── Step 1b: Gap fill protection (short: price rises above prev_close) ── if use_gap_fill_exit and bar_close > prev_close: exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "gap_fill" final_r = (entry_price_raw - exit_price_raw) / stop_distance break # ── Step 1c: Max hold time exit ── if max_hold_exit_ts is not None and ts >= max_hold_exit_ts: exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "max_hold" final_r = (entry_price_raw - exit_price_raw) / stop_distance break # ── Step 2: Update trough using actual bar low ── peak_price = min(peak_price, bar_low) # ── Step 3: R-multiple from close ── current_r = (entry_price_raw - bar_close) / stop_distance peak_r = max(peak_r, current_r) if early_failure_exit_ts is not None and ts <= early_failure_exit_ts: max_peak_r = getattr(params, "early_failure_exit_max_peak_r", None) if max_peak_r is None or peak_r <= float(max_peak_r): support_level = _early_failure_support_level(ts) if support_level is not None and support_level > 0: buffer = max( 0.0, float( getattr( params, "early_failure_exit_buffer_pct", 0.0, ) or 0.0 ), ) if bar_close > support_level * (1.0 + buffer): exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "early_failure" final_r = current_r break if ( time_stop_exit_ts is not None and ts >= time_stop_exit_ts and current_r <= params.time_stop_exit_below_r and ( params.time_stop_max_peak_r is None or peak_r <= params.time_stop_max_peak_r ) ): exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "time_stop" final_r = current_r break # ── Step 3b: VWAP exit check (short) ── if use_vwap_exit and peak_r >= params.vwap_exit_after_r: running_vwap = _compute_running_vwap(mkt_bars, ts) if running_vwap is not None: vwap_level = running_vwap + vwap_exit_buffer if params.vwap_exit_mode == "exit" and bar_close > vwap_level: exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "vwap_exit" final_r = current_r break elif params.vwap_exit_mode == "floor" and trailing_active: if vwap_level < current_stop: current_stop = vwap_level # ── Step 3c: Profit target exit (short) ── if params.profit_target_r is not None and current_r >= params.profit_target_r: exit_price_raw = bar_close exit_time_str = b["timestamp"] exit_reason = "profit_target" final_r = current_r break # Partial exit (short) if ( params.partial_exit_at_r is not None and not partial_exited and current_r >= params.partial_exit_at_r ): p_shares = int(original_shares * params.partial_exit_pct) if p_shares > 0 and p_shares < remaining_shares: p_exit_raw = bar_close p_exit = _apply_slippage_entry(p_exit_raw, slippage) partial_pnl = (entry_price_filled - p_exit) * p_shares partial_exit_slippage = abs(p_exit - p_exit_raw) * p_shares remaining_shares -= p_shares partial_exited = True partial_exit_r_val = current_r if current_stop > entry_price_raw: current_stop = entry_price_raw stop_level = "breakeven" # Pyramiding (short): add to winning position if ( pyramid_setup_allowed and pyramid_count < params.pyramid_max_adds and current_r >= params.pyramid_at_r * (1 + pyramid_count) ): add_shares = int(original_shares * params.pyramid_add_pct) if add_shares > 0: p_entry_raw = bar_close p_entry_filled = _apply_slippage_exit(p_entry_raw, slippage) add_shares = _cap_pyramid_add_shares(add_shares, p_entry_filled) if add_shares > 0: pyramid_legs.append((add_shares, p_entry_filled, p_entry_raw)) pyramid_total_shares += add_shares pyramid_count += 1 if current_r >= effective_breakeven_at_r and current_stop > entry_price_raw: current_stop = entry_price_raw stop_level = "breakeven" if current_r >= params.trailing_at_r: trailing_active = True stop_level = "trailing" # ── Step 4: Update trailing stop for NEXT bar ── if trailing_active: if use_atr_trail: atr_mult = params.trailing_stop_atr_multiplier # Gap-adaptive trailing: override base multiplier based on gap size if effective_gap_trail_wide_threshold is not None: if abs(gap_pct) > effective_gap_trail_wide_threshold: atr_mult = effective_gap_trail_wide_atr_multiplier elif params.gap_trail_tight_atr_multiplier is not None: atr_mult = params.gap_trail_tight_atr_multiplier if ( effective_trailing_tighten_at_r is not None and current_r >= effective_trailing_tighten_at_r and params.trailing_stop_atr_multiplier_tight > 0 ): atr_mult = params.trailing_stop_atr_multiplier_tight # Time-decay: linearly shrink trail width toward close if use_time_decay and ts >= decay_start_ts and decay_span > 0: elapsed = min((ts - decay_start_ts).total_seconds(), decay_span) decay_pct = elapsed / decay_span atr_mult *= 1.0 - decay_pct * (1.0 - params.time_decay_factor) # SPY intraday guard (short): tighten trail when SPY rallies from open if spy_guard_active and spy_bars: spy_bar = next( (sb for sb in spy_bars if sb.get("timestamp") == b.get("timestamp")), None, ) if spy_bar is not None and spy_open > 0: spy_change = (spy_bar["close"] - spy_open) / spy_open if spy_change > abs(params.spy_intraday_guard_pct): atr_mult *= params.spy_intraday_guard_tighten candidate_stop = peak_price + atr * atr_mult else: swing_low_window.append(bar_close) candidate_stop = min(swing_low_window) if candidate_stop < current_stop: current_stop = candidate_stop # Time exit if ts >= exit_target: exit_price_raw = b["close"] exit_time_str = b["timestamp"] exit_reason = "close" if direction == "long": final_r = (exit_price_raw - entry_price_raw) / stop_distance else: final_r = (entry_price_raw - exit_price_raw) / stop_distance break # Running exit (last bar before exit time) exit_price_raw = b["close"] exit_time_str = b["timestamp"] if direction == "long": final_r = (exit_price_raw - entry_price_raw) / stop_distance else: final_r = (entry_price_raw - exit_price_raw) / stop_distance # Apply slippage to exit (on remaining original shares + pyramid shares) exit_price = ( _apply_slippage_exit(exit_price_raw, slippage) if direction == "long" else _apply_slippage_entry(exit_price_raw, slippage) ) if direction == "long": remainder_pnl_pct = (exit_price - entry_price_filled) / entry_price_filled else: remainder_pnl_pct = (entry_price_filled - exit_price) / entry_price_filled # Blended PnL: partial exit + final exit on remaining original shares remainder_pnl = remainder_pnl_pct * (remaining_shares * entry_price_filled) pnl = remainder_pnl + partial_pnl # Pyramid PnL: add-on legs exit at the same price as the main position pyr_pnl = 0.0 pyr_entry_slippage = 0.0 if pyramid_legs: for p_shares, p_entry_filled, p_entry_raw in pyramid_legs: if direction == "long": pyr_pnl += (exit_price - p_entry_filled) * p_shares else: pyr_pnl += (p_entry_filled - exit_price) * p_shares pyr_entry_slippage += abs(p_entry_filled - p_entry_raw) * p_shares pnl += pyr_pnl # pnl_pct as return on total deployed capital (original + pyramid) total_deployed_cost = original_shares * entry_price_filled + sum( s * e for s, e, _ in pyramid_legs ) pnl_pct = pnl / total_deployed_cost if total_deployed_cost > 0 else 0.0 entry_slippage = abs(entry_price_filled - entry_price_raw) * original_shares exit_slippage = abs(exit_price - exit_price_raw) * (remaining_shares + pyramid_total_shares) slippage_cost = entry_slippage + exit_slippage + partial_exit_slippage + pyr_entry_slippage return IntradayTrade( date=date_str, ticker=ticker, entry_price=round(entry_price_filled, 4), exit_price=round(exit_price, 4), entry_time=entry_bar["timestamp"], exit_time=exit_time_str, shares=round(original_shares, 4), pnl=round(pnl, 4), pnl_pct=round(pnl_pct, 6), exit_reason=exit_reason, morning_gain_pct=round(gap_pct, 6), # reuse field for gap% slippage_cost=round(slippage_cost, 4), orb_direction=direction, rvol=round(rvol, 3), atr_at_entry=round(atr, 4), r_multiple_at_exit=round(final_r, 3), stop_level_at_exit=stop_level, partial_exit_r=round(partial_exit_r_val, 3) if partial_exit_r_val is not None else None, pyramid_adds=pyramid_count, pyramid_pnl=round(pyr_pnl, 4), total_capital_deployed=round(total_deployed_cost, 4), trigger_type=trigger_type, ) def _optional_float(value: object, ndigits: int | None = None) -> float | None: if value is None: return None try: result = float(value) except (TypeError, ValueError): return None return round(result, ndigits) if ndigits is not None else result def _attach_orb_candidate_diagnostics( trade: IntradayTrade, cand: dict, ticker_enrich: dict, score_rank_pct: float, ) -> None: """Copy candidate-selection features onto filled trades for post-run analysis.""" trade.gap_pct = _optional_float(cand.get("gap_pct"), 6) trade.entry_dollar_volume = _optional_float(cand.get("first_bar_dollar_vol"), 2) trade.avg_dollar_vol_30d = _optional_float(ticker_enrich.get("avg_dollar_vol_30d"), 2) trade.entropy_20d = _optional_float(cand.get("entropy_20d"), 4) trade.ret_5d = _optional_float(ticker_enrich.get("ret_5d"), 6) trade.event_score = _optional_float(cand.get("event_score"), 4) trade.ownership_13dg_flag = bool(cand.get("ownership_13dg_flag")) trade.ownership_13dg_initial_flag = bool(cand.get("ownership_13dg_initial_flag")) trade.ownership_13dg_days_since = ( int(cand.get("ownership_13dg_days_since")) if cand.get("ownership_13dg_days_since") is not None else None ) trade.ownership_13dg_strength_score = _optional_float( cand.get("ownership_13dg_strength_score"), 4, ) trade.form4_flag = bool(cand.get("form4_flag")) trade.form4_days_since = ( int(cand.get("form4_days_since")) if cand.get("form4_days_since") is not None else None ) trade.form4_total_value = _optional_float(cand.get("form4_total_value"), 2) trade.form4_owner_count = ( int(cand.get("form4_owner_count")) if cand.get("form4_owner_count") is not None else None ) trade.form4_c_suite_count = ( int(cand.get("form4_c_suite_count")) if cand.get("form4_c_suite_count") is not None else None ) trade.candidate_score = _optional_float(cand.get("score"), 6) trade.score_rank_pct = _optional_float(score_rank_pct, 6) trade.premarket_dollar_vol = _optional_float(cand.get("premarket_dollar_vol"), 2) trade.first_bar_dollar_vol = _optional_float(cand.get("first_bar_dollar_vol"), 2) trade.volume_attention_rank_pct = _optional_float( cand.get("volume_attention_rank_pct"), 6, ) trade.volume_attention_global_rank_pct = _optional_float( cand.get("volume_attention_global_rank_pct"), 6, ) trade.volume_attention_sector_rank_pct = _optional_float( cand.get("volume_attention_sector_rank_pct"), 6, ) trade.volume_attention_price_rank_pct = _optional_float( cand.get("volume_attention_price_rank_pct"), 6, ) trade.opening_dollar_vol_rank_pct = _optional_float( cand.get("opening_dollar_vol_rank_pct"), 6, ) trade.rvol_rank_pct = _optional_float(cand.get("rvol_rank_pct"), 6) trade.premarket_dollar_vol_rank_pct = _optional_float( cand.get("premarket_dollar_vol_rank_pct"), 6, ) trade.body_ratio = _optional_float(cand.get("body_ratio"), 6) trade.close_location = _optional_float(cand.get("close_location"), 6) trade.gap_zscore_20d = _optional_float(cand.get("gap_zscore_20d"), 6) trade.obv_slope_20 = _optional_float(cand.get("obv_slope_20"), 6) trade.obv_slope_5 = _optional_float(cand.get("obv_slope_5"), 6) trade.orb_return = _optional_float(cand.get("orb_return"), 6) trade.crowded_gap_requires_confirmation = bool( cand.get("crowded_gap_requires_confirmation") ) trade.crowded_gap_size_scale = _optional_float(cand.get("crowded_gap_size_scale"), 4) trade.countertrend_gap_requires_confirmation = bool( cand.get("countertrend_gap_requires_confirmation") ) trade.countertrend_gap_size_scale = _optional_float( cand.get("countertrend_gap_size_scale"), 4 ) trade.distressed_reclaim_requires_confirmation = bool( cand.get("distressed_reclaim_requires_confirmation") ) trade.distressed_reclaim_size_scale = _optional_float( cand.get("distressed_reclaim_size_scale"), 4 ) trade.hot_reclaim_requires_confirmation = bool( cand.get("hot_reclaim_requires_confirmation") ) trade.hot_reclaim_size_scale = _optional_float(cand.get("hot_reclaim_size_scale"), 4) trade.weak_downside_reclaim_requires_confirmation = bool( cand.get("weak_downside_reclaim_requires_confirmation") ) trade.weak_downside_reclaim_size_scale = _optional_float( cand.get("weak_downside_reclaim_size_scale"), 4 ) trade.quiet_downside_reclaim_requires_confirmation = bool( cand.get("quiet_downside_reclaim_requires_confirmation") ) trade.quiet_downside_reclaim_size_scale = _optional_float( cand.get("quiet_downside_reclaim_size_scale"), 4 ) trade.stale_obv_reversal_requires_confirmation = bool( cand.get("stale_obv_reversal_requires_confirmation") ) trade.stale_obv_reversal_size_scale = _optional_float( cand.get("stale_obv_reversal_size_scale"), 4 ) trade.thin_gap_up_loss_cap_active = bool(cand.get("thin_gap_up_loss_cap_active")) trade.moderate_downside_loss_cap_active = bool( cand.get("moderate_downside_loss_cap_active") ) trade.stalled_gap_up_requires_confirmation = bool( cand.get("stalled_gap_up_requires_confirmation") ) trade.stalled_gap_up_size_scale = _optional_float( cand.get("stalled_gap_up_size_scale"), 4 ) trade.liquid_stalled_gap_up_requires_confirmation = bool( cand.get("liquid_stalled_gap_up_requires_confirmation") ) trade.liquid_stalled_gap_up_size_scale = _optional_float( cand.get("liquid_stalled_gap_up_size_scale"), 4 ) trade.sector_confirmation_active = bool(cand.get("sector_confirmation_active")) trade.sector_confirmation_member_count = ( int(cand.get("sector_confirmation_member_count") or 0) or None ) trade.sector_confirmation_avg_orb_return = _optional_float( cand.get("sector_confirmation_avg_orb_return"), 6 ) trade.sector_confirmation_total_first_bar_dollar_vol = _optional_float( cand.get("sector_confirmation_total_first_bar_dollar_vol"), 2 ) trade.sector_confirmation_score = _optional_float( cand.get("sector_confirmation_score"), 6 ) trade.red_to_green_reserved = bool(cand.get("red_to_green_reserved")) trade.candidate_seed_overlay = bool(cand.get("candidate_seed_overlay")) trade.candidate_seed_overlay_reserved = bool( cand.get("candidate_seed_overlay_reserved") ) trade.gap_up_fill_exit_active = bool(cand.get("gap_up_fill_exit_active")) def _float_or_none(value: object) -> float | None: if value is None: return None try: return float(value) except (TypeError, ValueError): return None def _orb_gap_exhaustion_pressure_size_scale( params: ORBStrategyParams, cand: dict, ticker_enrich: dict, direction_str: str, ) -> float: """Scale stretched positive gaps whose ORB candle does not close strongly.""" min_gap_zscore = getattr(params, "gap_exhaustion_pressure_min_gap_zscore", None) max_close_location = getattr( params, "gap_exhaustion_pressure_max_close_location", None, ) if direction_str != "long" or min_gap_zscore is None or max_close_location is None: return 1.0 gap_pct = _float_or_none(cand.get("gap_pct")) min_gap_pct = getattr(params, "gap_exhaustion_pressure_min_gap_pct", None) if min_gap_pct is not None and (gap_pct is None or gap_pct < float(min_gap_pct)): return 1.0 gap_zscore = _float_or_none( cand.get("gap_zscore_20d") if cand.get("gap_zscore_20d") is not None else ticker_enrich.get("gap_zscore_20d") ) close_location = _float_or_none(cand.get("close_location")) ret_5d = _float_or_none( cand.get("ret_5d") if cand.get("ret_5d") is not None else ticker_enrich.get("ret_5d") ) min_ret_5d = getattr(params, "gap_exhaustion_pressure_min_ret_5d", None) if ( gap_zscore is None or close_location is None or gap_zscore < float(min_gap_zscore) or close_location > float(max_close_location) or ( min_ret_5d is not None and (ret_5d is None or ret_5d < float(min_ret_5d)) ) ): return 1.0 raw_scale = getattr(params, "gap_exhaustion_pressure_size_scale", 1.0) return max( 0.0, min( 1.0, float(1.0 if raw_scale is None else raw_scale), ), ) def _orb_stale_obv_rvol_pressure_size_scale( params: ORBStrategyParams, cand: dict, ticker_enrich: dict, score_rank_pct: float, ) -> float: """Scale low-RVOL breakouts when the prior OBV trend is stale.""" max_rvol = getattr(params, "stale_obv_rvol_pressure_max_rvol", None) max_obv20 = getattr(params, "stale_obv_rvol_pressure_max_obv_slope_20d", None) if max_rvol is None or max_obv20 is None: return 1.0 rvol = _float_or_none(cand.get("rvol")) obv20 = _float_or_none( cand.get("obv_slope_20") if cand.get("obv_slope_20") is not None else ticker_enrich.get("obv_slope_20") ) ret_5d = _float_or_none( cand.get("ret_5d") if cand.get("ret_5d") is not None else ticker_enrich.get("ret_5d") ) max_ret_5d = getattr(params, "stale_obv_rvol_pressure_max_ret_5d", None) max_rank = getattr(params, "stale_obv_rvol_pressure_max_score_rank_pct", None) if ( rvol is None or obv20 is None or rvol > float(max_rvol) or obv20 > float(max_obv20) or ( max_ret_5d is not None and (ret_5d is None or ret_5d > float(max_ret_5d)) ) or (max_rank is not None and score_rank_pct > float(max_rank)) ): return 1.0 raw_scale = getattr(params, "stale_obv_rvol_pressure_size_scale", 1.0) return max( 0.0, min( 1.0, float(1.0 if raw_scale is None else raw_scale), ), ) def _orb_mid_attention_exhaustion_size_scale( params: ORBStrategyParams, cand: dict, *, trigger_type: str, direction_str: str, ) -> tuple[bool, float]: """Scale mid-liquidity names with elevated but non-extreme opening RVOL.""" raw_scale = getattr(params, "mid_attention_exhaustion_size_scale", 1.0) if raw_scale is None or float(raw_scale) >= 1.0: return False, 1.0 if direction_str != "long": return False, 1.0 allowed_triggers = getattr( params, "mid_attention_exhaustion_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return False, 1.0 min_rvol = getattr(params, "mid_attention_exhaustion_min_rvol", None) max_rvol = getattr(params, "mid_attention_exhaustion_max_rvol", None) min_pm = getattr( params, "mid_attention_exhaustion_min_premarket_dollar_vol", None, ) max_pm = getattr( params, "mid_attention_exhaustion_max_premarket_dollar_vol", None, ) if min_rvol is None or max_rvol is None or min_pm is None or max_pm is None: return False, 1.0 rvol = _float_or_none(cand.get("rvol")) pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol")) active = ( rvol is not None and pm_dollar_vol is not None and float(min_rvol) <= rvol <= float(max_rvol) and float(min_pm) <= pm_dollar_vol <= float(max_pm) ) if not active: return False, 1.0 return True, max(0.0, min(1.0, float(raw_scale))) def _orb_mid_liquidity_fragility_size_scale( params: ORBStrategyParams, cand: dict, ticker_enrich: dict, *, trigger_type: str, direction_str: str, ) -> tuple[bool, float]: """Scale fragile mid-liquidity setups without removing the trade slot.""" raw_scale = getattr(params, "mid_liquidity_fragility_size_scale", 1.0) if raw_scale is None or float(raw_scale) >= 1.0: return False, 1.0 if direction_str != "long": return False, 1.0 allowed_triggers = getattr( params, "mid_liquidity_fragility_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return False, 1.0 pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol")) rvol = _float_or_none(cand.get("rvol")) body_ratio = _float_or_none(cand.get("body_ratio")) ret_5d = _float_or_none( cand.get("ret_5d") if cand.get("ret_5d") is not None else ticker_enrich.get("ret_5d") ) if pm_dollar_vol is None: return False, 1.0 thin_min_pm = getattr( params, "mid_liquidity_fragility_thin_min_premarket_dollar_vol", None, ) thin_max_pm = getattr( params, "mid_liquidity_fragility_thin_max_premarket_dollar_vol", None, ) thin_active = False if thin_min_pm is not None and thin_max_pm is not None: thin_profile = float(thin_min_pm) <= pm_dollar_vol <= float(thin_max_pm) if thin_profile: thin_clauses: list[bool] = [] thin_max_rvol = getattr( params, "mid_liquidity_fragility_thin_max_rvol", None, ) if thin_max_rvol is not None: thin_clauses.append(rvol is not None and rvol <= float(thin_max_rvol)) thin_min_ret = getattr( params, "mid_liquidity_fragility_thin_min_ret_5d", None, ) thin_max_ret = getattr( params, "mid_liquidity_fragility_thin_max_ret_5d", None, ) if thin_min_ret is not None and thin_max_ret is not None: thin_clauses.append( ret_5d is not None and float(thin_min_ret) <= ret_5d <= float(thin_max_ret) ) thin_min_body = getattr( params, "mid_liquidity_fragility_thin_min_body_ratio", None, ) if thin_min_body is not None: thin_clauses.append( body_ratio is not None and body_ratio >= float(thin_min_body) ) thin_active = any(thin_clauses) mid_min_pm = getattr( params, "mid_liquidity_fragility_mid_min_premarket_dollar_vol", None, ) mid_max_pm = getattr( params, "mid_liquidity_fragility_mid_max_premarket_dollar_vol", None, ) mid_min_body = getattr( params, "mid_liquidity_fragility_mid_min_body_ratio", None, ) mid_max_body = getattr( params, "mid_liquidity_fragility_mid_max_body_ratio", None, ) mid_active = ( mid_min_pm is not None and mid_max_pm is not None and mid_min_body is not None and mid_max_body is not None and body_ratio is not None and float(mid_min_pm) <= pm_dollar_vol <= float(mid_max_pm) and float(mid_min_body) <= body_ratio <= float(mid_max_body) ) if not (thin_active or mid_active): return False, 1.0 return True, max(0.0, min(1.0, float(raw_scale))) def _orb_orphan_thin_attention_size_scale( params: ORBStrategyParams, cand: dict, *, trigger_type: str, direction_str: str, ) -> tuple[bool, float]: """Scale thin ORB candidates whose attention is not sector-confirmed.""" raw_scale = getattr(params, "orphan_thin_attention_size_scale", 1.0) max_pm = getattr( params, "orphan_thin_attention_max_premarket_dollar_vol", None, ) if raw_scale is None or float(raw_scale) >= 1.0 or max_pm is None: return False, 1.0 if direction_str != "long": return False, 1.0 allowed_triggers = getattr( params, "orphan_thin_attention_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return False, 1.0 if bool(cand.get("sector_confirmation_active")): return False, 1.0 pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol")) if pm_dollar_vol is None or pm_dollar_vol > float(max_pm): return False, 1.0 return True, max(0.0, min(1.0, float(raw_scale))) def _orb_gap_up_fill_trap_size_scale( params: ORBStrategyParams, cand: dict, *, trigger_type: str, direction_str: str, ) -> tuple[bool, float]: """Scale positive-gap candidates where gap-fill exit is not enough.""" raw_scale = getattr(params, "gap_up_fill_trap_size_scale", 1.0) max_orb_return = getattr(params, "gap_up_fill_trap_max_orb_return", None) if raw_scale is None or float(raw_scale) >= 1.0 or max_orb_return is None: return False, 1.0 if direction_str != "long": return False, 1.0 allowed_triggers = getattr( params, "gap_up_fill_trap_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return False, 1.0 if not bool(cand.get("gap_up_fill_exit_active")): return False, 1.0 orb_return = _float_or_none(cand.get("orb_return")) if orb_return is None or orb_return > float(max_orb_return): return False, 1.0 return True, max(0.0, min(1.0, float(raw_scale))) def _orb_low_candidate_quality_size_scale( params: ORBStrategyParams, cand: dict, *, trigger_type: str, direction_str: str, ) -> tuple[bool, float]: """Scale entries whose composite cross-sectional score is too weak.""" raw_scale = getattr(params, "low_candidate_quality_size_scale", 1.0) max_score = getattr(params, "low_candidate_quality_max_score", None) if raw_scale is None or float(raw_scale) >= 1.0 or max_score is None: return False, 1.0 if direction_str != "long": return False, 1.0 allowed_triggers = getattr( params, "low_candidate_quality_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return False, 1.0 score = _float_or_none(cand.get("score")) if score is None or score > float(max_score): return False, 1.0 return True, max(0.0, min(1.0, float(raw_scale))) def _orb_unboosted_primary_fragility_size_scale( params: ORBStrategyParams, cand: dict, ticker_enrich: dict, *, trigger_type: str, direction_str: str, high_conviction_size_scales: tuple[float, ...], ) -> float: """Scale fragile primary ORB setups only when no high-conviction booster fired.""" raw_scale = getattr(params, "unboosted_primary_fragility_size_scale", 1.0) if raw_scale is None or float(raw_scale) >= 1.0: return 1.0 if direction_str != "long": return 1.0 if any(scale > 1.0 for scale in high_conviction_size_scales): return 1.0 allowed_triggers = getattr( params, "unboosted_primary_fragility_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return 1.0 gap_pct = _float_or_none(cand.get("gap_pct")) pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol")) close_location = _float_or_none(cand.get("close_location")) rvol = _float_or_none(cand.get("rvol")) ret_5d = _float_or_none( cand.get("ret_5d") if cand.get("ret_5d") is not None else ticker_enrich.get("ret_5d") ) crowded_active = False crowded_min_gap = getattr( params, "unboosted_primary_fragility_crowded_min_gap_pct", None, ) crowded_min_pm = getattr( params, "unboosted_primary_fragility_crowded_min_premarket_dollar_vol", None, ) crowded_max_close = getattr( params, "unboosted_primary_fragility_crowded_max_close_location", None, ) if ( crowded_min_gap is not None and crowded_min_pm is not None and crowded_max_close is not None ): crowded_active = ( gap_pct is not None and gap_pct >= float(crowded_min_gap) and pm_dollar_vol is not None and pm_dollar_vol >= float(crowded_min_pm) and close_location is not None and close_location <= float(crowded_max_close) ) weak_attention_active = False weak_max_rvol = getattr( params, "unboosted_primary_fragility_weak_max_rvol", None, ) weak_max_ret = getattr( params, "unboosted_primary_fragility_weak_max_ret_5d", None, ) if weak_max_rvol is not None and weak_max_ret is not None: weak_attention_active = ( rvol is not None and rvol <= float(weak_max_rvol) and ret_5d is not None and ret_5d <= float(weak_max_ret) ) if not (crowded_active or weak_attention_active): return 1.0 return max(0.0, min(1.0, float(raw_scale))) def _orb_unsupported_attention_size_scale( params: ORBStrategyParams, cand: dict, *, trigger_type: str, direction_str: str, high_conviction_size_scales: tuple[float, ...], ) -> float: """Scale attention-heavy setups that lack sector/leader confirmation.""" raw_scale = getattr(params, "unsupported_attention_size_scale", 1.0) if raw_scale is None or float(raw_scale) >= 1.0: return 1.0 if direction_str != "long": return 1.0 allowed_triggers = getattr( params, "unsupported_attention_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return 1.0 if ( getattr(params, "unsupported_attention_ignore_high_conviction", True) and any(scale > 1.0 for scale in high_conviction_size_scales) ): return 1.0 score = _float_or_none(cand.get("score")) pm_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol")) gap_pct = _float_or_none(cand.get("gap_pct")) rvol = _float_or_none(cand.get("rvol")) sector_confirmed = bool(cand.get("sector_confirmation_active")) liquid_active = False liquid_min_pm = getattr( params, "unsupported_attention_liquid_min_premarket_dollar_vol", None, ) liquid_max_score = getattr( params, "unsupported_attention_liquid_max_candidate_score", None, ) if liquid_min_pm is not None and liquid_max_score is not None: liquid_active = ( pm_dollar_vol is not None and pm_dollar_vol >= float(liquid_min_pm) and score is not None and score <= float(liquid_max_score) and ( not getattr( params, "unsupported_attention_liquid_require_no_sector_confirmation", True, ) or not sector_confirmed ) ) thin_active = False thin_max_pm = getattr( params, "unsupported_attention_thin_max_premarket_dollar_vol", None, ) thin_min_gap = getattr(params, "unsupported_attention_thin_min_gap_pct", None) thin_min_rvol = getattr(params, "unsupported_attention_thin_min_rvol", None) if thin_max_pm is not None and thin_min_gap is not None and thin_min_rvol is not None: first_bar_dollar_vol = _float_or_none(cand.get("first_bar_dollar_vol")) thin_max_first_bar = getattr( params, "unsupported_attention_thin_max_first_bar_dollar_vol", None, ) thin_active = ( pm_dollar_vol is not None and pm_dollar_vol <= float(thin_max_pm) and gap_pct is not None and gap_pct >= float(thin_min_gap) and rvol is not None and rvol >= float(thin_min_rvol) and ( thin_max_first_bar is None or ( first_bar_dollar_vol is not None and first_bar_dollar_vol <= float(thin_max_first_bar) ) ) ) thin_max_rvol = getattr(params, "unsupported_attention_thin_max_rvol", None) if thin_active and thin_max_rvol is not None: thin_active = rvol <= float(thin_max_rvol) if not (liquid_active or thin_active): return 1.0 return max(0.0, min(1.0, float(raw_scale))) def _orb_positive_gap_rebound_failure_size_scale( params: ORBStrategyParams, cand: dict, ticker_enrich: dict, *, trigger_type: str, direction_str: str, ) -> float: """Scale failed positive-gap rebounds after a sharp prior selloff.""" raw_scale = getattr(params, "positive_gap_rebound_failure_size_scale", 1.0) if raw_scale is None or float(raw_scale) >= 1.0: return 1.0 if direction_str != "long": return 1.0 allowed_triggers = getattr( params, "positive_gap_rebound_failure_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return 1.0 min_gap = getattr(params, "positive_gap_rebound_failure_min_gap_pct", None) max_ret_5d = getattr(params, "positive_gap_rebound_failure_max_ret_5d", None) max_orb_return = getattr(params, "positive_gap_rebound_failure_max_orb_return", None) min_attention = getattr( params, "positive_gap_rebound_failure_min_volume_attention_rank_pct", None, ) if ( min_gap is None or max_ret_5d is None or max_orb_return is None or min_attention is None ): return 1.0 gap_pct = _float_or_none(cand.get("gap_pct")) ret_5d = _float_or_none( cand.get("ret_5d") if cand.get("ret_5d") is not None else ticker_enrich.get("ret_5d") ) orb_return = _float_or_none(cand.get("orb_return")) attention_rank = _float_or_none(cand.get("volume_attention_rank_pct")) if ( gap_pct is None or ret_5d is None or orb_return is None or attention_rank is None ): return 1.0 active = ( gap_pct >= float(min_gap) and ret_5d <= float(max_ret_5d) and orb_return <= float(max_orb_return) and attention_rank >= float(min_attention) ) if not active: return 1.0 return max(0.0, min(1.0, float(raw_scale))) def _orb_red_to_green_acceleration_size_scale( params: ORBStrategyParams, cand: dict, ticker_enrich: dict, score_rank_pct: float, direction_str: str, risk_size_scales: tuple[float, ...], ) -> float: """Boost only clean, strong downside-gap reclaim breakouts.""" raw_scale = getattr(params, "red_to_green_acceleration_size_scale", 1.0) if raw_scale is None or float(raw_scale) <= 1.0: return 1.0 if direction_str != "long": return 1.0 if getattr(params, "red_to_green_acceleration_ignore_scaled_risk_overlays", True): if any(scale < 1.0 for scale in risk_size_scales): return 1.0 min_abs_gap = getattr(params, "red_to_green_acceleration_min_abs_gap_pct", None) if min_abs_gap is not None: gap_pct = cand.get("gap_pct") if gap_pct is None or float(gap_pct) > -float(min_abs_gap): return 1.0 min_orb_return = getattr(params, "red_to_green_acceleration_min_orb_return", None) if min_orb_return is not None: orb_return = cand.get("orb_return") if orb_return is None or float(orb_return) < float(min_orb_return): return 1.0 min_score_rank = getattr( params, "red_to_green_acceleration_min_score_rank_pct", None ) if min_score_rank is not None and score_rank_pct < float(min_score_rank): return 1.0 min_candidate_score = getattr( params, "red_to_green_acceleration_min_candidate_score", None ) if min_candidate_score is not None: if float(cand.get("score") or 0.0) < float(min_candidate_score): return 1.0 min_pm_dollar_vol = getattr( params, "red_to_green_acceleration_min_premarket_dollar_vol", None ) if min_pm_dollar_vol is not None: pm_dollar_vol = cand.get("premarket_dollar_vol") if pm_dollar_vol is None or float(pm_dollar_vol) < float(min_pm_dollar_vol): return 1.0 return max(1.0, min(3.0, float(raw_scale))) def _orb_liquid_leader_conviction_size_scale( params: ORBStrategyParams, cand: dict, score_rank_pct: float, trigger_type: str, direction_str: str, risk_size_scales: tuple[float, ...], ) -> float: """Boost only liquid, top-ranked leaders that were not defensively scaled.""" raw_scale = getattr(params, "liquid_leader_conviction_size_scale", 1.0) if raw_scale is None or float(raw_scale) <= 1.0: return 1.0 if direction_str != "long": return 1.0 allowed_triggers = getattr( params, "liquid_leader_conviction_allowed_trigger_types", None, ) if allowed_triggers is not None and trigger_type not in set(allowed_triggers): return 1.0 if ( trigger_type != "orb" and bool( getattr( params, "liquid_leader_conviction_secondary_requires_sector_confirmation", False, ) ) and not bool(cand.get("sector_confirmation_active")) ): return 1.0 secondary_min_body = getattr( params, "liquid_leader_conviction_secondary_min_body_ratio", None, ) if trigger_type != "orb" and secondary_min_body is not None: body_ratio = cand.get("body_ratio") if body_ratio is None or float(body_ratio) < float(secondary_min_body): return 1.0 if getattr(params, "liquid_leader_conviction_ignore_scaled_risk_overlays", True): if any(scale < 1.0 for scale in risk_size_scales): return 1.0 min_candidate_score = getattr( params, "liquid_leader_conviction_min_candidate_score", None, ) if min_candidate_score is not None: if float(cand.get("score") or 0.0) < float(min_candidate_score): return 1.0 min_score_rank = getattr( params, "liquid_leader_conviction_min_score_rank_pct", None, ) if min_score_rank is not None and score_rank_pct < float(min_score_rank): return 1.0 min_pm_dollar_vol = getattr( params, "liquid_leader_conviction_min_premarket_dollar_vol", None, ) if min_pm_dollar_vol is not None: pm_dollar_vol = cand.get("premarket_dollar_vol") if pm_dollar_vol is None or float(pm_dollar_vol) < float(min_pm_dollar_vol): return 1.0 min_rvol = getattr(params, "liquid_leader_conviction_min_rvol", None) if min_rvol is not None: rvol = cand.get("rvol") if rvol is None or float(rvol) < float(min_rvol): return 1.0 min_abs_gap = getattr(params, "liquid_leader_conviction_min_abs_gap_pct", None) if min_abs_gap is not None: gap_pct = cand.get("gap_pct") if gap_pct is None or abs(float(gap_pct)) < float(min_abs_gap): return 1.0 return max(1.0, min(3.0, float(raw_scale))) def _orb_ownership_initial_size_scale( params: ORBStrategyParams, cand: dict, score_rank_pct: float, trigger_type: str, direction_str: str, risk_size_scales: tuple[float, ...], ) -> float: """Boost only PIT-safe initial-owner 13D/13G names that are already clean ORB setups.""" raw_scale = getattr(params, "ownership_initial_size_scale", 1.0) if raw_scale is None or float(raw_scale) <= 1.0: return 1.0 if direction_str != "long": return 1.0 if not bool(cand.get("ownership_13dg_initial_flag")): return 1.0 allowed_triggers = getattr(params, "ownership_initial_allowed_trigger_types", None) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return 1.0 min_score_rank = getattr(params, "ownership_initial_min_score_rank_pct", None) if min_score_rank is not None and score_rank_pct < float(min_score_rank): return 1.0 if getattr(params, "ownership_initial_ignore_scaled_risk_overlays", True): if any(scale < 1.0 for scale in risk_size_scales): return 1.0 return max(1.0, min(2.0, float(raw_scale))) def _orb_form4_size_scale( params: ORBStrategyParams, cand: dict, trigger_type: str, direction_str: str, risk_size_scales: tuple[float, ...], ) -> float: """Boost clean ORB setups with recent PIT-safe Form 4 insider-buy support.""" raw_scale = getattr(params, "form4_size_scale", 1.0) if raw_scale is None or float(raw_scale) <= 1.0: return 1.0 if direction_str != "long": return 1.0 if not bool(cand.get("form4_flag")): return 1.0 allowed_triggers = getattr(params, "form4_allowed_trigger_types", None) if allowed_triggers is not None and trigger_type not in {str(t) for t in allowed_triggers}: return 1.0 if getattr(params, "form4_ignore_scaled_risk_overlays", True): if any(scale < 1.0 for scale in risk_size_scales): return 1.0 min_total = getattr(params, "form4_min_total_value", None) if min_total is not None: total_value = cand.get("form4_total_value") if total_value is None or float(total_value) < float(min_total): return 1.0 min_owner_count = getattr(params, "form4_min_owner_count", None) min_c_suite_count = getattr(params, "form4_min_c_suite_count", None) owner_count = int(cand.get("form4_owner_count") or 0) c_suite_count = int(cand.get("form4_c_suite_count") or 0) if bool(getattr(params, "form4_require_cluster_or_csuite", False)): cluster_ok = ( min_owner_count is not None and owner_count >= int(min_owner_count) ) csuite_ok = ( min_c_suite_count is not None and c_suite_count >= int(min_c_suite_count) ) if not (cluster_ok or csuite_ok): return 1.0 else: if min_owner_count is not None and owner_count < int(min_owner_count): return 1.0 if min_c_suite_count is not None and c_suite_count < int(min_c_suite_count): return 1.0 return max(1.0, min(2.0, float(raw_scale))) def _orb_opening_burst_liquid_size_scale( params: ORBStrategyParams, cand: dict, *, date_str: str, entry_ts: dt.datetime, trigger_type: str, score_rank_pct: float, direction_str: str, risk_size_scales: tuple[float, ...], ) -> float: """Boost liquid leaders only when the actual entry happens in the opening burst.""" raw_scale = getattr(params, "opening_burst_liquid_size_scale", 1.0) if raw_scale is None or float(raw_scale) <= 1.0: return 1.0 if direction_str != "long": return 1.0 allowed_triggers = getattr(params, "opening_burst_liquid_allowed_trigger_types", None) if allowed_triggers is not None and trigger_type not in set(allowed_triggers): return 1.0 max_minutes = getattr( params, "opening_burst_liquid_max_entry_minutes_after_open", None, ) if max_minutes is None: return 1.0 market_open = _market_open_ts(date_str) entry_minutes = (entry_ts.astimezone(_ET) - market_open).total_seconds() / 60.0 if entry_minutes < 0 or entry_minutes > float(max_minutes): return 1.0 min_pm_dollar_vol = getattr( params, "opening_burst_liquid_min_premarket_dollar_vol", None, ) if min_pm_dollar_vol is not None: pm_dollar_vol = cand.get("premarket_dollar_vol") if pm_dollar_vol is None or float(pm_dollar_vol) < float(min_pm_dollar_vol): return 1.0 min_gap_pct = getattr(params, "opening_burst_liquid_min_gap_pct", None) if min_gap_pct is not None: gap_pct = cand.get("gap_pct") if gap_pct is None or float(gap_pct) < float(min_gap_pct): return 1.0 min_score_rank = getattr( params, "opening_burst_liquid_min_score_rank_pct", None, ) if min_score_rank is not None and score_rank_pct < float(min_score_rank): return 1.0 min_candidate_score = getattr( params, "opening_burst_liquid_min_candidate_score", None, ) if min_candidate_score is not None: if float(cand.get("score") or 0.0) < float(min_candidate_score): return 1.0 if getattr(params, "opening_burst_liquid_ignore_scaled_risk_overlays", True): if any(scale < 1.0 for scale in risk_size_scales): return 1.0 return max(1.0, min(3.0, float(raw_scale))) def _orb_soft_day_setup_profile_allows( params: ORBStrategyParams, soft_day_reason: str | None, cand: dict, ticker_enrich: dict, ) -> bool: """Named soft-day setup filters for former no-trade days.""" profile = str(getattr(params, "soft_day_setup_profile", "none") or "none") if profile in {"", "none"}: return True if profile != "skip_day_reclaim_v1": return True reason = soft_day_reason or "" if reason == "market_regime": return False score = _float_or_none(cand.get("score")) or 0.0 ret_5d = _float_or_none(cand.get("ret_5d", ticker_enrich.get("ret_5d"))) rvol = _float_or_none(cand.get("rvol")) gap_pct = _float_or_none(cand.get("gap_pct")) premarket_dollar_vol = _float_or_none(cand.get("premarket_dollar_vol")) or 0.0 body_ratio = _float_or_none(cand.get("body_ratio")) or 0.0 close_location = _float_or_none(cand.get("close_location")) or 0.0 if ret_5d is None or rvol is None or gap_pct is None: return False # Preserve the original high-conviction fallback path, but not for # market-regime-only days where the wide probe was net-negative. if score >= 1.0015 and ret_5d >= 0.03 and rvol <= 30.0: return True if reason == "breadth": if ( gap_pct < 0.0 and premarket_dollar_vol >= 20_000_000 and rvol <= 30.0 and ( ret_5d >= 0.15 or (0.03 <= ret_5d <= 0.08 and close_location <= 0.20) ) ): return True if ( gap_pct > 0.0 and premarket_dollar_vol >= 5_000_000 and rvol <= 30.0 and ret_5d >= 0.03 and body_ratio >= 0.70 and close_location >= 0.90 ): return True return False if reason == "market_regime+breadth": if ( gap_pct > 0.0 and 20_000_000 <= premarket_dollar_vol <= 1_200_000_000 and rvol <= 30.0 and ret_5d >= 0.20 and body_ratio >= 0.60 and close_location >= 0.65 ): return True if ( -0.10 <= gap_pct < -0.02 and premarket_dollar_vol >= 10_000_000 and rvol <= 30.0 and 0.05 <= ret_5d <= 0.60 and close_location <= 0.10 ): return True return False return False def _soft_day_reason_parts(soft_day_reason: str | None) -> set[str]: return { part.strip() for part in str(soft_day_reason or "").split("+") if part.strip() } def _orb_soft_day_vwap_reason_allowed( params: ORBStrategyParams, soft_day_reason: str | None, ) -> bool: """Return whether the auxiliary VWAP sleeve is allowed for this soft-day reason.""" raw_allowed = getattr(params, "soft_day_vwap_reclaim_allowed_reason_parts", None) if not raw_allowed: return True allowed = {str(part).strip() for part in raw_allowed if str(part).strip()} if not allowed: return True return bool(_soft_day_reason_parts(soft_day_reason) & allowed) def _orb_soft_day_vwap_min_score_pct( params: ORBStrategyParams, soft_day_reason: str | None, ) -> float | None: """Reason-specific score floor for the soft-day VWAP auxiliary sleeve.""" parts = _soft_day_reason_parts(soft_day_reason) override = None if "hard_breadth" in parts: override = getattr( params, "soft_day_vwap_reclaim_min_score_pct_hard_breadth", None, ) elif "market_regime" in parts and "breadth" in parts: override = getattr(params, "soft_day_vwap_reclaim_min_score_pct_joint", None) elif "market_regime" in parts: override = getattr( params, "soft_day_vwap_reclaim_min_score_pct_market_regime", None, ) elif "breadth" in parts: override = getattr(params, "soft_day_vwap_reclaim_min_score_pct_breadth", None) if override is not None: return float(override) base = getattr(params, "soft_day_vwap_reclaim_min_score_pct", None) return None if base is None else float(base) def _orb_soft_day_vwap_reason_param( params: ORBStrategyParams, soft_day_reason: str | None, base_name: str, ) -> float | None: """Return a reason-specific soft-day VWAP gate value when configured.""" parts = _soft_day_reason_parts(soft_day_reason) suffix = None if "hard_breadth" in parts: suffix = "hard_breadth" elif "market_regime" in parts and "breadth" in parts: suffix = "joint" elif "market_regime" in parts: suffix = "market_regime" elif "breadth" in parts: suffix = "breadth" if suffix: override = getattr(params, f"{base_name}_{suffix}", None) if override is not None: return float(override) base = getattr(params, base_name, None) return None if base is None else float(base) def _orb_soft_day_vwap_size_scale( params: ORBStrategyParams, soft_day_reason: str | None, ) -> float: """Reason-specific size scale for the soft-day VWAP auxiliary sleeve.""" parts = _soft_day_reason_parts(soft_day_reason) override = None if "hard_breadth" in parts: override = getattr( params, "soft_day_vwap_reclaim_size_scale_hard_breadth", None, ) elif "market_regime" in parts and "breadth" in parts: override = getattr(params, "soft_day_vwap_reclaim_size_scale_joint", None) elif "market_regime" in parts: override = getattr( params, "soft_day_vwap_reclaim_size_scale_market_regime", None, ) elif "breadth" in parts: override = getattr(params, "soft_day_vwap_reclaim_size_scale_breadth", None) raw = ( getattr(params, "soft_day_vwap_reclaim_size_scale", 0.0) if override is None else override ) return min(1.0, max(0.0, float(raw or 0.0))) # ── Day Simulation ───────────────────────────────────────────────────────── def simulate_orb_day( bars_by_ticker: dict[str, list[dict]], date_str: str, params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], equity: float, blacklisted_tickers: set[str] | None = None, spy_bars: list[dict] | None = None, ticker_sectors: dict[str, str] | None = None, available_cash: float | None = None, sizing_capital: float | None = None, vix_value: float | None = None, overlay_tickers: set[str] | None = None, ) -> DayResult: """Simulate one full trading day using the ORB strategy. 1. VIX regime check — skip high-VIX days (max_vix) 2. SPY regime check (daily gap from enrichment) — skip bad market days 3. compute_orb_candidates — filter and rank candidates 4. If fewer than min_candidates_to_trade → skip day 5. For each candidate: simulate_orb_trade (with VIX size scaling) 6. Apply daily loss limit and max-stops kill switch Args: bars_by_ticker: {ticker: [bars]} for this day. date_str: Trading date 'YYYY-MM-DD'. params: ORB strategy parameters. enrichment: Pre-computed features from enrich_daily_bars(). equity: Current portfolio equity (for risk-based sizing). blacklisted_tickers: Tickers in cooldown. spy_bars: SPY bars (unused — regime check now uses enrichment). vix_value: Previous close VIX for this trading day (None if unavailable). Returns: DayResult compatible with compute_metrics(). """ result = DayResult(date=date_str) # VIX regime check: skip the entire day if VIX is too high. # vix_value is the prior close VIX (lookahead-free). if params.max_vix is not None and vix_value is not None: if vix_value > params.max_vix: result.skip_reason = "vix_gate" return result # skip high-VIX days # VIX position size scaler (applied to sizing_capital later) vix_scaler = _orb_vix_size_scaler(vix_value, params) # Market regime check: index ETF daily gap (lookahead-free via enrichment) regime_scaler = 1.0 regime_gap_pct = None soft_day_reason_parts: list[str] = [] if params.market_regime_spy_threshold is not None or params.regime_size_scale_low is not None: regime_ticker = getattr(params, "market_regime_ticker", None) or "SPY" regime_enrich = enrichment.get(regime_ticker, {}).get(date_str, {}) regime_prev_close = regime_enrich.get("prev_close") regime_today_open = _regime_today_open( regime_enrich, bars_by_ticker.get(regime_ticker), date_str, ) if regime_prev_close and regime_today_open and regime_prev_close > 0: regime_gap = (regime_today_open - regime_prev_close) / regime_prev_close regime_gap_pct = regime_gap # Hard skip floor (V20 param or V19 legacy threshold) if params.regime_skip_below is not None and regime_gap < params.regime_skip_below: result.skip_reason = "market_regime" return result if (params.regime_size_scale_low is None and params.market_regime_spy_threshold is not None and regime_gap < params.market_regime_spy_threshold): if getattr(params, "soft_day_fallback_on_regime_skip", False): regime_scaler = max( 0.0, float(getattr(params, "soft_day_regime_skip_size_scale", 1.0) or 0.0), ) soft_day_reason_parts.append("market_regime") else: result.skip_reason = "market_regime" return result # Soft scaler (V20 path) if params.regime_size_scale_low is not None and params.regime_size_scale_high is not None: regime_scaler = _linear_scaler( regime_gap, params.regime_size_scale_low, params.regime_size_scale_high, params.regime_size_scale_min, invert=True, ) # Candidate breadth filter breadth_scaler = 1.0 breadth_ratio = None breadth_positive_count = None breadth_total_count = None hard_breadth_fallback_active = False min_breadth = getattr(params, "min_candidate_breadth", None) if min_breadth is not None or params.breadth_size_scale_low is not None: pos_gap_count = 0 total_with_data = 0 for ticker in bars_by_ticker: t_enrich = enrichment.get(ticker, {}).get(date_str, {}) prev_c = t_enrich.get("prev_close") today_o = _regime_today_open(t_enrich, bars_by_ticker.get(ticker), date_str) if prev_c and today_o and prev_c > 0: total_with_data += 1 if today_o > prev_c: pos_gap_count += 1 if total_with_data > 0: breadth_ratio = pos_gap_count / total_with_data breadth_positive_count = pos_gap_count breadth_total_count = total_with_data if params.breadth_skip_below is not None and breadth_ratio < params.breadth_skip_below: hard_fallback_min = getattr( params, "hard_breadth_soft_fallback_min_breadth", None ) if ( getattr(params, "hard_breadth_soft_fallback_enabled", False) and ( hard_fallback_min is None or breadth_ratio >= float(hard_fallback_min) ) ): breadth_scaler = max( 0.0, float( getattr( params, "hard_breadth_soft_fallback_size_scale", 0.03, ) or 0.0 ), ) soft_day_reason_parts.append("hard_breadth") hard_breadth_fallback_active = True else: result.skip_reason = "breadth" return result if (params.breadth_size_scale_low is None and min_breadth is not None and breadth_ratio < min_breadth and not hard_breadth_fallback_active): if getattr(params, "soft_day_fallback_on_breadth_skip", False): breadth_scaler = max( 0.0, float(getattr(params, "soft_day_breadth_skip_size_scale", 1.0) or 0.0), ) soft_day_reason_parts.append("breadth") else: result.skip_reason = "breadth" return result if params.breadth_size_scale_low is not None and params.breadth_size_scale_high is not None: breadth_scaler = _linear_scaler( breadth_ratio, params.breadth_size_scale_low, params.breadth_size_scale_high, params.breadth_size_scale_min, invert=True, ) market_orb_quality_scaler = 1.0 market_orb_quality_close_location = None market_orb_quality_return_pct = None market_orb_quality_secondary_close_location = None market_orb_quality_secondary_return_pct = None market_thrust_breadth_override_active = False market_thrust_index_breadth_override_active = False market_thrust_opening_breadth_override_active = False market_thrust_opening_breadth_positive_ratio = None market_thrust_opening_breadth_avg_return_pct = None market_thrust_opening_breadth_strong_close_location_ratio = None market_thrust_opening_breadth_total_count = None market_orb_quality_divergence_active = False market_orb_quality_divergence_max_trades_active = False market_orb_quality_primary_weak_secondary_strong_active = False market_orb_quality_primary_weak_secondary_strong_max_trades_active = False market_orb_quality_primary_lag_secondary_lead_active = False market_orb_quality_primary_lag_secondary_lead_max_trades_active = False market_orb_quality_joint_weak_active = False market_orb_quality_joint_weak_max_trades_active = False market_orb_quality_joint_panic_active = False market_orb_quality_joint_panic_max_trades_active = False market_orb_quality_divergence_max_trades = None market_orb_quality_joint_weak_condition_met = False market_orb_quality_joint_weak_require_confirmation = False market_orb_quality_joint_panic_condition_met = False market_orb_quality_joint_panic_require_confirmation = False quality_ticker = ( getattr(params, "market_orb_quality_ticker", None) or getattr(params, "market_regime_ticker", None) or "SPY" ) secondary_quality_ticker = getattr(params, "market_orb_quality_secondary_ticker", None) use_market_orb_quality = any( getattr(params, key, None) is not None for key in ( "market_orb_quality_size_scale_low", "market_orb_quality_size_scale_high", "market_orb_quality_primary_strong_above", "market_orb_quality_secondary_weak_above", "market_orb_quality_secondary_weak_below", "market_orb_quality_divergence_scale", "market_orb_quality_primary_weak_below", "market_orb_quality_primary_weak_above", "market_orb_quality_secondary_strong_above", "market_orb_quality_primary_weak_secondary_strong_scale", "market_orb_quality_primary_lag_above", "market_orb_quality_primary_lag_below", "market_orb_quality_secondary_lead_above", "market_orb_quality_secondary_lead_below", "market_orb_quality_primary_lag_secondary_lead_scale", "market_orb_quality_joint_weak_primary_below", "market_orb_quality_joint_weak_primary_above", "market_orb_quality_joint_weak_secondary_below", "market_orb_quality_joint_weak_secondary_above", "market_orb_quality_joint_weak_scale", "market_orb_quality_joint_panic_primary_below", "market_orb_quality_joint_panic_secondary_below", "market_orb_quality_joint_panic_scale", "market_thrust_breadth_override_min_primary_close_location", "market_thrust_breadth_override_min_secondary_close_location", "market_thrust_breadth_override_min_primary_return_pct", "market_thrust_breadth_override_min_secondary_return_pct", ) ) or bool(getattr(params, "market_thrust_breadth_override_enabled", False)) or bool( getattr(params, "market_thrust_opening_breadth_override_enabled", False) ) if use_market_orb_quality: quality_bar = _first_regular_bar( bars_by_ticker.get(quality_ticker, []), _market_open_ts(date_str), ) market_orb_quality_close_location = _bar_close_location(quality_bar) market_orb_quality_return_pct = _bar_return_pct(quality_bar) if ( getattr(params, "market_orb_quality_size_scale_low", None) is not None and getattr(params, "market_orb_quality_size_scale_high", None) is not None ): market_orb_quality_scaler = _linear_range_scaler( market_orb_quality_close_location, getattr(params, "market_orb_quality_size_scale_low", None), getattr(params, "market_orb_quality_size_scale_high", None), getattr(params, "market_orb_quality_size_scale_min", 1.0), getattr(params, "market_orb_quality_size_scale_max", 1.0), ) if secondary_quality_ticker: secondary_bar = _first_regular_bar( bars_by_ticker.get(secondary_quality_ticker, []), _market_open_ts(date_str), ) market_orb_quality_secondary_close_location = _bar_close_location(secondary_bar) market_orb_quality_secondary_return_pct = _bar_return_pct(secondary_bar) primary_strong_above = getattr(params, "market_orb_quality_primary_strong_above", None) secondary_weak_below = getattr(params, "market_orb_quality_secondary_weak_below", None) secondary_weak_above = getattr(params, "market_orb_quality_secondary_weak_above", None) divergence_scale = getattr(params, "market_orb_quality_divergence_scale", None) primary_weak_below = getattr(params, "market_orb_quality_primary_weak_below", None) primary_weak_above = getattr(params, "market_orb_quality_primary_weak_above", None) secondary_strong_above = getattr(params, "market_orb_quality_secondary_strong_above", None) primary_weak_secondary_strong_scale = getattr( params, "market_orb_quality_primary_weak_secondary_strong_scale", None ) primary_lag_above = getattr(params, "market_orb_quality_primary_lag_above", None) primary_lag_below = getattr(params, "market_orb_quality_primary_lag_below", None) secondary_lead_above = getattr(params, "market_orb_quality_secondary_lead_above", None) secondary_lead_below = getattr(params, "market_orb_quality_secondary_lead_below", None) primary_lag_secondary_lead_scale = getattr( params, "market_orb_quality_primary_lag_secondary_lead_scale", None ) joint_weak_primary_below = getattr( params, "market_orb_quality_joint_weak_primary_below", None ) joint_weak_primary_above = getattr( params, "market_orb_quality_joint_weak_primary_above", None ) joint_weak_secondary_below = getattr( params, "market_orb_quality_joint_weak_secondary_below", None ) joint_weak_secondary_above = getattr( params, "market_orb_quality_joint_weak_secondary_above", None ) joint_panic_primary_below = getattr( params, "market_orb_quality_joint_panic_primary_below", None ) joint_panic_secondary_below = getattr( params, "market_orb_quality_joint_panic_secondary_below", None ) divergence_condition_met = ( primary_strong_above is not None and secondary_weak_below is not None and market_orb_quality_close_location is not None and market_orb_quality_secondary_close_location is not None and market_orb_quality_close_location >= primary_strong_above and market_orb_quality_secondary_close_location <= secondary_weak_below and ( secondary_weak_above is None or market_orb_quality_secondary_close_location >= secondary_weak_above ) ) if ( divergence_scale is not None and divergence_condition_met ): market_orb_quality_scaler *= max(0.0, divergence_scale) market_orb_quality_divergence_active = True divergence_max_trades = getattr( params, "market_orb_quality_divergence_max_trades", None ) if ( divergence_max_trades is not None and divergence_condition_met ): market_orb_quality_divergence_max_trades = max( 0, int(divergence_max_trades) ) market_orb_quality_divergence_max_trades_active = True primary_weak_secondary_strong_condition_met = ( primary_weak_below is not None and secondary_strong_above is not None and market_orb_quality_close_location is not None and market_orb_quality_secondary_close_location is not None and market_orb_quality_close_location <= primary_weak_below and ( primary_weak_above is None or market_orb_quality_close_location >= primary_weak_above ) and market_orb_quality_secondary_close_location >= secondary_strong_above ) if ( primary_weak_secondary_strong_scale is not None and primary_weak_secondary_strong_condition_met ): market_orb_quality_scaler *= max(0.0, primary_weak_secondary_strong_scale) market_orb_quality_primary_weak_secondary_strong_active = True primary_weak_secondary_strong_max_trades = getattr( params, "market_orb_quality_primary_weak_secondary_strong_max_trades", None ) if ( primary_weak_secondary_strong_max_trades is not None and primary_weak_secondary_strong_condition_met ): primary_weak_secondary_strong_max_trades = max( 0, int(primary_weak_secondary_strong_max_trades) ) if market_orb_quality_divergence_max_trades is None: market_orb_quality_divergence_max_trades = primary_weak_secondary_strong_max_trades else: market_orb_quality_divergence_max_trades = min( market_orb_quality_divergence_max_trades, primary_weak_secondary_strong_max_trades, ) market_orb_quality_primary_weak_secondary_strong_active = True market_orb_quality_primary_weak_secondary_strong_max_trades_active = True primary_lag_secondary_lead_condition_met = ( primary_lag_below is not None and secondary_lead_above is not None and market_orb_quality_close_location is not None and market_orb_quality_secondary_close_location is not None and market_orb_quality_close_location <= primary_lag_below and ( primary_lag_above is None or market_orb_quality_close_location >= primary_lag_above ) and market_orb_quality_secondary_close_location >= secondary_lead_above and ( secondary_lead_below is None or market_orb_quality_secondary_close_location <= secondary_lead_below ) ) if ( primary_lag_secondary_lead_scale is not None and primary_lag_secondary_lead_condition_met ): market_orb_quality_scaler *= max(0.0, primary_lag_secondary_lead_scale) market_orb_quality_primary_lag_secondary_lead_active = True primary_lag_secondary_lead_max_trades = getattr( params, "market_orb_quality_primary_lag_secondary_lead_max_trades", None ) if ( primary_lag_secondary_lead_max_trades is not None and primary_lag_secondary_lead_condition_met ): primary_lag_secondary_lead_max_trades = max( 0, int(primary_lag_secondary_lead_max_trades) ) if market_orb_quality_divergence_max_trades is None: market_orb_quality_divergence_max_trades = primary_lag_secondary_lead_max_trades else: market_orb_quality_divergence_max_trades = min( market_orb_quality_divergence_max_trades, primary_lag_secondary_lead_max_trades, ) market_orb_quality_primary_lag_secondary_lead_active = True market_orb_quality_primary_lag_secondary_lead_max_trades_active = True market_orb_quality_joint_weak_condition_met = ( joint_weak_primary_below is not None and joint_weak_secondary_below is not None and market_orb_quality_close_location is not None and market_orb_quality_secondary_close_location is not None and market_orb_quality_close_location <= joint_weak_primary_below and ( joint_weak_primary_above is None or market_orb_quality_close_location >= joint_weak_primary_above ) and market_orb_quality_secondary_close_location <= joint_weak_secondary_below and ( joint_weak_secondary_above is None or market_orb_quality_secondary_close_location >= joint_weak_secondary_above ) ) if market_orb_quality_joint_weak_condition_met: joint_weak_scale = getattr(params, "market_orb_quality_joint_weak_scale", None) if joint_weak_scale is not None: market_orb_quality_scaler *= max(0.0, joint_weak_scale) market_orb_quality_joint_weak_active = True joint_weak_max_trades = getattr( params, "market_orb_quality_joint_weak_max_trades", None ) if joint_weak_max_trades is not None: joint_weak_max_trades = max(0, int(joint_weak_max_trades)) if market_orb_quality_divergence_max_trades is None: market_orb_quality_divergence_max_trades = joint_weak_max_trades else: market_orb_quality_divergence_max_trades = min( market_orb_quality_divergence_max_trades, joint_weak_max_trades, ) market_orb_quality_joint_weak_active = True market_orb_quality_joint_weak_max_trades_active = True if getattr(params, "market_orb_quality_joint_weak_require_confirmation", False): market_orb_quality_joint_weak_require_confirmation = True market_orb_quality_joint_weak_active = True market_orb_quality_joint_panic_condition_met = ( joint_panic_primary_below is not None and joint_panic_secondary_below is not None and market_orb_quality_close_location is not None and market_orb_quality_secondary_close_location is not None and market_orb_quality_close_location <= joint_panic_primary_below and market_orb_quality_secondary_close_location <= joint_panic_secondary_below ) if market_orb_quality_joint_panic_condition_met: joint_panic_scale = getattr(params, "market_orb_quality_joint_panic_scale", None) if joint_panic_scale is not None: market_orb_quality_scaler *= max(0.0, joint_panic_scale) market_orb_quality_joint_panic_active = True joint_panic_max_trades = getattr( params, "market_orb_quality_joint_panic_max_trades", None ) if joint_panic_max_trades is not None: joint_panic_max_trades = max(0, int(joint_panic_max_trades)) if market_orb_quality_divergence_max_trades is None: market_orb_quality_divergence_max_trades = joint_panic_max_trades else: market_orb_quality_divergence_max_trades = min( market_orb_quality_divergence_max_trades, joint_panic_max_trades, ) market_orb_quality_joint_panic_active = True market_orb_quality_joint_panic_max_trades_active = True if getattr(params, "market_orb_quality_joint_panic_require_confirmation", False): market_orb_quality_joint_panic_require_confirmation = True market_orb_quality_joint_panic_active = True def _meets_min(value: float | None, threshold: float | None) -> bool: return threshold is None or (value is not None and value >= threshold) if getattr(params, "market_thrust_opening_breadth_override_enabled", False): opening_breadth_stats = _opening_breadth_stats( bars_by_ticker, date_str, min_first_bar_dollar_vol=getattr( params, "market_thrust_opening_breadth_override_min_first_bar_dollar_vol", None, ), strong_close_location=float( getattr( params, "market_thrust_opening_breadth_override_strong_close_location", 0.65, ) or 0.65 ), ) market_thrust_opening_breadth_total_count = int( opening_breadth_stats.get("total_count") or 0 ) market_thrust_opening_breadth_positive_ratio = _float_or_none( opening_breadth_stats.get("positive_ratio") ) market_thrust_opening_breadth_avg_return_pct = _float_or_none( opening_breadth_stats.get("avg_return_pct") ) market_thrust_opening_breadth_strong_close_location_ratio = _float_or_none( opening_breadth_stats.get("strong_close_location_ratio") ) market_thrust_breadth_override_candidate = ( bool(getattr(params, "market_thrust_breadth_override_enabled", False)) and "breadth" in soft_day_reason_parts and "hard_breadth" not in soft_day_reason_parts and not ( market_orb_quality_divergence_active or market_orb_quality_primary_weak_secondary_strong_active or market_orb_quality_primary_lag_secondary_lead_active or market_orb_quality_joint_weak_active or market_orb_quality_joint_panic_active ) and _meets_min( market_orb_quality_close_location, getattr(params, "market_thrust_breadth_override_min_primary_close_location", None), ) and _meets_min( market_orb_quality_secondary_close_location, getattr(params, "market_thrust_breadth_override_min_secondary_close_location", None), ) and _meets_min( market_orb_quality_return_pct, getattr(params, "market_thrust_breadth_override_min_primary_return_pct", None), ) and _meets_min( market_orb_quality_secondary_return_pct, getattr(params, "market_thrust_breadth_override_min_secondary_return_pct", None), ) and _meets_min( regime_gap_pct, getattr(params, "market_thrust_breadth_override_min_regime_gap_pct", None), ) and _meets_min( breadth_ratio, getattr(params, "market_thrust_breadth_override_min_breadth_ratio", None), ) ) opening_breadth_reason_allowed = bool(soft_day_reason_parts) and ( ( "market_regime" in soft_day_reason_parts and bool( getattr( params, "market_thrust_opening_breadth_override_allow_regime_soft_day", True, ) ) ) or ( "breadth" in soft_day_reason_parts and bool( getattr( params, "market_thrust_opening_breadth_override_allow_breadth_soft_day", True, ) ) ) or ( "hard_breadth" in soft_day_reason_parts and bool( getattr( params, "market_thrust_opening_breadth_override_allow_hard_breadth", False, ) ) ) ) market_thrust_opening_breadth_override_candidate = ( bool(getattr(params, "market_thrust_opening_breadth_override_enabled", False)) and opening_breadth_reason_allowed and market_thrust_opening_breadth_total_count is not None and market_thrust_opening_breadth_total_count >= int( getattr( params, "market_thrust_opening_breadth_override_min_total_count", 100, ) or 0 ) and _meets_min( market_thrust_opening_breadth_positive_ratio, getattr( params, "market_thrust_opening_breadth_override_min_positive_ratio", None, ), ) and _meets_min( market_thrust_opening_breadth_avg_return_pct, getattr( params, "market_thrust_opening_breadth_override_min_avg_return_pct", None, ), ) and _meets_min( market_thrust_opening_breadth_strong_close_location_ratio, getattr( params, "market_thrust_opening_breadth_override_min_strong_close_location_ratio", None, ), ) ) if market_thrust_breadth_override_candidate: breadth_scaler = max( breadth_scaler, max( 0.0, float( getattr( params, "market_thrust_breadth_override_size_scale_floor", 1.0, ) or 0.0 ), ), ) market_thrust_index_breadth_override_active = True market_thrust_breadth_override_active = True if ( bool(getattr(params, "market_thrust_breadth_override_clear_soft_day", False)) and breadth_scaler >= params.soft_day_scaler_threshold ): soft_day_reason_parts = [ reason for reason in soft_day_reason_parts if reason != "breadth" ] if market_thrust_opening_breadth_override_candidate: regime_scaler = max( regime_scaler, max( 0.0, float( getattr( params, "market_thrust_opening_breadth_override_regime_size_scale_floor", 1.0, ) or 0.0 ), ), ) breadth_scaler = max( breadth_scaler, max( 0.0, float( getattr( params, "market_thrust_opening_breadth_override_breadth_size_scale_floor", 1.0, ) or 0.0 ), ), ) market_thrust_breadth_override_active = True market_thrust_opening_breadth_override_active = True if getattr( params, "market_thrust_opening_breadth_override_clear_soft_day", False, ): soft_day_reason_parts = [ reason for reason in soft_day_reason_parts if not ( ( reason == "market_regime" and getattr( params, "market_thrust_opening_breadth_override_allow_regime_soft_day", True, ) ) or ( reason == "breadth" and getattr( params, "market_thrust_opening_breadth_override_allow_breadth_soft_day", True, ) ) or ( reason == "hard_breadth" and getattr( params, "market_thrust_opening_breadth_override_allow_hard_breadth", False, ) ) ) ] combined_scaler = regime_scaler * breadth_scaler if ( soft_day_reason_parts and params.soft_day_combined_size_scale_floor is not None ): combined_scaler = max( combined_scaler, max(0.0, float(params.soft_day_combined_size_scale_floor)), ) is_soft_day = bool(soft_day_reason_parts) or combined_scaler < params.soft_day_scaler_threshold result.regime_scaler = regime_scaler result.regime_gap_pct = regime_gap_pct result.breadth_scaler = breadth_scaler result.breadth_ratio = breadth_ratio result.breadth_positive_count = breadth_positive_count result.breadth_total_count = breadth_total_count result.sparse_day_scaler = 1.0 result.market_orb_quality_scaler = market_orb_quality_scaler result.market_orb_quality_close_location = market_orb_quality_close_location result.market_orb_quality_return_pct = market_orb_quality_return_pct result.market_orb_quality_secondary_close_location = market_orb_quality_secondary_close_location result.market_orb_quality_secondary_return_pct = market_orb_quality_secondary_return_pct result.market_thrust_breadth_override_active = market_thrust_breadth_override_active result.market_thrust_opening_breadth_override_active = ( market_thrust_opening_breadth_override_active ) result.market_thrust_opening_breadth_positive_ratio = ( market_thrust_opening_breadth_positive_ratio ) result.market_thrust_opening_breadth_avg_return_pct = ( market_thrust_opening_breadth_avg_return_pct ) result.market_thrust_opening_breadth_strong_close_location_ratio = ( market_thrust_opening_breadth_strong_close_location_ratio ) result.market_thrust_opening_breadth_total_count = ( market_thrust_opening_breadth_total_count ) result.market_orb_quality_divergence_active = market_orb_quality_divergence_active result.market_orb_quality_divergence_max_trades_active = ( market_orb_quality_divergence_max_trades_active ) result.market_orb_quality_primary_weak_secondary_strong_active = ( market_orb_quality_primary_weak_secondary_strong_active ) result.market_orb_quality_primary_weak_secondary_strong_max_trades_active = ( market_orb_quality_primary_weak_secondary_strong_max_trades_active ) result.market_orb_quality_primary_lag_secondary_lead_active = ( market_orb_quality_primary_lag_secondary_lead_active ) result.market_orb_quality_primary_lag_secondary_lead_max_trades_active = ( market_orb_quality_primary_lag_secondary_lead_max_trades_active ) result.market_orb_quality_joint_weak_active = market_orb_quality_joint_weak_active result.market_orb_quality_joint_weak_max_trades_active = ( market_orb_quality_joint_weak_max_trades_active ) result.market_orb_quality_joint_panic_active = market_orb_quality_joint_panic_active result.market_orb_quality_joint_panic_max_trades_active = ( market_orb_quality_joint_panic_max_trades_active ) result.is_soft_day = is_soft_day result.soft_day_reason = "+".join(soft_day_reason_parts) if soft_day_reason_parts else None trade_params = params conditional_confirmation_active = False conditional_confirmation_below = getattr( params, "conditional_confirmation_below_return_pct", None ) if conditional_confirmation_below is not None and not params.require_confirmation_bar: confirmation_ticker = ( getattr(params, "conditional_confirmation_ticker", None) or getattr(params, "market_regime_ticker", None) or getattr(params, "market_orb_quality_ticker", None) or "QQQ" ) confirmation_bar = _first_regular_bar( bars_by_ticker.get(confirmation_ticker, []), _market_open_ts(date_str), ) confirmation_return = _bar_return_pct(confirmation_bar) if ( confirmation_return is not None and confirmation_return <= conditional_confirmation_below ): trade_params = params.model_copy(update={"require_confirmation_bar": True}) conditional_confirmation_active = True if ( market_orb_quality_joint_weak_condition_met and market_orb_quality_joint_weak_require_confirmation and not trade_params.require_confirmation_bar ): trade_params = trade_params.model_copy(update={"require_confirmation_bar": True}) conditional_confirmation_active = True if ( market_orb_quality_joint_panic_condition_met and market_orb_quality_joint_panic_require_confirmation and not trade_params.require_confirmation_bar ): trade_params = trade_params.model_copy(update={"require_confirmation_bar": True}) conditional_confirmation_active = True result.conditional_confirmation_active = conditional_confirmation_active broad_gapup_fallback_mode = bool( getattr(params, "broad_gapup_continuation_enabled", False) ) market_thrust_liquid_mode = bool( getattr(params, "market_thrust_liquid_continuation_enabled", False) ) market_thrust_opening_impulse_mode = bool( getattr(params, "market_thrust_opening_impulse_reclaim_enabled", False) ) intraday_continuation_reclaim_mode = bool( getattr(params, "intraday_continuation_reclaim_enabled", False) ) base_candidate_updates: dict[str, object] = {} if broad_gapup_fallback_mode: base_candidate_updates["broad_gapup_continuation_enabled"] = False if market_thrust_liquid_mode: base_candidate_updates["market_thrust_liquid_continuation_enabled"] = False if market_thrust_opening_impulse_mode: base_candidate_updates["market_thrust_opening_impulse_reclaim_enabled"] = False if intraday_continuation_reclaim_mode: base_candidate_updates["intraday_continuation_reclaim_enabled"] = False candidate_params = ( params.model_copy(update=base_candidate_updates) if base_candidate_updates else params ) _cand_stats: dict = {} candidates = compute_orb_candidates( bars_by_ticker, date_str, candidate_params, enrichment, blacklisted_tickers=blacklisted_tickers, spy_bars=None, # handled above via enrichment ticker_sectors=ticker_sectors, _stats_out=_cand_stats, _suppress_empty_log=( broad_gapup_fallback_mode or intraday_continuation_reclaim_mode ), overlay_tickers=overlay_tickers, ) broad_gapup_candidates: list[dict] = [] if broad_gapup_fallback_mode: broad_scan_params = params.model_copy( update={ "max_candidates": max( int(getattr(params, "max_candidates", 0) or 0), len(bars_by_ticker), ), "max_candidates_per_sector": None, "market_thrust_liquid_continuation_enabled": False, "market_thrust_opening_impulse_reclaim_enabled": False, "intraday_continuation_reclaim_enabled": False, } ) broad_gapup_all_candidates = compute_orb_candidates( bars_by_ticker, date_str, broad_scan_params, enrichment, blacklisted_tickers=blacklisted_tickers, spy_bars=None, ticker_sectors=ticker_sectors, overlay_tickers=overlay_tickers, ) broad_gapup_candidates = [ cand for cand in broad_gapup_all_candidates if cand.get("broad_gapup_continuation") ] market_thrust_liquid_candidates: list[dict] = [] opening_breadth_only_thrust = ( market_thrust_opening_breadth_override_active and not market_thrust_index_breadth_override_active ) liquid_allowed_on_opening_breadth = bool( getattr( params, "market_thrust_opening_breadth_override_activate_liquid_continuation", True, ) ) liquid_requires_market_thrust = bool( getattr(params, "market_thrust_liquid_continuation_require_market_thrust", True) ) market_thrust_liquid_scan_active = ( market_thrust_liquid_mode and (market_thrust_breadth_override_active or not liquid_requires_market_thrust) and ( not opening_breadth_only_thrust or liquid_allowed_on_opening_breadth ) ) if market_thrust_liquid_scan_active: no_thrust_liquid_repair_scan = ( not market_thrust_breadth_override_active and not liquid_requires_market_thrust ) liquid_scan_updates = { "max_candidates": max( int(getattr(params, "max_candidates", 0) or 0), len(bars_by_ticker), ), "max_candidates_per_sector": None, "broad_gapup_continuation_enabled": False, "market_thrust_opening_impulse_reclaim_enabled": False, "intraday_continuation_reclaim_enabled": False, } if no_thrust_liquid_repair_scan: no_thrust_override_map = { "market_thrust_liquid_continuation_min_gap_pct": ( "market_thrust_liquid_continuation_no_thrust_min_gap_pct" ), "market_thrust_liquid_continuation_max_gap_pct": ( "market_thrust_liquid_continuation_no_thrust_max_gap_pct" ), "market_thrust_liquid_continuation_min_first_bar_return_pct": ( "market_thrust_liquid_continuation_no_thrust_min_first_bar_return_pct" ), "market_thrust_liquid_continuation_min_first_bar_dollar_vol": ( "market_thrust_liquid_continuation_no_thrust_min_first_bar_dollar_vol" ), "market_thrust_liquid_continuation_min_avg_dollar_vol": ( "market_thrust_liquid_continuation_no_thrust_min_avg_dollar_vol" ), } for base_field, override_field in no_thrust_override_map.items(): override_value = getattr(params, override_field, None) if override_value is not None: liquid_scan_updates[base_field] = override_value market_thrust_scan_params = params.model_copy( update=liquid_scan_updates ) market_thrust_all_candidates = compute_orb_candidates( bars_by_ticker, date_str, market_thrust_scan_params, enrichment, blacklisted_tickers=blacklisted_tickers, spy_bars=None, ticker_sectors=ticker_sectors, overlay_tickers=overlay_tickers, ) used_tickers = { cand["ticker"] for cand in candidates + broad_gapup_candidates } market_thrust_liquid_candidates = [ cand for cand in market_thrust_all_candidates if cand.get("market_thrust_liquid_continuation") and cand["ticker"] not in used_tickers ] rank_mode = None priority_min_first_return = None if no_thrust_liquid_repair_scan: rank_mode = getattr( params, "market_thrust_liquid_continuation_no_thrust_rank_mode", None, ) priority_min_first_return = getattr( params, ( "market_thrust_liquid_continuation_no_thrust_" "priority_min_first_bar_return_pct" ), None, ) if priority_min_first_return is not None: priority_floor = float(priority_min_first_return) market_thrust_liquid_candidates.sort( key=lambda cand: ( float(cand.get("orb_return") or 0.0) >= priority_floor, float(cand.get("score") or 0.0), ), reverse=True, ) elif rank_mode: market_thrust_liquid_candidates.sort( key=lambda cand: _orb_market_thrust_liquid_rank_key(cand, rank_mode), reverse=True, ) max_market_thrust_candidates = getattr( params, "market_thrust_liquid_continuation_max_candidates", None, ) if no_thrust_liquid_repair_scan: no_thrust_max_market_thrust_candidates = getattr( params, "market_thrust_liquid_continuation_no_thrust_max_candidates", None, ) if no_thrust_max_market_thrust_candidates is not None: max_market_thrust_candidates = no_thrust_max_market_thrust_candidates if max_market_thrust_candidates is not None: market_thrust_liquid_candidates = market_thrust_liquid_candidates[ : max(0, int(max_market_thrust_candidates)) ] market_thrust_opening_impulse_candidates: list[dict] = [] impulse_requires_market_thrust = bool( getattr( params, "market_thrust_opening_impulse_reclaim_require_market_thrust", True, ) ) market_thrust_impulse_scan_active = ( market_thrust_opening_impulse_mode and (market_thrust_breadth_override_active or not impulse_requires_market_thrust) ) if market_thrust_impulse_scan_active: no_thrust_impulse_repair_scan = ( not market_thrust_breadth_override_active and not impulse_requires_market_thrust ) impulse_scan_updates = { "max_candidates": max( int(getattr(params, "max_candidates", 0) or 0), len(bars_by_ticker), ), "max_candidates_per_sector": None, "broad_gapup_continuation_enabled": False, "market_thrust_liquid_continuation_enabled": False, "intraday_continuation_reclaim_enabled": False, } if no_thrust_impulse_repair_scan: no_thrust_impulse_override_map = { "market_thrust_opening_impulse_reclaim_min_gap_pct": ( "market_thrust_opening_impulse_reclaim_no_thrust_min_gap_pct" ), "market_thrust_opening_impulse_reclaim_max_gap_pct": ( "market_thrust_opening_impulse_reclaim_no_thrust_max_gap_pct" ), "market_thrust_opening_impulse_reclaim_min_first_bar_return_pct": ( "market_thrust_opening_impulse_reclaim_no_thrust_min_first_bar_return_pct" ), "market_thrust_opening_impulse_reclaim_min_first_bar_dollar_vol": ( "market_thrust_opening_impulse_reclaim_no_thrust_min_first_bar_dollar_vol" ), "market_thrust_opening_impulse_reclaim_min_avg_dollar_vol": ( "market_thrust_opening_impulse_reclaim_no_thrust_min_avg_dollar_vol" ), "market_thrust_opening_impulse_reclaim_min_body_ratio": ( "market_thrust_opening_impulse_reclaim_no_thrust_min_body_ratio" ), "market_thrust_opening_impulse_reclaim_min_close_location": ( "market_thrust_opening_impulse_reclaim_no_thrust_min_close_location" ), "market_thrust_opening_impulse_reclaim_min_ret_5d": ( "market_thrust_opening_impulse_reclaim_no_thrust_min_ret_5d" ), "market_thrust_opening_impulse_reclaim_max_ret_5d": ( "market_thrust_opening_impulse_reclaim_no_thrust_max_ret_5d" ), } for base_field, override_field in no_thrust_impulse_override_map.items(): override_value = getattr(params, override_field, None) if override_value is not None: impulse_scan_updates[base_field] = override_value market_thrust_impulse_scan_params = params.model_copy( update=impulse_scan_updates ) market_thrust_impulse_all_candidates = compute_orb_candidates( bars_by_ticker, date_str, market_thrust_impulse_scan_params, enrichment, blacklisted_tickers=blacklisted_tickers, spy_bars=None, ticker_sectors=ticker_sectors, overlay_tickers=overlay_tickers, ) used_tickers = { cand["ticker"] for cand in ( candidates + broad_gapup_candidates + market_thrust_liquid_candidates ) } market_thrust_opening_impulse_candidates = [ cand for cand in market_thrust_impulse_all_candidates if cand.get("market_thrust_opening_impulse_reclaim") and cand["ticker"] not in used_tickers ] max_market_thrust_impulse_candidates = getattr( params, "market_thrust_opening_impulse_reclaim_max_candidates", None, ) if no_thrust_impulse_repair_scan: no_thrust_max_impulse_candidates = getattr( params, "market_thrust_opening_impulse_reclaim_no_thrust_max_candidates", None, ) if no_thrust_max_impulse_candidates is not None: max_market_thrust_impulse_candidates = no_thrust_max_impulse_candidates if max_market_thrust_impulse_candidates is not None: market_thrust_opening_impulse_candidates = ( market_thrust_opening_impulse_candidates[ : max(0, int(max_market_thrust_impulse_candidates)) ] ) intraday_continuation_candidates: list[dict] = [] intraday_cluster_count = 0 intraday_cluster_avg_signal_return = 0.0 intraday_cluster_total_signal_dollar_vol = 0.0 if intraday_continuation_reclaim_mode: intraday_scan_params = params.model_copy( update={ "max_candidates": max( int(getattr(params, "max_candidates", 0) or 0), len(bars_by_ticker), ), "max_candidates_per_sector": None, "broad_gapup_continuation_enabled": False, "market_thrust_liquid_continuation_enabled": False, "market_thrust_opening_impulse_reclaim_enabled": False, } ) intraday_all_candidates = compute_orb_candidates( bars_by_ticker, date_str, intraday_scan_params, enrichment, blacklisted_tickers=blacklisted_tickers, spy_bars=None, ticker_sectors=ticker_sectors, overlay_tickers=overlay_tickers, _suppress_empty_log=True, ) used_tickers = { cand["ticker"] for cand in ( candidates + broad_gapup_candidates + market_thrust_liquid_candidates + market_thrust_opening_impulse_candidates ) } intraday_continuation_candidates = [ cand for cand in intraday_all_candidates if cand.get("intraday_continuation_reclaim") and cand["ticker"] not in used_tickers ] intraday_continuation_candidates.sort( key=lambda cand: ( float(cand.get("intraday_continuation_signal_return") or 0.0), float(cand.get("intraday_continuation_signal_dollar_vol") or 0.0), ), reverse=True, ) intraday_cluster_count = len(intraday_continuation_candidates) intraday_cluster_avg_signal_return = ( sum( float(cand.get("intraday_continuation_signal_return") or 0.0) for cand in intraday_continuation_candidates ) / intraday_cluster_count if intraday_cluster_count > 0 else 0.0 ) intraday_cluster_total_signal_dollar_vol = sum( float(cand.get("intraday_continuation_signal_dollar_vol") or 0.0) for cand in intraday_continuation_candidates ) min_intraday_cluster_count = getattr( params, "intraday_continuation_reclaim_min_cluster_count", None, ) min_intraday_cluster_avg_signal_return = getattr( params, "intraday_continuation_reclaim_min_cluster_avg_signal_return_pct", None, ) min_intraday_cluster_total_signal_dollar_vol = getattr( params, "intraday_continuation_reclaim_min_cluster_total_signal_dollar_vol", None, ) if ( ( min_intraday_cluster_count is not None and intraday_cluster_count < max(0, int(min_intraday_cluster_count)) ) or ( min_intraday_cluster_avg_signal_return is not None and intraday_cluster_avg_signal_return < float(min_intraday_cluster_avg_signal_return) ) or ( min_intraday_cluster_total_signal_dollar_vol is not None and intraday_cluster_total_signal_dollar_vol < float(min_intraday_cluster_total_signal_dollar_vol) ) ): intraday_continuation_candidates = [] max_intraday_candidates = getattr( params, "intraday_continuation_reclaim_max_candidates", None, ) if max_intraday_candidates is not None: intraday_continuation_candidates = intraday_continuation_candidates[ : max(0, int(max_intraday_candidates)) ] result.candidates_found = ( len(candidates) + len(broad_gapup_candidates) + len(market_thrust_liquid_candidates) + len(market_thrust_opening_impulse_candidates) + len(intraday_continuation_candidates) ) result.candidate_filter_stats = _cand_stats if _cand_stats else None if ( len(candidates) < params.min_candidates_to_trade and not broad_gapup_candidates and not market_thrust_liquid_candidates and not market_thrust_opening_impulse_candidates and not intraday_continuation_candidates ): result.skip_reason = "no_candidates" if len(candidates) == 0 else "below_min_candidates" return result # ── Pass 1: Find entry times for all candidates ── # Determines chronological order BEFORE allocating capital, so earlier entries # get capital first regardless of composite score ranking. # Each item: (entry_ts, cand, direction_str, trigger_type, forced_entry_price | None) primary_timed_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = [] nofill_vwap_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = [] late_breakout_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = [] soft_day_vwap_candidates: list[tuple[dt.datetime, dict, str, str, float | None]] = [] for cand in candidates: direction_str = ( "long" if cand["direction"] == "bullish" else "short" if cand["direction"] == "bearish" else cand["direction"] ) breakout_ts = _find_breakout_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) momo_result = ( _find_momentum_confirm_time(cand["mkt_bars"], cand["orb_bar"], params) if getattr(params, "dual_trigger_enabled", False) else None ) if breakout_ts is not None and (momo_result is None or breakout_ts <= momo_result[0]): trigger_type = ( "broad_gapup_continuation" if cand.get("broad_gapup_continuation") else "orb" ) primary_timed_candidates.append((breakout_ts, cand, direction_str, trigger_type, None)) elif momo_result is not None: primary_timed_candidates.append((momo_result[0], cand, direction_str, "momentum_confirm", momo_result[1])) elif getattr(params, "nofill_vwap_reclaim_enabled", False): vwap_result = _find_vwap_reclaim_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if vwap_result is not None: nofill_vwap_candidates.append( (vwap_result[0], cand, direction_str, "vwap_reclaim", vwap_result[1]) ) if breakout_ts is None and getattr(params, "late_breakout_enabled", False): late_breakout_result = _find_late_breakout_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if late_breakout_result is not None: late_breakout_candidates.append( ( late_breakout_result[0], cand, direction_str, "late_breakout", late_breakout_result[1], ) ) if ( is_soft_day and getattr(params, "soft_day_vwap_reclaim_enabled", False) and _orb_soft_day_vwap_reason_allowed(params, result.soft_day_reason) ): soft_vwap_result = _find_vwap_reclaim_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if soft_vwap_result is not None: soft_day_vwap_candidates.append( ( soft_vwap_result[0], cand, direction_str, "soft_day_vwap_reclaim", soft_vwap_result[1], ) ) broad_gapup_entry_mode = str( getattr(params, "broad_gapup_continuation_entry_mode", "breakout") or "breakout" ).lower() for cand in broad_gapup_candidates: direction_str = ( "long" if cand["direction"] == "bullish" else "short" if cand["direction"] == "bearish" else cand["direction"] ) if broad_gapup_entry_mode == "opening_burst": orb_ts = _parse_ts(cand["orb_bar"]["timestamp"]) entry_bar = next( (bar for bar in cand["mkt_bars"] if _parse_ts(bar["timestamp"]) > orb_ts), None, ) if entry_bar is not None: primary_timed_candidates.append( ( _parse_ts(entry_bar["timestamp"]), cand, direction_str, "broad_gapup_continuation", float(entry_bar["open"]), ) ) continue if broad_gapup_entry_mode == "late_breakout": late_breakout_result = _find_late_breakout_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if late_breakout_result is not None: primary_timed_candidates.append( ( late_breakout_result[0], cand, direction_str, "broad_gapup_continuation", late_breakout_result[1], ) ) continue if broad_gapup_entry_mode == "vwap_reclaim": vwap_result = _find_vwap_reclaim_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if vwap_result is not None: primary_timed_candidates.append( ( vwap_result[0], cand, direction_str, "broad_gapup_continuation", vwap_result[1], ) ) continue breakout_ts = _find_breakout_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if breakout_ts is not None: primary_timed_candidates.append( (breakout_ts, cand, direction_str, "broad_gapup_continuation", None) ) market_thrust_impulse_entry_mode = str( getattr( params, "market_thrust_opening_impulse_reclaim_entry_mode", "vwap_reclaim", ) or "vwap_reclaim" ).lower() for cand in market_thrust_opening_impulse_candidates: direction_str = ( "long" if cand["direction"] == "bullish" else "short" if cand["direction"] == "bearish" else cand["direction"] ) if market_thrust_impulse_entry_mode == "opening_burst": orb_ts = _parse_ts(cand["orb_bar"]["timestamp"]) entry_bar = next( (bar for bar in cand["mkt_bars"] if _parse_ts(bar["timestamp"]) > orb_ts), None, ) if entry_bar is not None: primary_timed_candidates.append( ( _parse_ts(entry_bar["timestamp"]), cand, direction_str, "market_thrust_opening_impulse_reclaim", float(entry_bar["open"]), ) ) continue if market_thrust_impulse_entry_mode == "late_breakout": late_breakout_result = _find_late_breakout_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if late_breakout_result is not None: primary_timed_candidates.append( ( late_breakout_result[0], cand, direction_str, "market_thrust_opening_impulse_reclaim", late_breakout_result[1], ) ) continue vwap_result = _find_vwap_reclaim_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if vwap_result is not None: primary_timed_candidates.append( ( vwap_result[0], cand, direction_str, "market_thrust_opening_impulse_reclaim", vwap_result[1], ) ) market_thrust_liquid_entry_mode = str( getattr(params, "market_thrust_liquid_continuation_entry_mode", "breakout") or "breakout" ).lower() for cand in market_thrust_liquid_candidates: direction_str = ( "long" if cand["direction"] == "bullish" else "short" if cand["direction"] == "bearish" else cand["direction"] ) if market_thrust_liquid_entry_mode == "opening_burst": orb_ts = _parse_ts(cand["orb_bar"]["timestamp"]) entry_bar = next( (bar for bar in cand["mkt_bars"] if _parse_ts(bar["timestamp"]) > orb_ts), None, ) if entry_bar is not None: primary_timed_candidates.append( ( _parse_ts(entry_bar["timestamp"]), cand, direction_str, "market_thrust_opening_burst", float(entry_bar["open"]), ) ) continue if market_thrust_liquid_entry_mode in { "opening_followthrough", "followthrough_burst", }: followthrough_result = _find_opening_followthrough_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, ) if followthrough_result is not None: primary_timed_candidates.append( ( followthrough_result[0], cand, direction_str, "market_thrust_opening_followthrough", followthrough_result[1], ) ) continue breakout_ts = _find_breakout_time( cand["mkt_bars"], cand["orb_bar"], direction_str, params, date_str ) if breakout_ts is not None: primary_timed_candidates.append( ( breakout_ts, cand, direction_str, "market_thrust_liquid_continuation", None, ) ) for cand in intraday_continuation_candidates: direction_str = ( "long" if cand["direction"] == "bullish" else "short" if cand["direction"] == "bearish" else cand["direction"] ) entry_ts = cand.get("intraday_continuation_entry_ts") entry_price = cand.get("intraday_continuation_entry_price") if isinstance(entry_ts, dt.datetime) and entry_price is not None: primary_timed_candidates.append( ( entry_ts, cand, direction_str, "intraday_continuation_reclaim", float(entry_price), ) ) if ( getattr(params, "broad_gapup_continuation_only_when_no_primary_entries", False) and primary_timed_candidates ): non_broad_primary_timed_candidates = [ item for item in primary_timed_candidates if item[3] != "broad_gapup_continuation" ] if non_broad_primary_timed_candidates: primary_timed_candidates = non_broad_primary_timed_candidates market_thrust_liquid_trigger_types = { "market_thrust_liquid_continuation", "market_thrust_opening_burst", "market_thrust_opening_followthrough", } no_thrust_liquid_priority_min_first_return = ( getattr( params, ( "market_thrust_liquid_continuation_no_thrust_" "priority_min_first_bar_return_pct" ), None, ) if not market_thrust_breadth_override_active and not liquid_requires_market_thrust else None ) market_thrust_impulse_trigger_types = { "market_thrust_opening_impulse_reclaim", } if ( getattr( params, "market_thrust_liquid_continuation_only_when_no_primary_entries", False, ) and not market_thrust_breadth_override_active and primary_timed_candidates ): non_liquid_primary_timed_candidates = [ item for item in primary_timed_candidates if item[3] not in market_thrust_liquid_trigger_types ] if non_liquid_primary_timed_candidates: primary_timed_candidates = non_liquid_primary_timed_candidates if ( getattr( params, "market_thrust_opening_impulse_reclaim_only_when_no_primary_entries", False, ) and not market_thrust_breadth_override_active and primary_timed_candidates ): repair_trigger_types = ( market_thrust_liquid_trigger_types | market_thrust_impulse_trigger_types ) non_repair_primary_timed_candidates = [ item for item in primary_timed_candidates if item[3] not in repair_trigger_types ] if non_repair_primary_timed_candidates: primary_timed_candidates = non_repair_primary_timed_candidates timed_candidates = list(primary_timed_candidates) if nofill_vwap_candidates: allow_nofill_vwap = ( not getattr(params, "nofill_vwap_reclaim_only_when_no_primary_entries", True) or len(primary_timed_candidates) == 0 ) if allow_nofill_vwap: timed_candidates.extend(nofill_vwap_candidates) if late_breakout_candidates: allow_late_breakout = ( not getattr(params, "late_breakout_only_when_no_primary_entries", True) or len(primary_timed_candidates) == 0 ) if allow_late_breakout: timed_candidates.extend(late_breakout_candidates) if soft_day_vwap_candidates: allow_soft_day_vwap = ( not getattr( params, "soft_day_vwap_reclaim_only_when_no_primary_entries", False, ) or len(primary_timed_candidates) == 0 ) if allow_soft_day_vwap: timed_candidates.extend(soft_day_vwap_candidates) reclaim_entry_attention_by_key = _compute_reclaim_entry_attention_by_key( timed_candidates, params, ) result.entry_diagnostics = { "primary_timed_candidates": len(primary_timed_candidates), "nofill_vwap_candidates": len(nofill_vwap_candidates), "late_breakout_candidates": len(late_breakout_candidates), "soft_day_vwap_candidates": len(soft_day_vwap_candidates), "broad_gapup_continuation_candidates": len( [ item for item in primary_timed_candidates if item[3] == "broad_gapup_continuation" ] ), "market_thrust_liquid_continuation_candidates": len( [ item for item in primary_timed_candidates if item[3] == "market_thrust_liquid_continuation" ] ), "market_thrust_opening_burst_candidates": len( [ item for item in primary_timed_candidates if item[3] == "market_thrust_opening_burst" ] ), "market_thrust_opening_followthrough_candidates": len( [ item for item in primary_timed_candidates if item[3] == "market_thrust_opening_followthrough" ] ), "market_thrust_opening_impulse_reclaim_candidates": len( [ item for item in primary_timed_candidates if item[3] == "market_thrust_opening_impulse_reclaim" ] ), "intraday_continuation_reclaim_candidates": len( [ item for item in primary_timed_candidates if item[3] == "intraday_continuation_reclaim" ] ), "intraday_continuation_reclaim_cluster_count": intraday_cluster_count, "intraday_continuation_reclaim_cluster_avg_signal_return": ( intraday_cluster_avg_signal_return ), "intraday_continuation_reclaim_cluster_total_signal_dollar_vol": ( intraday_cluster_total_signal_dollar_vol ), "timed_candidates": len(timed_candidates), } # Sort by entry time ascending (earliest fills first). On defensive # soft-day sleeves, optionally allocate the single probe slot to the best # ranked setup before considering trigger time. When the auxiliary VWAP # sleeve is configured as a no-primary-trade fallback, primary entries must # get first right of refusal before VWAP probes are considered. soft_day_vwap_after_non_soft_path = ( is_soft_day and ( getattr(params, "soft_day_vwap_reclaim_only_when_no_primary_trades", False) or getattr( params, "soft_day_vwap_reclaim_only_when_no_existing_trades", False, ) ) ) auxiliary_trigger_types = { "vwap_reclaim", "soft_day_vwap_reclaim", "late_breakout", "intraday_continuation_reclaim", } def _timed_rank_score( item: tuple[dt.datetime, dict, str, str, float | None], ) -> float: cand = item[1] if ( item[3] in market_thrust_liquid_trigger_types and no_thrust_liquid_priority_min_first_return is not None ): priority_floor = float(no_thrust_liquid_priority_min_first_return) priority_bonus = ( 10_000.0 if float(cand.get("orb_return") or 0.0) >= priority_floor else 0.0 ) return priority_bonus + float(cand.get("score") or 0.0) if item[3] == "intraday_continuation_reclaim": return float(cand.get("intraday_continuation_signal_return") or 0.0) return float(cand.get("score") or 0.0) late_breakout_after_primary_path = bool( getattr(params, "late_breakout_only_when_no_primary_trades", False) or getattr(params, "late_breakout_only_when_no_existing_trades", False) ) entry_tie_break_rank_by_score = bool( getattr(params, "entry_tie_break_rank_by_score", False) ) if is_soft_day and getattr(params, "soft_day_rank_before_time", False): timed_candidates.sort( key=lambda x: ( ( 1 if soft_day_vwap_after_non_soft_path and x[3] == "soft_day_vwap_reclaim" else 0 ), ( 1 if late_breakout_after_primary_path and x[3] == "late_breakout" else 0 ), -_timed_rank_score(x), x[0], ) ) else: timed_candidates.sort( key=lambda x: ( ( 1 if soft_day_vwap_after_non_soft_path and x[3] == "soft_day_vwap_reclaim" else 0 ), ( 1 if late_breakout_after_primary_path and x[3] == "late_breakout" else 0 ), x[0], -_timed_rank_score(x) if entry_tie_break_rank_by_score else 0.0, ) ) # ── Pass 2: Simulate in chronological order with capital constraints ── sizing_cap = sizing_capital if sizing_capital is not None else equity sparse_day_scaler = _orb_sparse_day_scaler(len(timed_candidates), params) result.sparse_day_scaler = sparse_day_scaler # Apply VIX + regime/breadth + market-ORB-quality scalers to sizing capital. # Market ORB quality may legitimately scale above 1.0 on especially strong open regimes. combined_size_mult = vix_scaler * combined_scaler * sparse_day_scaler * market_orb_quality_scaler adjusted_sizing = sizing_cap * combined_size_mult # Preserve legacy ORB semantics: day-level kill switch is anchored to the # unscaled research budget, even when intra-day sizing is softened by # regime/breadth/meta scalers. This avoids changing historical strategy # behavior when new size scalers are introduced. daily_loss_limit = sizing_cap * params.daily_max_loss_pct remaining_cash = available_cash # None → no constraint (settlement_days=0) result.available_cash_start = available_cash if available_cash is not None else equity skipped_cash = 0 entries_at_ts: dict[str, int] = {} # timestamp_str → entries taken at that bar nofill_vwap_trades = 0 late_breakout_trades = 0 soft_day_vwap_trades = 0 broad_gapup_continuation_trades = 0 market_thrust_liquid_continuation_trades = 0 market_thrust_opening_burst_trades = 0 market_thrust_opening_followthrough_trades = 0 market_thrust_opening_impulse_reclaim_trades = 0 intraday_continuation_reclaim_trades = 0 soft_day_sector_confirmation_override_trades = 0 traded_tickers: set[str] = set() entry_reject_stats: dict[str, int] = {} total_deployed = 0.0 # cumulative deployed capital for deployment cap max_deploy = ( sizing_cap * params.max_total_deployment_pct if params.max_total_deployment_pct is not None else None ) entry_market_guard_ticker = ( getattr(params, "entry_market_guard_ticker", None) or getattr(params, "market_regime_ticker", None) or "QQQ" ) entry_market_guard_bars = ( bars_by_ticker.get(str(entry_market_guard_ticker)) if bool(getattr(params, "entry_market_guard_enabled", False)) else None ) for idx, (entry_ts_pass2, cand, direction_str, trigger_type_pass2, forced_price_pass2) in enumerate(timed_candidates): def _reject(reason: str) -> None: entry_reject_stats[reason] = entry_reject_stats.get(reason, 0) + 1 # Kill switch: only count losses from trades that have ALREADY EXITED # before this entry time (exit-time-aware accounting). realized_loss = sum( abs(t.pnl) for t in result.trades if t.pnl < 0 and _parse_ts(t.exit_time) <= entry_ts_pass2 ) realized_stops = sum( 1 for t in result.trades if t.exit_reason == "stop_loss" and t.r_multiple_at_exit is not None and t.r_multiple_at_exit <= -0.8 and _parse_ts(t.exit_time) <= entry_ts_pass2 ) if realized_loss >= daily_loss_limit: break if realized_stops >= params.max_stops_per_day: break if cand["ticker"] in traded_tickers: _reject("duplicate_ticker") continue if ( market_orb_quality_divergence_max_trades is not None and len(result.trades) >= market_orb_quality_divergence_max_trades ): break if params.max_trades_per_day is not None and len(result.trades) >= max( 0, int(params.max_trades_per_day) ): break # Simultaneous-entry cap: limit correlated risk when all candidates enter # on the same bar. Top-ranked candidates are taken first because timed_candidates # is sorted by entry_ts (ties preserve ranking order). if params.max_simultaneous_entries is not None: ts_key = entry_ts_pass2.isoformat() if entries_at_ts.get(ts_key, 0) >= params.max_simultaneous_entries: _reject("max_simultaneous_entries") continue # Cash exhaustion if remaining_cash is not None and remaining_cash <= 0: skipped_cash += len(timed_candidates) - idx _reject("cash_exhausted") break # Portfolio deployment cap if max_deploy is not None and total_deployed >= max_deploy: _reject("max_deployment") continue # Score rank percentage: 1.0 = top ranked, 0.0 = bottom ranked rank_candidates = ( broad_gapup_candidates if trigger_type_pass2 == "broad_gapup_continuation" and broad_gapup_candidates else market_thrust_liquid_candidates if trigger_type_pass2 in { "market_thrust_liquid_continuation", "market_thrust_opening_burst", "market_thrust_opening_followthrough", } and market_thrust_liquid_candidates else market_thrust_opening_impulse_candidates if trigger_type_pass2 == "market_thrust_opening_impulse_reclaim" and market_thrust_opening_impulse_candidates else intraday_continuation_candidates if trigger_type_pass2 == "intraday_continuation_reclaim" and intraday_continuation_candidates else candidates ) n_cands = len(rank_candidates) cand_rank = next( (i for i, c in enumerate(rank_candidates) if c["ticker"] == cand["ticker"]), n_cands - 1, ) score_rank_pct = 1.0 - (cand_rank / max(n_cands - 1, 1)) ticker_enrich = enrichment.get(cand["ticker"], {}).get(date_str, {}) timed_key = (str(cand.get("ticker") or ""), str(trigger_type_pass2), entry_ts_pass2.isoformat()) reclaim_entry_attention_diag = reclaim_entry_attention_by_key.get(timed_key, {}) if trigger_type_pass2 == "vwap_reclaim": if ( params.nofill_vwap_reclaim_max_trades is not None and nofill_vwap_trades >= params.nofill_vwap_reclaim_max_trades ): _reject("nofill_vwap_max_trades") continue if ( params.nofill_vwap_reclaim_min_score_pct is not None and score_rank_pct < params.nofill_vwap_reclaim_min_score_pct ): _reject("nofill_vwap_score") continue elif trigger_type_pass2 == "late_breakout": if ( getattr(params, "late_breakout_only_when_no_existing_trades", False) and result.trades ): _reject("late_breakout_existing_trade") continue if ( getattr(params, "late_breakout_only_when_no_primary_trades", False) and any(t.trigger_type not in auxiliary_trigger_types for t in result.trades) ): _reject("late_breakout_primary_trade_exists") continue if ( params.late_breakout_max_trades is not None and late_breakout_trades >= params.late_breakout_max_trades ): _reject("late_breakout_max_trades") continue if ( params.late_breakout_min_score_pct is not None and score_rank_pct < params.late_breakout_min_score_pct ): _reject("late_breakout_score") continue elif trigger_type_pass2 == "soft_day_vwap_reclaim": if not _orb_soft_day_vwap_reason_allowed(params, result.soft_day_reason): _reject("soft_day_vwap_reason") continue if ( getattr( params, "soft_day_vwap_reclaim_only_when_no_existing_trades", False, ) and result.trades ): _reject("soft_day_vwap_existing_trade") continue if ( getattr(params, "soft_day_vwap_reclaim_only_when_no_primary_trades", False) and any( t.trigger_type not in auxiliary_trigger_types for t in result.trades ) ): _reject("soft_day_vwap_primary_trade_exists") continue if ( params.soft_day_vwap_reclaim_max_trades is not None and soft_day_vwap_trades >= params.soft_day_vwap_reclaim_max_trades ): _reject("soft_day_vwap_max_trades") continue soft_day_vwap_min_score = _orb_soft_day_vwap_min_score_pct( params, result.soft_day_reason, ) if ( soft_day_vwap_min_score is not None and score_rank_pct < soft_day_vwap_min_score ): _reject("soft_day_vwap_score") continue min_pm_dolvol = _orb_soft_day_vwap_reason_param( params, result.soft_day_reason, "soft_day_vwap_reclaim_min_premarket_dollar_vol", ) if ( min_pm_dolvol is not None and float(cand.get("premarket_dollar_vol") or 0.0) < min_pm_dolvol ): _reject("soft_day_vwap_premarket_dolvol") continue min_aux_ret5 = _orb_soft_day_vwap_reason_param( params, result.soft_day_reason, "soft_day_vwap_reclaim_min_ret_5d", ) if min_aux_ret5 is not None: aux_ret5 = cand.get("ret_5d", ticker_enrich.get("ret_5d")) if aux_ret5 is None or float(aux_ret5) < min_aux_ret5: _reject("soft_day_vwap_ret5") continue gap_down_max_ret5 = _orb_soft_day_vwap_reason_param( params, result.soft_day_reason, "soft_day_vwap_reclaim_gap_down_max_ret_5d", ) if gap_down_max_ret5 is not None and float(cand.get("gap_pct") or 0.0) < 0.0: aux_ret5 = cand.get("ret_5d", ticker_enrich.get("ret_5d")) if aux_ret5 is None or float(aux_ret5) > gap_down_max_ret5: _reject("soft_day_vwap_gap_down_ret5") continue weak_participation_max_rvol_rank = getattr( params, "soft_day_vwap_reclaim_weak_participation_max_rvol_rank_pct", None, ) weak_participation_max_body = getattr( params, "soft_day_vwap_reclaim_weak_participation_max_body_ratio", None, ) if ( weak_participation_max_rvol_rank is not None and weak_participation_max_body is not None ): aux_rvol_rank = cand.get("rvol_rank_pct") aux_body = cand.get("body_ratio") if ( aux_rvol_rank is not None and aux_body is not None and float(aux_rvol_rank) <= float(weak_participation_max_rvol_rank) and float(aux_body) <= float(weak_participation_max_body) ): _reject("soft_day_vwap_weak_participation") continue min_aux_rvol = _orb_soft_day_vwap_reason_param( params, result.soft_day_reason, "soft_day_vwap_reclaim_min_rvol", ) if min_aux_rvol is not None: aux_rvol = cand.get("rvol") if aux_rvol is None or float(aux_rvol) < min_aux_rvol: _reject("soft_day_vwap_min_rvol") continue max_aux_rvol = _orb_soft_day_vwap_reason_param( params, result.soft_day_reason, "soft_day_vwap_reclaim_max_rvol", ) if max_aux_rvol is not None: aux_rvol = cand.get("rvol") if aux_rvol is None or float(aux_rvol) > max_aux_rvol: _reject("soft_day_vwap_rvol") continue min_aux_body = _orb_soft_day_vwap_reason_param( params, result.soft_day_reason, "soft_day_vwap_reclaim_min_body_ratio", ) if min_aux_body is not None and float(cand.get("body_ratio") or 0.0) < min_aux_body: _reject("soft_day_vwap_body") continue min_aux_close_loc = _orb_soft_day_vwap_reason_param( params, result.soft_day_reason, "soft_day_vwap_reclaim_min_close_location", ) if ( min_aux_close_loc is not None and float(cand.get("close_location") or 0.0) < min_aux_close_loc ): _reject("soft_day_vwap_close_location") continue elif trigger_type_pass2 == "broad_gapup_continuation": if ( getattr(params, "broad_gapup_continuation_only_when_no_primary_trades", False) and any( t.trigger_type not in auxiliary_trigger_types and t.trigger_type != "broad_gapup_continuation" for t in result.trades ) ): _reject("broad_gapup_primary_trade_exists") continue max_broad_trades = getattr( params, "broad_gapup_continuation_max_trades", None, ) if ( max_broad_trades is not None and broad_gapup_continuation_trades >= max(0, int(max_broad_trades)) ): _reject("broad_gapup_max_trades") continue broad_min_score_pct = getattr( params, "broad_gapup_continuation_min_score_pct", None, ) if broad_min_score_pct is not None and score_rank_pct < float(broad_min_score_pct): _reject("broad_gapup_score") continue elif trigger_type_pass2 in { "market_thrust_liquid_continuation", "market_thrust_opening_burst", "market_thrust_opening_followthrough", }: if ( getattr( params, "market_thrust_liquid_continuation_only_when_no_primary_trades", False, ) and not market_thrust_breadth_override_active and any( t.trigger_type not in auxiliary_trigger_types and t.trigger_type not in market_thrust_liquid_trigger_types for t in result.trades ) ): _reject("market_thrust_liquid_primary_trade_exists") continue max_market_thrust_trades = getattr( params, "market_thrust_liquid_continuation_max_trades", None, ) if not market_thrust_breadth_override_active: no_thrust_max_market_thrust_trades = getattr( params, "market_thrust_liquid_continuation_no_thrust_max_trades", None, ) if no_thrust_max_market_thrust_trades is not None: max_market_thrust_trades = no_thrust_max_market_thrust_trades if ( max_market_thrust_trades is not None and ( market_thrust_liquid_continuation_trades + market_thrust_opening_burst_trades + market_thrust_opening_followthrough_trades ) >= max(0, int(max_market_thrust_trades)) ): _reject("market_thrust_liquid_max_trades") continue market_thrust_min_score_pct = getattr( params, "market_thrust_liquid_continuation_min_score_pct", None, ) if ( market_thrust_min_score_pct is not None and score_rank_pct < float(market_thrust_min_score_pct) ): _reject("market_thrust_liquid_score") continue elif trigger_type_pass2 == "market_thrust_opening_impulse_reclaim": if ( getattr( params, "market_thrust_opening_impulse_reclaim_only_when_no_primary_trades", False, ) and not market_thrust_breadth_override_active and any( t.trigger_type not in auxiliary_trigger_types and t.trigger_type not in market_thrust_liquid_trigger_types and t.trigger_type not in market_thrust_impulse_trigger_types for t in result.trades ) ): _reject("market_thrust_impulse_primary_trade_exists") continue max_market_thrust_impulse_trades = getattr( params, "market_thrust_opening_impulse_reclaim_max_trades", None, ) if not market_thrust_breadth_override_active: no_thrust_max_impulse_trades = getattr( params, "market_thrust_opening_impulse_reclaim_no_thrust_max_trades", None, ) if no_thrust_max_impulse_trades is not None: max_market_thrust_impulse_trades = no_thrust_max_impulse_trades if ( max_market_thrust_impulse_trades is not None and market_thrust_opening_impulse_reclaim_trades >= max(0, int(max_market_thrust_impulse_trades)) ): _reject("market_thrust_impulse_max_trades") continue market_thrust_impulse_min_score_pct = getattr( params, "market_thrust_opening_impulse_reclaim_min_score_pct", None, ) if ( market_thrust_impulse_min_score_pct is not None and score_rank_pct < float(market_thrust_impulse_min_score_pct) ): _reject("market_thrust_impulse_score") continue elif trigger_type_pass2 == "intraday_continuation_reclaim": if ( getattr( params, "intraday_continuation_reclaim_only_when_no_primary_trades", True, ) and any( t.trigger_type not in auxiliary_trigger_types and t.trigger_type != "intraday_continuation_reclaim" for t in result.trades ) ): _reject("intraday_continuation_primary_trade_exists") continue max_intraday_trades = getattr( params, "intraday_continuation_reclaim_max_trades", None, ) if ( max_intraday_trades is not None and intraday_continuation_reclaim_trades >= max(0, int(max_intraday_trades)) ): _reject("intraday_continuation_max_trades") continue intraday_min_score_pct = getattr( params, "intraday_continuation_reclaim_min_score_pct", None, ) if ( intraday_min_score_pct is not None and score_rank_pct < float(intraday_min_score_pct) ): _reject("intraday_continuation_score") continue if ( reclaim_entry_attention_by_key and _reclaim_entry_attention_trigger_allowed(params, trigger_type_pass2) ): min_entry_rel = getattr( params, "reclaim_entry_attention_min_entry_rel_volume", None, ) entry_rel = reclaim_entry_attention_diag.get("entry_rel_volume") if ( min_entry_rel is not None and (entry_rel is None or entry_rel < float(min_entry_rel)) ): _reject("reclaim_entry_attention_relvol") continue min_entry_rank = getattr( params, "reclaim_entry_attention_min_rank_pct", None, ) entry_rank = reclaim_entry_attention_diag.get( "reclaim_entry_attention_rank_pct" ) if ( min_entry_rank is not None and (entry_rank is None or entry_rank < float(min_entry_rank)) ): _reject("reclaim_entry_attention_rank") continue soft_day_sector_confirmation_override_active = False if is_soft_day and trigger_type_pass2 != "soft_day_vwap_reclaim": soft_day_sector_confirmation_override_active = ( _orb_soft_day_sector_confirmation_override_allows( params, result.soft_day_reason, cand, score_rank_pct=score_rank_pct, trigger_type=trigger_type_pass2, ) ) max_sector_override = getattr( params, "soft_day_sector_confirmation_override_max_trades", 1, ) if ( soft_day_sector_confirmation_override_active and max_sector_override is not None and soft_day_sector_confirmation_override_trades >= max(0, int(max_sector_override)) ): soft_day_sector_confirmation_override_active = False # Soft-day selection gates if is_soft_day: if ( trigger_type_pass2 not in { "broad_gapup_continuation", "market_thrust_opening_burst", "market_thrust_opening_followthrough", "market_thrust_opening_impulse_reclaim", "intraday_continuation_reclaim", } and params.soft_day_max_trades is not None and len(result.trades) >= params.soft_day_max_trades ): _reject("soft_day_max_trades") continue if ( trigger_type_pass2 != "soft_day_vwap_reclaim" and trigger_type_pass2 != "broad_gapup_continuation" and trigger_type_pass2 != "market_thrust_liquid_continuation" and trigger_type_pass2 != "market_thrust_opening_burst" and trigger_type_pass2 != "market_thrust_opening_followthrough" and trigger_type_pass2 != "market_thrust_opening_impulse_reclaim" and trigger_type_pass2 != "intraday_continuation_reclaim" and not soft_day_sector_confirmation_override_active ): if params.soft_day_min_score_pct is not None and score_rank_pct < params.soft_day_min_score_pct: _reject("soft_day_score") continue if ( params.soft_day_min_candidate_score is not None and float(cand.get("score") or 0.0) < params.soft_day_min_candidate_score ): _reject("soft_day_candidate_score") continue if params.soft_day_min_ret_5d is not None: ret_5d = cand.get("ret_5d", ticker_enrich.get("ret_5d")) if ret_5d is None or float(ret_5d) < params.soft_day_min_ret_5d: _reject("soft_day_ret5") continue if params.soft_day_max_rvol is not None: cand_rvol = cand.get("rvol") if cand_rvol is None or float(cand_rvol) > params.soft_day_max_rvol: _reject("soft_day_rvol") continue if not _orb_soft_day_setup_profile_allows( params, result.soft_day_reason, cand, ticker_enrich, ): _reject("soft_day_profile") continue entry_market_guard_active, entry_market_guard_return, entry_market_guard_size_scale = ( _entry_market_guard_scale( params, entry_market_guard_bars, entry_ts_pass2, date_str, is_soft_day, ) ) if ( entry_market_guard_active and bool(getattr(params, "entry_market_guard_skip_trade", False)) ): _reject("entry_market_guard") continue # Previous close + entropy for gap fill protection and per-candidate size scaling cand_prev_close = ticker_enrich.get("prev_close") # Per-candidate entropy size scaler (from momentum strategy) entropy_20d = ticker_enrich.get("entropy_20d") entropy_scaler = ( _orb_entropy_size_scaler(entropy_20d, params) if getattr(params, "entropy_size_scale_low", None) is not None else 1.0 ) crowded_gap_size_scale = float(cand.get("crowded_gap_size_scale") or 1.0) countertrend_gap_size_scale = float( cand.get("countertrend_gap_size_scale") or 1.0 ) distressed_reclaim_size_scale = float( cand.get("distressed_reclaim_size_scale") or 1.0 ) hot_reclaim_size_scale = float(cand.get("hot_reclaim_size_scale") or 1.0) weak_downside_reclaim_size_scale = float( cand.get("weak_downside_reclaim_size_scale") or 1.0 ) quiet_downside_reclaim_size_scale = float( cand.get("quiet_downside_reclaim_size_scale") or 1.0 ) stale_obv_reversal_size_scale = float( cand.get("stale_obv_reversal_size_scale") or 1.0 ) stalled_gap_up_size_scale = float(cand.get("stalled_gap_up_size_scale") or 1.0) liquid_stalled_gap_up_size_scale = float( cand.get("liquid_stalled_gap_up_size_scale") or 1.0 ) ranked_downside_gap_size_scale = 1.0 ranked_downside_gap_reserve_full_cash = False ranked_downside_gap_loss_cap_pct = None ranked_downside_gap_min_abs_gap = getattr( params, "ranked_downside_gap_min_abs_gap_pct", None ) ranked_downside_gap_action = str( getattr(params, "ranked_downside_gap_action", "none") or "none" ) if ( direction_str == "long" and ranked_downside_gap_min_abs_gap is not None and cand.get("gap_pct") is not None and float(cand.get("gap_pct") or 0.0) <= -float(ranked_downside_gap_min_abs_gap) and ( getattr(params, "ranked_downside_gap_max_premarket_dollar_vol", None) is None or float(cand.get("premarket_dollar_vol") or 0.0) <= float(getattr(params, "ranked_downside_gap_max_premarket_dollar_vol")) ) and ( getattr(params, "ranked_downside_gap_max_score_rank_pct", None) is None or score_rank_pct <= float(getattr(params, "ranked_downside_gap_max_score_rank_pct")) ) and ( getattr(params, "ranked_downside_gap_min_market_secondary_close_location", None) is None or ( market_orb_quality_secondary_close_location is not None and market_orb_quality_secondary_close_location >= float(getattr(params, "ranked_downside_gap_min_market_secondary_close_location")) ) ) ): if ranked_downside_gap_action in ("scale", "scale_reserve"): ranked_downside_gap_size_scale = max( 0.0, min( 1.0, float(getattr(params, "ranked_downside_gap_size_scale", 1.0) or 1.0), ), ) if ranked_downside_gap_action == "scale_reserve": ranked_downside_gap_reserve_full_cash = True if ranked_downside_gap_action == "loss_cap": if getattr(params, "ranked_downside_gap_loss_cap_pct", None) is not None: ranked_downside_gap_loss_cap_pct = max( 0.0, float(getattr(params, "ranked_downside_gap_loss_cap_pct") or 0.0), ) isolated_downside_loss_cap_active = False isolated_downside_loss_cap_pct = None isolated_downside_size_scale = 1.0 isolated_downside_min_abs_gap = getattr( params, "isolated_downside_loss_cap_min_abs_gap_pct", None, ) if ( direction_str == "long" and isolated_downside_min_abs_gap is not None and cand.get("gap_pct") is not None and float(cand.get("gap_pct") or 0.0) <= -float(isolated_downside_min_abs_gap) and not bool(cand.get("sector_confirmation_active")) ): allowed_triggers = getattr( params, "isolated_downside_loss_cap_allowed_trigger_types", None, ) ret_5d_for_isolated = ticker_enrich.get("ret_5d") premarket_for_isolated = float(cand.get("premarket_dollar_vol") or 0.0) isolated_downside_ok = ( (not allowed_triggers or trigger_type_pass2 in set(allowed_triggers)) and ( getattr(params, "isolated_downside_loss_cap_max_ret_5d", None) is None or ( ret_5d_for_isolated is not None and float(ret_5d_for_isolated) <= float(getattr(params, "isolated_downside_loss_cap_max_ret_5d")) ) ) and ( getattr( params, "isolated_downside_loss_cap_min_premarket_dollar_vol", None, ) is None or premarket_for_isolated >= float( getattr( params, "isolated_downside_loss_cap_min_premarket_dollar_vol", ) ) ) and ( getattr( params, "isolated_downside_loss_cap_max_premarket_dollar_vol", None, ) is None or premarket_for_isolated <= float( getattr( params, "isolated_downside_loss_cap_max_premarket_dollar_vol", ) ) ) and ( getattr(params, "isolated_downside_loss_cap_max_body_ratio", None) is None or float(cand.get("body_ratio") or 0.0) <= float(getattr(params, "isolated_downside_loss_cap_max_body_ratio")) ) and ( getattr(params, "isolated_downside_loss_cap_min_body_ratio", None) is None or float(cand.get("body_ratio") or 0.0) >= float(getattr(params, "isolated_downside_loss_cap_min_body_ratio")) ) and ( getattr(params, "isolated_downside_loss_cap_max_close_location", None) is None or float(cand.get("close_location") or 0.0) <= float( getattr( params, "isolated_downside_loss_cap_max_close_location", ) ) ) and ( getattr(params, "isolated_downside_loss_cap_min_close_location", None) is None or float(cand.get("close_location") or 0.0) >= float( getattr( params, "isolated_downside_loss_cap_min_close_location", ) ) ) and ( getattr(params, "isolated_downside_loss_cap_min_orb_return", None) is None or float(cand.get("orb_return") or 0.0) >= float(getattr(params, "isolated_downside_loss_cap_min_orb_return")) ) and ( getattr(params, "isolated_downside_loss_cap_max_score_rank_pct", None) is None or score_rank_pct <= float( getattr( params, "isolated_downside_loss_cap_max_score_rank_pct", ) ) ) ) if isolated_downside_ok: isolated_downside_loss_cap_active = True raw_isolated_scale = getattr( params, "isolated_downside_size_scale", 1.0, ) isolated_downside_size_scale = max( 0.0, min(1.0, float(raw_isolated_scale if raw_isolated_scale is not None else 1.0)), ) isolated_downside_raw_loss_cap = getattr( params, "isolated_downside_loss_cap_pct", None, ) if isolated_downside_raw_loss_cap is not None: isolated_downside_loss_cap_pct = max( 0.0, float(isolated_downside_raw_loss_cap), ) isolated_downside_pressure_active = False isolated_downside_pressure_size_scale = 1.0 isolated_pressure_min_abs_gap = getattr( params, "isolated_downside_pressure_min_abs_gap_pct", None, ) if ( direction_str == "long" and isolated_pressure_min_abs_gap is not None and cand.get("gap_pct") is not None and float(cand.get("gap_pct") or 0.0) <= -float(isolated_pressure_min_abs_gap) and not bool(cand.get("sector_confirmation_active")) ): isolated_pressure_allowed_triggers = getattr( params, "isolated_downside_pressure_allowed_trigger_types", None, ) isolated_pressure_ret_5d = ticker_enrich.get("ret_5d") isolated_pressure_premarket = float(cand.get("premarket_dollar_vol") or 0.0) isolated_pressure_ok = ( ( not isolated_pressure_allowed_triggers or trigger_type_pass2 in set(isolated_pressure_allowed_triggers) ) and ( getattr(params, "isolated_downside_pressure_max_ret_5d", None) is None or ( isolated_pressure_ret_5d is not None and float(isolated_pressure_ret_5d) <= float(getattr(params, "isolated_downside_pressure_max_ret_5d")) ) ) and ( getattr( params, "isolated_downside_pressure_min_premarket_dollar_vol", None, ) is None or isolated_pressure_premarket >= float( getattr( params, "isolated_downside_pressure_min_premarket_dollar_vol", ) ) ) and ( getattr(params, "isolated_downside_pressure_max_body_ratio", None) is None or float(cand.get("body_ratio") or 0.0) <= float(getattr(params, "isolated_downside_pressure_max_body_ratio")) ) and ( getattr( params, "isolated_downside_pressure_max_close_location", None, ) is None or float(cand.get("close_location") or 0.0) <= float( getattr( params, "isolated_downside_pressure_max_close_location", ) ) ) ) if isolated_pressure_ok: isolated_downside_pressure_active = True raw_isolated_pressure_scale = getattr( params, "isolated_downside_pressure_size_scale", 1.0, ) isolated_downside_pressure_size_scale = max( 0.0, min( 1.0, float( 1.0 if raw_isolated_pressure_scale is None else raw_isolated_pressure_scale ), ), ) rank_rvol_pressure_size_scale = 1.0 overextended_downside_reclaim_active = False overextended_downside_reclaim_size_scale = 1.0 overextended_min_abs_gap = getattr( params, "overextended_downside_reclaim_min_abs_gap_pct", None, ) overextended_min_ret = getattr( params, "overextended_downside_reclaim_min_ret_5d", None, ) if ( direction_str == "long" and overextended_min_abs_gap is not None and overextended_min_ret is not None and cand.get("gap_pct") is not None and float(cand.get("gap_pct") or 0.0) <= -float(overextended_min_abs_gap) ): overextended_allowed_triggers = getattr( params, "overextended_downside_reclaim_allowed_trigger_types", None, ) overextended_ret_5d = ticker_enrich.get("ret_5d") overextended_premarket = float(cand.get("premarket_dollar_vol") or 0.0) overextended_ok = ( ( not overextended_allowed_triggers or trigger_type_pass2 in {str(t) for t in overextended_allowed_triggers} ) and ( overextended_ret_5d is not None and float(overextended_ret_5d) >= float(overextended_min_ret) ) and ( getattr( params, "overextended_downside_reclaim_min_premarket_dollar_vol", None, ) is None or overextended_premarket >= float( getattr( params, "overextended_downside_reclaim_min_premarket_dollar_vol", ) ) ) ) if overextended_ok: overextended_downside_reclaim_active = True raw_overextended_scale = getattr( params, "overextended_downside_reclaim_size_scale", 1.0, ) overextended_downside_reclaim_size_scale = max( 0.0, min( 1.0, float( 1.0 if raw_overextended_scale is None else raw_overextended_scale ), ), ) ( mid_attention_exhaustion_active, mid_attention_exhaustion_size_scale, ) = _orb_mid_attention_exhaustion_size_scale( params, cand, trigger_type=trigger_type_pass2, direction_str=direction_str, ) ( mid_liquidity_fragility_active, mid_liquidity_fragility_size_scale, ) = _orb_mid_liquidity_fragility_size_scale( params, cand, ticker_enrich, trigger_type=trigger_type_pass2, direction_str=direction_str, ) ( orphan_thin_attention_active, orphan_thin_attention_size_scale, ) = _orb_orphan_thin_attention_size_scale( params, cand, trigger_type=trigger_type_pass2, direction_str=direction_str, ) ( gap_up_fill_trap_active, gap_up_fill_trap_size_scale, ) = _orb_gap_up_fill_trap_size_scale( params, cand, trigger_type=trigger_type_pass2, direction_str=direction_str, ) ( low_candidate_quality_active, low_candidate_quality_size_scale, ) = _orb_low_candidate_quality_size_scale( params, cand, trigger_type=trigger_type_pass2, direction_str=direction_str, ) rank_rvol_pressure_min_rvol = getattr( params, "rank_rvol_pressure_min_rvol", None ) rank_rvol_pressure_max_rank = getattr( params, "rank_rvol_pressure_max_score_rank_pct", None ) if ( rank_rvol_pressure_min_rvol is not None and rank_rvol_pressure_max_rank is not None and float(cand.get("rvol") or 0.0) >= float(rank_rvol_pressure_min_rvol) and score_rank_pct <= float(rank_rvol_pressure_max_rank) ): rank_rvol_ret_5d = ticker_enrich.get("ret_5d") rank_rvol_max_ret_5d = getattr( params, "rank_rvol_pressure_max_ret_5d", None ) if ( rank_rvol_max_ret_5d is None or ( rank_rvol_ret_5d is not None and float(rank_rvol_ret_5d) <= float(rank_rvol_max_ret_5d) ) ): raw_rank_rvol_scale = getattr( params, "rank_rvol_pressure_size_scale", 1.0, ) rank_rvol_pressure_size_scale = max( 0.0, min( 1.0, float( 1.0 if raw_rank_rvol_scale is None else raw_rank_rvol_scale ), ), ) gap_exhaustion_pressure_size_scale = ( _orb_gap_exhaustion_pressure_size_scale( params, cand, ticker_enrich, direction_str, ) ) stale_obv_rvol_pressure_size_scale = ( _orb_stale_obv_rvol_pressure_size_scale( params, cand, ticker_enrich, score_rank_pct, ) ) positive_gap_rebound_failure_size_scale = ( _orb_positive_gap_rebound_failure_size_scale( params, cand, ticker_enrich, trigger_type=trigger_type_pass2, direction_str=direction_str, ) ) sector_confirmation_size_scale = _orb_sector_confirmation_size_scale( params, cand, ) soft_day_sector_confirmation_override_size_scale = 1.0 if soft_day_sector_confirmation_override_active: raw_soft_sector_scale = getattr( params, "soft_day_sector_confirmation_override_size_scale", 1.0, ) soft_day_sector_confirmation_override_size_scale = max( 0.0, min(2.0, float(1.0 if raw_soft_sector_scale is None else raw_soft_sector_scale)), ) defensive_risk_size_scales = ( crowded_gap_size_scale, countertrend_gap_size_scale, distressed_reclaim_size_scale, hot_reclaim_size_scale, weak_downside_reclaim_size_scale, quiet_downside_reclaim_size_scale, stalled_gap_up_size_scale, liquid_stalled_gap_up_size_scale, stale_obv_reversal_size_scale, ranked_downside_gap_size_scale, isolated_downside_size_scale, isolated_downside_pressure_size_scale, overextended_downside_reclaim_size_scale, mid_attention_exhaustion_size_scale, mid_liquidity_fragility_size_scale, orphan_thin_attention_size_scale, gap_up_fill_trap_size_scale, low_candidate_quality_size_scale, rank_rvol_pressure_size_scale, gap_exhaustion_pressure_size_scale, stale_obv_rvol_pressure_size_scale, positive_gap_rebound_failure_size_scale, min(sector_confirmation_size_scale, 1.0), ) red_to_green_acceleration_size_scale = ( _orb_red_to_green_acceleration_size_scale( params, cand, ticker_enrich, score_rank_pct, direction_str, defensive_risk_size_scales, ) ) liquid_leader_conviction_size_scale = ( _orb_liquid_leader_conviction_size_scale( params, cand, score_rank_pct, trigger_type_pass2, direction_str, defensive_risk_size_scales, ) ) opening_burst_liquid_size_scale = _orb_opening_burst_liquid_size_scale( params, cand, date_str=date_str, entry_ts=entry_ts_pass2, trigger_type=trigger_type_pass2, score_rank_pct=score_rank_pct, direction_str=direction_str, risk_size_scales=defensive_risk_size_scales, ) ownership_initial_size_scale = _orb_ownership_initial_size_scale( params, cand, score_rank_pct, trigger_type_pass2, direction_str, defensive_risk_size_scales, ) form4_size_scale = _orb_form4_size_scale( params, cand, trigger_type_pass2, direction_str, defensive_risk_size_scales, ) high_conviction_size_scales = ( red_to_green_acceleration_size_scale, liquid_leader_conviction_size_scale, opening_burst_liquid_size_scale, ownership_initial_size_scale, form4_size_scale, ) unsupported_attention_size_scale = _orb_unsupported_attention_size_scale( params, cand, trigger_type=trigger_type_pass2, direction_str=direction_str, high_conviction_size_scales=high_conviction_size_scales, ) unboosted_primary_fragility_size_scale = ( _orb_unboosted_primary_fragility_size_scale( params, cand, ticker_enrich, trigger_type=trigger_type_pass2, direction_str=direction_str, high_conviction_size_scales=high_conviction_size_scales, ) ) base_sizing, soft_day_sector_confirmation_override_min_day_size_scale = ( _orb_soft_day_sector_confirmation_override_base_sizing( params, active=soft_day_sector_confirmation_override_active, sizing_cap=sizing_cap, adjusted_sizing=adjusted_sizing, ) ) cand_sizing_before_distressed = ( base_sizing * min(entropy_scaler, 1.0) * min(crowded_gap_size_scale, 1.0) * min(countertrend_gap_size_scale, 1.0) ) cand_sizing_before_hot = ( cand_sizing_before_distressed * min(distressed_reclaim_size_scale, 1.0) ) cand_sizing_before_weak_downside = cand_sizing_before_hot * min( hot_reclaim_size_scale, 1.0 ) cand_sizing_before_stalled = cand_sizing_before_weak_downside * min( weak_downside_reclaim_size_scale, 1.0 ) cand_sizing_before_quiet_downside = cand_sizing_before_stalled * min( quiet_downside_reclaim_size_scale, 1.0 ) cand_sizing_before_liquid_stalled = cand_sizing_before_quiet_downside * min( stalled_gap_up_size_scale, 1.0 ) cand_sizing_before_stale_obv = cand_sizing_before_liquid_stalled * min( liquid_stalled_gap_up_size_scale, 1.0 ) cand_sizing_before_ranked_downside = cand_sizing_before_stale_obv * min( stale_obv_reversal_size_scale, 1.0 ) cand_sizing_before_rank_rvol_pressure = cand_sizing_before_ranked_downside * min( ranked_downside_gap_size_scale, 1.0 ) cand_sizing = cand_sizing_before_rank_rvol_pressure * min( rank_rvol_pressure_size_scale, 1.0 ) cand_sizing *= min(isolated_downside_size_scale, 1.0) cand_sizing *= min(isolated_downside_pressure_size_scale, 1.0) cand_sizing *= min(overextended_downside_reclaim_size_scale, 1.0) cand_sizing *= min(mid_attention_exhaustion_size_scale, 1.0) cand_sizing *= min(mid_liquidity_fragility_size_scale, 1.0) cand_sizing_before_orphan_thin_attention = cand_sizing cand_sizing *= min(orphan_thin_attention_size_scale, 1.0) cand_sizing_before_gap_up_fill_trap = cand_sizing cand_sizing *= min(gap_up_fill_trap_size_scale, 1.0) cand_sizing_before_low_candidate_quality = cand_sizing cand_sizing *= min(low_candidate_quality_size_scale, 1.0) cand_sizing *= min(gap_exhaustion_pressure_size_scale, 1.0) cand_sizing_before_stale_obv_rvol_pressure = cand_sizing cand_sizing *= min(stale_obv_rvol_pressure_size_scale, 1.0) cand_sizing_before_positive_gap_rebound_failure = cand_sizing cand_sizing *= min(positive_gap_rebound_failure_size_scale, 1.0) cand_sizing *= red_to_green_acceleration_size_scale cand_sizing *= liquid_leader_conviction_size_scale cand_sizing *= opening_burst_liquid_size_scale cand_sizing *= ownership_initial_size_scale cand_sizing *= form4_size_scale cand_sizing_before_unsupported_attention = cand_sizing cand_sizing *= unsupported_attention_size_scale cand_sizing *= unboosted_primary_fragility_size_scale cand_sizing *= sector_confirmation_size_scale cand_sizing *= soft_day_sector_confirmation_override_size_scale cand_sizing *= entry_market_guard_size_scale if trigger_type_pass2 == "vwap_reclaim": cand_sizing *= min( 1.0, max(0.0, float(params.nofill_vwap_reclaim_size_scale or 0.0)), ) elif trigger_type_pass2 == "late_breakout": cand_sizing *= min( 1.0, max(0.0, float(params.late_breakout_size_scale or 0.0)), ) late_floor_pct = getattr(params, "late_breakout_sizing_floor_pct", None) if late_floor_pct is not None: cand_sizing = max( cand_sizing, sizing_cap * max(0.0, float(late_floor_pct)), ) elif trigger_type_pass2 == "soft_day_vwap_reclaim": cand_sizing *= _orb_soft_day_vwap_size_scale( params, result.soft_day_reason, ) elif trigger_type_pass2 == "broad_gapup_continuation": cand_sizing *= min( 1.0, max( 0.0, float( getattr( params, "broad_gapup_continuation_size_scale", 1.0, ) or 0.0 ), ), ) elif trigger_type_pass2 in { "market_thrust_liquid_continuation", "market_thrust_opening_burst", "market_thrust_opening_followthrough", }: liquid_size_scale = getattr( params, "market_thrust_liquid_continuation_size_scale", 1.0, ) if not market_thrust_breadth_override_active: no_thrust_liquid_size_scale = getattr( params, "market_thrust_liquid_continuation_no_thrust_size_scale", None, ) if no_thrust_liquid_size_scale is not None: liquid_size_scale = no_thrust_liquid_size_scale cand_sizing *= min(1.0, max(0.0, float(liquid_size_scale or 0.0))) elif trigger_type_pass2 == "market_thrust_opening_impulse_reclaim": impulse_size_scale = getattr( params, "market_thrust_opening_impulse_reclaim_size_scale", 1.0, ) if not market_thrust_breadth_override_active: no_thrust_impulse_size_scale = getattr( params, "market_thrust_opening_impulse_reclaim_no_thrust_size_scale", None, ) if no_thrust_impulse_size_scale is not None: impulse_size_scale = no_thrust_impulse_size_scale cand_sizing *= min( 1.0, max( 0.0, float(impulse_size_scale or 0.0), ), ) elif trigger_type_pass2 == "intraday_continuation_reclaim": cand_sizing *= min( 1.0, max( 0.0, float( getattr( params, "intraday_continuation_reclaim_size_scale", 1.0, ) or 0.0 ), ), ) late_trade_size_scale = 1.0 if ( params.late_trade_size_scale_after_n is not None and len(result.trades) >= max(0, int(params.late_trade_size_scale_after_n)) ): late_trade_size_scale = max( 0.0, min(1.0, float(params.late_trade_size_scale)), ) cand_sizing *= late_trade_size_scale if cand.get("distressed_reclaim_skip_trade"): if cand.get("distressed_reclaim_reserve_cash"): reserved = cand_sizing_before_distressed * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if cand.get("hot_reclaim_skip_trade"): if cand.get("hot_reclaim_reserve_cash"): reserved = cand_sizing_before_hot * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if cand.get("weak_downside_reclaim_skip_trade"): if cand.get("weak_downside_reclaim_reserve_cash"): reserved = cand_sizing_before_weak_downside * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if cand.get("quiet_downside_reclaim_skip_trade"): if cand.get("quiet_downside_reclaim_reserve_cash"): reserved = cand_sizing_before_stalled * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if cand.get("stalled_gap_up_skip_trade"): if cand.get("stalled_gap_up_reserve_cash"): reserved = cand_sizing_before_quiet_downside * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if cand.get("liquid_stalled_gap_up_skip_trade"): if cand.get("liquid_stalled_gap_up_reserve_cash"): reserved = cand_sizing_before_liquid_stalled * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if cand.get("stale_obv_reversal_skip_trade"): if cand.get("stale_obv_reversal_reserve_cash"): reserved = cand_sizing_before_stale_obv * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if gap_up_fill_trap_active and gap_up_fill_trap_size_scale <= 0.0: if bool(getattr(params, "gap_up_fill_trap_reserve_full_cash", False)): reserved = cand_sizing_before_gap_up_fill_trap * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if low_candidate_quality_active and low_candidate_quality_size_scale <= 0.0: if bool(getattr(params, "low_candidate_quality_reserve_full_cash", False)): reserved = cand_sizing_before_low_candidate_quality * params.max_position_pct if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue if positive_gap_rebound_failure_size_scale <= 0.0: if bool( getattr( params, "positive_gap_rebound_failure_reserve_full_cash", False, ) ): reserved = ( cand_sizing_before_positive_gap_rebound_failure * params.max_position_pct ) if max_deploy is not None: reserved = min(reserved, max(0.0, max_deploy - total_deployed)) if remaining_cash is not None: reserved = min(reserved, max(0.0, remaining_cash)) remaining_cash -= reserved total_deployed += reserved if params.max_simultaneous_entries is not None and reserved > 0: ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 continue # Forced entry bar for alternate triggers. forced_entry_bar_pass2: dict | None = None if trigger_type_pass2 == "momentum_confirm" and forced_price_pass2 is not None: orb_ts_raw = _parse_ts(cand["orb_bar"]["timestamp"]) post_bars = [b for b in cand["mkt_bars"] if _parse_ts(b["timestamp"]) > orb_ts_raw] forced_entry_bar_pass2 = post_bars[1] if len(post_bars) >= 2 else None elif ( trigger_type_pass2 in { "vwap_reclaim", "soft_day_vwap_reclaim", "late_breakout", "broad_gapup_continuation", "market_thrust_opening_burst", "market_thrust_opening_followthrough", "market_thrust_opening_impulse_reclaim", "intraday_continuation_reclaim", } and forced_price_pass2 is not None ): forced_entry_bar_pass2 = next( ( b for b in cand["mkt_bars"] if _parse_ts(b["timestamp"]) == entry_ts_pass2 ), None, ) candidate_trade_params = trade_params if ( cand.get("crowded_gap_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("countertrend_gap_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("distressed_reclaim_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("hot_reclaim_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("weak_downside_reclaim_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("quiet_downside_reclaim_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("stale_obv_reversal_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("stalled_gap_up_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("liquid_stalled_gap_up_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if cand.get("gap_up_fill_exit_active") and not candidate_trade_params.exit_on_gap_fill: candidate_trade_params = candidate_trade_params.model_copy( update={"exit_on_gap_fill": True} ) soft_day_sector_confirmation_override_loss_cap_pct = None if soft_day_sector_confirmation_override_active: raw_soft_sector_loss_cap = getattr( params, "soft_day_sector_confirmation_override_loss_cap_pct", None, ) if raw_soft_sector_loss_cap is not None: soft_day_sector_confirmation_override_loss_cap_pct = max( 0.0, float(raw_soft_sector_loss_cap), ) loss_cap_values = [ float(value) for value in ( cand.get("distressed_reclaim_loss_cap_pct"), cand.get("hot_reclaim_loss_cap_pct"), cand.get("weak_downside_reclaim_loss_cap_pct"), cand.get("quiet_downside_reclaim_loss_cap_pct"), cand.get("stale_obv_reversal_loss_cap_pct"), cand.get("stalled_gap_up_loss_cap_pct"), cand.get("liquid_stalled_gap_up_loss_cap_pct"), cand.get("thin_gap_up_loss_cap_pct"), cand.get("moderate_downside_loss_cap_pct"), ranked_downside_gap_loss_cap_pct, isolated_downside_loss_cap_pct, soft_day_sector_confirmation_override_loss_cap_pct, ( getattr(params, "broad_gapup_continuation_fixed_loss_pct", None) if trigger_type_pass2 == "broad_gapup_continuation" else None ), ( getattr( params, "market_thrust_liquid_continuation_fixed_loss_pct", None, ) if trigger_type_pass2 in { "market_thrust_liquid_continuation", "market_thrust_opening_burst", "market_thrust_opening_followthrough", } else None ), ( getattr( params, "market_thrust_opening_impulse_reclaim_fixed_loss_pct", None, ) if trigger_type_pass2 == "market_thrust_opening_impulse_reclaim" else None ), ( getattr( params, "intraday_continuation_reclaim_fixed_loss_pct", None, ) if trigger_type_pass2 == "intraday_continuation_reclaim" else None ), ) if value is not None ] if loss_cap_values: candidate_trade_params = candidate_trade_params.model_copy( update={"fixed_loss_pct": min(loss_cap_values)} ) simulate_reject: dict[str, str] = {} pyramid_allowed_for_candidate = not ( bool(getattr(params, "pyramid_require_sector_confirmation", False)) and not bool(cand.get("sector_confirmation_active")) ) available_cash_for_trade = remaining_cash trade = simulate_orb_trade( mkt_bars=cand["mkt_bars"], orb_bar=cand["orb_bar"], direction=direction_str, atr=cand["atr"], rvol=cand["rvol"], gap_pct=cand["gap_pct"], params=candidate_trade_params, equity=equity, date_str=date_str, ticker=cand["ticker"], available_cash=available_cash_for_trade, sizing_capital=cand_sizing, score_rank_pct=score_rank_pct, prev_close=cand_prev_close, spy_bars=spy_bars, is_soft_day=is_soft_day, trigger_type=trigger_type_pass2, forced_entry_price=forced_price_pass2, forced_entry_bar=forced_entry_bar_pass2, reject_reason_out=simulate_reject, pyramid_allowed=pyramid_allowed_for_candidate, candidate_score=_float_or_none(cand.get("score")), ) if trade is None: _reject("simulate_trade_none") reason = simulate_reject.get("reason") if reason: _reject(f"simulate_trade_none:{reason}") continue trade_deployed = trade.total_capital_deployed or (trade.shares * trade.entry_price) _attach_orb_candidate_diagnostics(trade, cand, ticker_enrich, score_rank_pct) trade.reclaim_entry_attention_rank_pct = _optional_float( reclaim_entry_attention_diag.get("reclaim_entry_attention_rank_pct"), 6, ) trade.entry_bar_dollar_vol_rank_pct = _optional_float( reclaim_entry_attention_diag.get("entry_bar_dollar_vol_rank_pct"), 6, ) trade.entry_cumulative_dollar_vol_rank_pct = _optional_float( reclaim_entry_attention_diag.get("entry_cumulative_dollar_vol_rank_pct"), 6, ) trade.entry_rel_volume_rank_pct = _optional_float( reclaim_entry_attention_diag.get("entry_rel_volume_rank_pct"), 6, ) trade.entry_rel_volume = _optional_float( reclaim_entry_attention_diag.get("entry_rel_volume"), 6, ) trade.late_trade_size_scale = ( round(late_trade_size_scale, 4) if late_trade_size_scale < 1.0 else None ) trade.rank_rvol_pressure_size_scale = ( round(rank_rvol_pressure_size_scale, 4) if rank_rvol_pressure_size_scale < 1.0 else None ) trade.gap_exhaustion_pressure_size_scale = ( round(gap_exhaustion_pressure_size_scale, 4) if gap_exhaustion_pressure_size_scale < 1.0 else None ) trade.stale_obv_rvol_pressure_size_scale = ( round(stale_obv_rvol_pressure_size_scale, 4) if stale_obv_rvol_pressure_size_scale < 1.0 else None ) trade.red_to_green_acceleration_size_scale = ( round(red_to_green_acceleration_size_scale, 4) if red_to_green_acceleration_size_scale > 1.0 else None ) trade.liquid_leader_conviction_size_scale = ( round(liquid_leader_conviction_size_scale, 4) if liquid_leader_conviction_size_scale > 1.0 else None ) trade.opening_burst_liquid_size_scale = ( round(opening_burst_liquid_size_scale, 4) if opening_burst_liquid_size_scale > 1.0 else None ) trade.ownership_initial_size_scale = ( round(ownership_initial_size_scale, 4) if ownership_initial_size_scale > 1.0 else None ) trade.form4_size_scale = ( round(form4_size_scale, 4) if form4_size_scale > 1.0 else None ) trade.unboosted_primary_fragility_size_scale = ( round(unboosted_primary_fragility_size_scale, 4) if unboosted_primary_fragility_size_scale < 1.0 else None ) trade.unsupported_attention_size_scale = ( round(unsupported_attention_size_scale, 4) if unsupported_attention_size_scale < 1.0 else None ) trade.positive_gap_rebound_failure_size_scale = ( round(positive_gap_rebound_failure_size_scale, 4) if positive_gap_rebound_failure_size_scale < 1.0 else None ) trade.isolated_downside_loss_cap_active = isolated_downside_loss_cap_active trade.isolated_downside_loss_cap_pct = ( round(isolated_downside_loss_cap_pct, 4) if isolated_downside_loss_cap_pct is not None else None ) trade.isolated_downside_size_scale = ( round(isolated_downside_size_scale, 4) if isolated_downside_size_scale < 1.0 else None ) trade.isolated_downside_pressure_active = isolated_downside_pressure_active trade.isolated_downside_pressure_size_scale = ( round(isolated_downside_pressure_size_scale, 4) if isolated_downside_pressure_size_scale < 1.0 else None ) trade.overextended_downside_reclaim_active = overextended_downside_reclaim_active trade.overextended_downside_reclaim_size_scale = ( round(overextended_downside_reclaim_size_scale, 4) if overextended_downside_reclaim_size_scale < 1.0 else None ) trade.mid_attention_exhaustion_active = mid_attention_exhaustion_active trade.mid_attention_exhaustion_size_scale = ( round(mid_attention_exhaustion_size_scale, 4) if mid_attention_exhaustion_size_scale < 1.0 else None ) trade.mid_liquidity_fragility_active = mid_liquidity_fragility_active trade.mid_liquidity_fragility_size_scale = ( round(mid_liquidity_fragility_size_scale, 4) if mid_liquidity_fragility_size_scale < 1.0 else None ) trade.orphan_thin_attention_active = orphan_thin_attention_active trade.orphan_thin_attention_size_scale = ( round(orphan_thin_attention_size_scale, 4) if orphan_thin_attention_size_scale < 1.0 else None ) trade.gap_up_fill_trap_active = gap_up_fill_trap_active trade.gap_up_fill_trap_size_scale = ( round(gap_up_fill_trap_size_scale, 4) if gap_up_fill_trap_size_scale < 1.0 else None ) trade.low_candidate_quality_active = low_candidate_quality_active trade.low_candidate_quality_size_scale = ( round(low_candidate_quality_size_scale, 4) if low_candidate_quality_size_scale < 1.0 else None ) trade.broad_gapup_continuation = bool(cand.get("broad_gapup_continuation")) trade.broad_gapup_continuation_size_scale = ( round(float(cand.get("broad_gapup_continuation_size_scale") or 1.0), 4) if cand.get("broad_gapup_continuation") else None ) trade.intraday_continuation_reclaim = bool( cand.get("intraday_continuation_reclaim") ) trade.intraday_continuation_reclaim_size_scale = ( round( float(cand.get("intraday_continuation_reclaim_size_scale") or 1.0), 4, ) if cand.get("intraday_continuation_reclaim") else None ) trade.market_thrust_liquid_continuation = bool( cand.get("market_thrust_liquid_continuation") ) trade.market_thrust_liquid_continuation_size_scale = ( round( float(cand.get("market_thrust_liquid_continuation_size_scale") or 1.0), 4, ) if cand.get("market_thrust_liquid_continuation") else None ) trade.market_thrust_opening_burst = ( trigger_type_pass2 == "market_thrust_opening_burst" ) trade.market_thrust_opening_followthrough = ( trigger_type_pass2 == "market_thrust_opening_followthrough" ) trade.sector_confirmation_size_scale = ( round(sector_confirmation_size_scale, 4) if sector_confirmation_size_scale != 1.0 else None ) trade.soft_day_sector_confirmation_override_size_scale = ( round(soft_day_sector_confirmation_override_size_scale, 4) if soft_day_sector_confirmation_override_active else None ) trade.soft_day_sector_confirmation_override_min_day_size_scale = ( round(soft_day_sector_confirmation_override_min_day_size_scale, 4) if soft_day_sector_confirmation_override_min_day_size_scale is not None else None ) trade.entry_market_guard_active = entry_market_guard_active trade.entry_market_guard_return_pct = _optional_float( entry_market_guard_return, 6, ) trade.entry_market_guard_size_scale = ( round(entry_market_guard_size_scale, 4) if entry_market_guard_size_scale != 1.0 else None ) trade.soft_day_trade = is_soft_day trade.soft_day_reason = result.soft_day_reason result.trades.append(trade) if trigger_type_pass2 == "vwap_reclaim": nofill_vwap_trades += 1 elif trigger_type_pass2 == "late_breakout": late_breakout_trades += 1 elif trigger_type_pass2 == "soft_day_vwap_reclaim": soft_day_vwap_trades += 1 elif trigger_type_pass2 == "broad_gapup_continuation": broad_gapup_continuation_trades += 1 elif trigger_type_pass2 == "market_thrust_liquid_continuation": market_thrust_liquid_continuation_trades += 1 elif trigger_type_pass2 == "market_thrust_opening_burst": market_thrust_opening_burst_trades += 1 elif trigger_type_pass2 == "market_thrust_opening_followthrough": market_thrust_opening_followthrough_trades += 1 elif trigger_type_pass2 == "market_thrust_opening_impulse_reclaim": market_thrust_opening_impulse_reclaim_trades += 1 elif trigger_type_pass2 == "intraday_continuation_reclaim": intraday_continuation_reclaim_trades += 1 if soft_day_sector_confirmation_override_active: soft_day_sector_confirmation_override_trades += 1 traded_tickers.add(cand["ticker"]) result.daily_pnl += trade.pnl # Track simultaneous entries count ts_key = entry_ts_pass2.isoformat() entries_at_ts[ts_key] = entries_at_ts.get(ts_key, 0) + 1 # Track total deployed capital (original + pyramid) total_deployed += trade_deployed # Deduct deployed capital from remaining settled cash if remaining_cash is not None: remaining_cash -= trade_deployed if cand.get("distressed_reclaim_reserve_full_cash"): full_slot_cash = cand_sizing_before_distressed * params.max_position_pct extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if cand.get("hot_reclaim_reserve_full_cash"): full_slot_cash = cand_sizing_before_hot * params.max_position_pct extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if cand.get("weak_downside_reclaim_reserve_full_cash"): full_slot_cash = cand_sizing_before_weak_downside * params.max_position_pct extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if cand.get("quiet_downside_reclaim_reserve_full_cash"): full_slot_cash = cand_sizing_before_stalled * params.max_position_pct extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if cand.get("stalled_gap_up_reserve_full_cash"): full_slot_cash = cand_sizing_before_quiet_downside * params.max_position_pct extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if cand.get("liquid_stalled_gap_up_reserve_full_cash"): full_slot_cash = cand_sizing_before_liquid_stalled * params.max_position_pct extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if ranked_downside_gap_reserve_full_cash: full_slot_cash = cand_sizing_before_ranked_downside * params.max_position_pct extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if ( stale_obv_rvol_pressure_size_scale < 1.0 and bool(getattr(params, "stale_obv_rvol_pressure_reserve_full_cash", False)) ): full_slot_cash = ( cand_sizing_before_stale_obv_rvol_pressure * params.max_position_pct ) extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if ( positive_gap_rebound_failure_size_scale < 1.0 and bool( getattr( params, "positive_gap_rebound_failure_reserve_full_cash", False, ) ) ): full_slot_cash = ( cand_sizing_before_positive_gap_rebound_failure * params.max_position_pct ) extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min( extra_reserved, max(0.0, max_deploy - total_deployed), ) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if ( orphan_thin_attention_size_scale < 1.0 and bool(getattr(params, "orphan_thin_attention_reserve_full_cash", False)) ): full_slot_cash = ( cand_sizing_before_orphan_thin_attention * params.max_position_pct ) extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if ( gap_up_fill_trap_size_scale < 1.0 and bool(getattr(params, "gap_up_fill_trap_reserve_full_cash", False)) ): full_slot_cash = ( cand_sizing_before_gap_up_fill_trap * params.max_position_pct ) extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if ( low_candidate_quality_size_scale < 1.0 and bool(getattr(params, "low_candidate_quality_reserve_full_cash", False)) ): full_slot_cash = ( cand_sizing_before_low_candidate_quality * params.max_position_pct ) extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if ( unsupported_attention_size_scale < 1.0 and bool(getattr(params, "unsupported_attention_reserve_full_cash", False)) ): full_slot_cash = ( cand_sizing_before_unsupported_attention * params.max_position_pct ) extra_reserved = max(0.0, full_slot_cash - trade_deployed) if max_deploy is not None: extra_reserved = min(extra_reserved, max(0.0, max_deploy - total_deployed)) extra_reserved = min(extra_reserved, max(0.0, remaining_cash)) remaining_cash -= extra_reserved total_deployed += extra_reserved if result.entry_diagnostics is None: result.entry_diagnostics = {} result.entry_diagnostics.update( { "primary_trades": len( [ t for t in result.trades if t.trigger_type not in {"vwap_reclaim", "soft_day_vwap_reclaim", "late_breakout"} ] ), "nofill_vwap_trades": nofill_vwap_trades, "late_breakout_trades": late_breakout_trades, "soft_day_vwap_trades": soft_day_vwap_trades, "broad_gapup_continuation_trades": broad_gapup_continuation_trades, "market_thrust_liquid_continuation_trades": ( market_thrust_liquid_continuation_trades ), "market_thrust_opening_burst_trades": market_thrust_opening_burst_trades, "market_thrust_opening_followthrough_trades": ( market_thrust_opening_followthrough_trades ), "market_thrust_opening_impulse_reclaim_trades": ( market_thrust_opening_impulse_reclaim_trades ), "intraday_continuation_reclaim_trades": ( intraday_continuation_reclaim_trades ), "soft_day_sector_confirmation_override_trades": ( soft_day_sector_confirmation_override_trades ), "entry_reject_stats": entry_reject_stats, } ) # ── Pass 3 (optional): Re-entry after stop-out ── if params.reentry_after_stop: stopped_trades = [ t for t in result.trades if t.exit_reason == "stop_loss" and not t.is_reentry ] for stopped_trade in stopped_trades: # Check re-entry count for this ticker reentries_done = sum( 1 for t in result.trades if t.ticker == stopped_trade.ticker and t.is_reentry ) if reentries_done >= params.reentry_max_per_ticker: continue # Deployment cap check if max_deploy is not None and total_deployed >= max_deploy: break # Find original candidate data cand_match = next( ((c, d) for _, c, d, *_ in timed_candidates if c["ticker"] == stopped_trade.ticker), None, ) if cand_match is None: continue cand, direction_str = cand_match if ( cand.get("distressed_reclaim_skip_trade") or cand.get("hot_reclaim_skip_trade") or cand.get("weak_downside_reclaim_skip_trade") or cand.get("quiet_downside_reclaim_skip_trade") or cand.get("stale_obv_reversal_skip_trade") or cand.get("stalled_gap_up_skip_trade") or cand.get("liquid_stalled_gap_up_skip_trade") ): continue # Score rank for re-entry (same as original) n_cands = len(candidates) cand_rank = next( (i for i, c in enumerate(candidates) if c["ticker"] == cand["ticker"]), n_cands - 1, ) score_rank_pct = 1.0 - (cand_rank / max(n_cands - 1, 1)) ticker_enrich = enrichment.get(cand["ticker"], {}).get(date_str, {}) cand_prev_close = ticker_enrich.get("prev_close") exit_ts = _parse_ts(stopped_trade.exit_time) candidate_trade_params = trade_params if ( cand.get("crowded_gap_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("countertrend_gap_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("distressed_reclaim_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("hot_reclaim_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("weak_downside_reclaim_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("quiet_downside_reclaim_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("stale_obv_reversal_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("stalled_gap_up_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if ( cand.get("liquid_stalled_gap_up_requires_confirmation") and not candidate_trade_params.require_confirmation_bar ): candidate_trade_params = candidate_trade_params.model_copy( update={"require_confirmation_bar": True} ) if cand.get("gap_up_fill_exit_active") and not candidate_trade_params.exit_on_gap_fill: candidate_trade_params = candidate_trade_params.model_copy( update={"exit_on_gap_fill": True} ) loss_cap_values = [ float(value) for value in ( cand.get("distressed_reclaim_loss_cap_pct"), cand.get("hot_reclaim_loss_cap_pct"), cand.get("weak_downside_reclaim_loss_cap_pct"), cand.get("quiet_downside_reclaim_loss_cap_pct"), cand.get("stale_obv_reversal_loss_cap_pct"), cand.get("stalled_gap_up_loss_cap_pct"), cand.get("liquid_stalled_gap_up_loss_cap_pct"), cand.get("thin_gap_up_loss_cap_pct"), cand.get("moderate_downside_loss_cap_pct"), ) if value is not None ] if loss_cap_values: candidate_trade_params = candidate_trade_params.model_copy( update={"fixed_loss_pct": min(loss_cap_values)} ) crowded_gap_size_scale = float(cand.get("crowded_gap_size_scale") or 1.0) countertrend_gap_size_scale = float( cand.get("countertrend_gap_size_scale") or 1.0 ) distressed_reclaim_size_scale = float( cand.get("distressed_reclaim_size_scale") or 1.0 ) hot_reclaim_size_scale = float(cand.get("hot_reclaim_size_scale") or 1.0) weak_downside_reclaim_size_scale = float( cand.get("weak_downside_reclaim_size_scale") or 1.0 ) quiet_downside_reclaim_size_scale = float( cand.get("quiet_downside_reclaim_size_scale") or 1.0 ) stale_obv_reversal_size_scale = float( cand.get("stale_obv_reversal_size_scale") or 1.0 ) stalled_gap_up_size_scale = float(cand.get("stalled_gap_up_size_scale") or 1.0) liquid_stalled_gap_up_size_scale = float( cand.get("liquid_stalled_gap_up_size_scale") or 1.0 ) rank_rvol_pressure_size_scale = 1.0 rank_rvol_pressure_min_rvol = getattr( params, "rank_rvol_pressure_min_rvol", None ) rank_rvol_pressure_max_rank = getattr( params, "rank_rvol_pressure_max_score_rank_pct", None ) if ( rank_rvol_pressure_min_rvol is not None and rank_rvol_pressure_max_rank is not None and float(cand.get("rvol") or 0.0) >= float(rank_rvol_pressure_min_rvol) and score_rank_pct <= float(rank_rvol_pressure_max_rank) ): rank_rvol_ret_5d = ticker_enrich.get("ret_5d") rank_rvol_max_ret_5d = getattr( params, "rank_rvol_pressure_max_ret_5d", None ) if ( rank_rvol_max_ret_5d is None or ( rank_rvol_ret_5d is not None and float(rank_rvol_ret_5d) <= float(rank_rvol_max_ret_5d) ) ): raw_rank_rvol_scale = getattr( params, "rank_rvol_pressure_size_scale", 1.0, ) rank_rvol_pressure_size_scale = max( 0.0, min( 1.0, float( 1.0 if raw_rank_rvol_scale is None else raw_rank_rvol_scale ), ), ) gap_exhaustion_pressure_size_scale = ( _orb_gap_exhaustion_pressure_size_scale( params, cand, ticker_enrich, direction_str, ) ) stale_obv_rvol_pressure_size_scale = ( _orb_stale_obv_rvol_pressure_size_scale( params, cand, ticker_enrich, score_rank_pct, ) ) positive_gap_rebound_failure_size_scale = ( _orb_positive_gap_rebound_failure_size_scale( params, cand, ticker_enrich, trigger_type="reentry", direction_str=direction_str, ) ) ( mid_attention_exhaustion_active, mid_attention_exhaustion_size_scale, ) = _orb_mid_attention_exhaustion_size_scale( params, cand, trigger_type="reentry", direction_str=direction_str, ) ( mid_liquidity_fragility_active, mid_liquidity_fragility_size_scale, ) = _orb_mid_liquidity_fragility_size_scale( params, cand, ticker_enrich, trigger_type="reentry", direction_str=direction_str, ) ( orphan_thin_attention_active, orphan_thin_attention_size_scale, ) = _orb_orphan_thin_attention_size_scale( params, cand, trigger_type="reentry", direction_str=direction_str, ) ( gap_up_fill_trap_active, gap_up_fill_trap_size_scale, ) = _orb_gap_up_fill_trap_size_scale( params, cand, trigger_type="reentry", direction_str=direction_str, ) ( low_candidate_quality_active, low_candidate_quality_size_scale, ) = _orb_low_candidate_quality_size_scale( params, cand, trigger_type="reentry", direction_str=direction_str, ) sector_confirmation_size_scale = _orb_sector_confirmation_size_scale( params, cand, ) defensive_risk_size_scales = ( crowded_gap_size_scale, countertrend_gap_size_scale, distressed_reclaim_size_scale, hot_reclaim_size_scale, weak_downside_reclaim_size_scale, quiet_downside_reclaim_size_scale, stalled_gap_up_size_scale, liquid_stalled_gap_up_size_scale, stale_obv_reversal_size_scale, rank_rvol_pressure_size_scale, gap_exhaustion_pressure_size_scale, stale_obv_rvol_pressure_size_scale, positive_gap_rebound_failure_size_scale, mid_attention_exhaustion_size_scale, mid_liquidity_fragility_size_scale, orphan_thin_attention_size_scale, gap_up_fill_trap_size_scale, low_candidate_quality_size_scale, min(sector_confirmation_size_scale, 1.0), ) red_to_green_acceleration_size_scale = ( _orb_red_to_green_acceleration_size_scale( params, cand, ticker_enrich, score_rank_pct, direction_str, defensive_risk_size_scales, ) ) liquid_leader_conviction_size_scale = ( _orb_liquid_leader_conviction_size_scale( params, cand, score_rank_pct, "reentry", direction_str, defensive_risk_size_scales, ) ) reentry_trade = simulate_orb_trade( mkt_bars=cand["mkt_bars"], orb_bar=cand["orb_bar"], direction=direction_str, atr=cand["atr"], rvol=cand["rvol"], gap_pct=cand["gap_pct"], params=candidate_trade_params, equity=equity, date_str=date_str, ticker=cand["ticker"], available_cash=remaining_cash, sizing_capital=( adjusted_sizing * min(crowded_gap_size_scale, 1.0) * min(countertrend_gap_size_scale, 1.0) * min(distressed_reclaim_size_scale, 1.0) * min(hot_reclaim_size_scale, 1.0) * min(weak_downside_reclaim_size_scale, 1.0) * min(quiet_downside_reclaim_size_scale, 1.0) * min(stale_obv_reversal_size_scale, 1.0) * min(stalled_gap_up_size_scale, 1.0) * min(liquid_stalled_gap_up_size_scale, 1.0) * min(rank_rvol_pressure_size_scale, 1.0) * min(gap_exhaustion_pressure_size_scale, 1.0) * min(stale_obv_rvol_pressure_size_scale, 1.0) * min(positive_gap_rebound_failure_size_scale, 1.0) * min(mid_attention_exhaustion_size_scale, 1.0) * min(mid_liquidity_fragility_size_scale, 1.0) * min(orphan_thin_attention_size_scale, 1.0) * min(gap_up_fill_trap_size_scale, 1.0) * min(low_candidate_quality_size_scale, 1.0) * red_to_green_acceleration_size_scale * liquid_leader_conviction_size_scale * sector_confirmation_size_scale ), score_rank_pct=score_rank_pct, prev_close=cand_prev_close, entry_after_ts=exit_ts, spy_bars=spy_bars, is_soft_day=is_soft_day, pyramid_allowed=not ( bool(getattr(params, "pyramid_require_sector_confirmation", False)) and not bool(cand.get("sector_confirmation_active")) ), candidate_score=_float_or_none(cand.get("score")), ) if reentry_trade is not None: reentry_trade.is_reentry = True _attach_orb_candidate_diagnostics( reentry_trade, cand, ticker_enrich, score_rank_pct ) reentry_trade.rank_rvol_pressure_size_scale = ( round(rank_rvol_pressure_size_scale, 4) if rank_rvol_pressure_size_scale < 1.0 else None ) reentry_trade.gap_exhaustion_pressure_size_scale = ( round(gap_exhaustion_pressure_size_scale, 4) if gap_exhaustion_pressure_size_scale < 1.0 else None ) reentry_trade.stale_obv_rvol_pressure_size_scale = ( round(stale_obv_rvol_pressure_size_scale, 4) if stale_obv_rvol_pressure_size_scale < 1.0 else None ) reentry_trade.positive_gap_rebound_failure_size_scale = ( round(positive_gap_rebound_failure_size_scale, 4) if positive_gap_rebound_failure_size_scale < 1.0 else None ) reentry_trade.mid_attention_exhaustion_active = ( mid_attention_exhaustion_active ) reentry_trade.mid_attention_exhaustion_size_scale = ( round(mid_attention_exhaustion_size_scale, 4) if mid_attention_exhaustion_size_scale < 1.0 else None ) reentry_trade.mid_liquidity_fragility_active = ( mid_liquidity_fragility_active ) reentry_trade.mid_liquidity_fragility_size_scale = ( round(mid_liquidity_fragility_size_scale, 4) if mid_liquidity_fragility_size_scale < 1.0 else None ) reentry_trade.orphan_thin_attention_active = ( orphan_thin_attention_active ) reentry_trade.orphan_thin_attention_size_scale = ( round(orphan_thin_attention_size_scale, 4) if orphan_thin_attention_size_scale < 1.0 else None ) reentry_trade.gap_up_fill_trap_active = gap_up_fill_trap_active reentry_trade.gap_up_fill_trap_size_scale = ( round(gap_up_fill_trap_size_scale, 4) if gap_up_fill_trap_size_scale < 1.0 else None ) reentry_trade.low_candidate_quality_active = ( low_candidate_quality_active ) reentry_trade.low_candidate_quality_size_scale = ( round(low_candidate_quality_size_scale, 4) if low_candidate_quality_size_scale < 1.0 else None ) reentry_trade.red_to_green_acceleration_size_scale = ( round(red_to_green_acceleration_size_scale, 4) if red_to_green_acceleration_size_scale > 1.0 else None ) reentry_trade.liquid_leader_conviction_size_scale = ( round(liquid_leader_conviction_size_scale, 4) if liquid_leader_conviction_size_scale > 1.0 else None ) reentry_trade.sector_confirmation_size_scale = ( round(sector_confirmation_size_scale, 4) if sector_confirmation_size_scale != 1.0 else None ) reentry_trade.soft_day_trade = is_soft_day reentry_trade.soft_day_reason = result.soft_day_reason result.trades.append(reentry_trade) result.daily_pnl += reentry_trade.pnl re_deployed = reentry_trade.total_capital_deployed or ( reentry_trade.shares * reentry_trade.entry_price ) total_deployed += re_deployed if remaining_cash is not None: remaining_cash -= re_deployed result.skipped_insufficient_cash = skipped_cash result.capital_deployed = sum( t.total_capital_deployed or (t.shares * t.entry_price) for t in result.trades ) # Note: daily_return_pct is set by run_orb_simulation (portfolio-level: PnL/equity). # Default 0.0 is correct for no-trade days. return result # ── Full Backtest Simulation ─────────────────────────────────────────────── def run_orb_simulation_with_state( all_intraday: dict[str, dict[str, list[dict]]], trading_days: list[str], params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], ticker_sectors: dict[str, str] | None = None, state: ORBSimulationState | None = None, progress_callback: Callable[[int, int], None] | None = None, vix_by_day: dict[str, float] | None = None, overlay_tickers_per_day: dict[str, set[str]] | None = None, next_trading_day_after_window: str | None = None, ) -> tuple[list[DayResult], ORBSimulationState]: """Run the full ORB backtest simulation across all trading days. Key differences from run_simulation() (momentum): - Uses compounding equity (position sizing depends on current equity) - ATR-based stop loss (dynamic, not fixed %) - Breakout entry (conditional, can miss) - RVOL + gap composite ranking Pure computation — no API calls, no disk I/O. Safe to call repeatedly with different params for sweep mode. Args: all_intraday: {date: {ticker: [bars]}} — pre-loaded intraday data. trading_days: Ordered list of dates to simulate. params: ORB strategy parameters. enrichment: {ticker: {date: features}} from enrich_daily_bars(). Returns: (day_results, next_state) where next_state can be fed into the next chunk. """ results: list[DayResult] = [] equity = state.equity if state is not None else params.initial_capital # Ticker cooldown tracker ticker_last_traded: dict[str, dt.date] = ( {ticker: dt.date.fromisoformat(last_date) for ticker, last_date in state.ticker_last_traded.items()} if state is not None else {} ) # GFV / settlement tracking (only active when settlement_days > 0). # daily_budget_reset is a research mode with a fresh fixed buying-power # budget each day, so it must not inherit accumulated settled cash. # settled_cash: funds available for new day-trade positions (GFV-safe) # pending_settlements: (settlement_date_str, amount) — proceeds awaiting settlement daily_cash_reset = bool(params.daily_budget_reset) settlement_enabled = params.settlement_days > 0 and not daily_cash_reset settled_cash = ( state.settled_cash if state is not None and state.settled_cash is not None else params.initial_capital ) pending_settlements: list[tuple[str, float]] = ( list(state.pending_settlements) if state is not None else [] ) pending_idle_sleeve_positions: list[dict] = ( list(state.pending_idle_sleeve_positions) if state is not None else [] ) # Rolling PnL window: persists cross-chunk daily PnL history for rolling loss filter. # Trimmed to rolling_loss_days length so memory stays bounded. rolling_pnl_window: list[float] = list(state.recent_daily_pnl) if state is not None else [] # Drawdown governor: track peak equity to detect drawdowns peak_equity = state.peak_equity if state is not None and state.peak_equity is not None else equity # Streak sizing: track recent trade outcomes for streak-based sizing streak_outcomes: list[bool] = list(state.streak_outcomes) if state is not None else [] total_days = len(trading_days) for day_idx, date_str in enumerate(trading_days): if progress_callback: progress_callback(day_idx + 1, total_days) bars_by_ticker = all_intraday.get(date_str) if not bars_by_ticker: # No intraday data for this day — still record it (0% return, no trades) results.append(DayResult(date=date_str)) rolling_pnl_window.append(0.0) continue # Step 1: Move proceeds that have reached their settlement date into settled_cash if settlement_enabled: still_pending = [] for settle_date, amount in pending_settlements: if settle_date <= date_str: settled_cash += amount else: still_pending.append((settle_date, amount)) pending_settlements = still_pending idle_exit_trades: list[IntradayTrade] = [] idle_exit_pnl = 0.0 if getattr(params, "orb_idle_sleeve_enabled", False) and pending_idle_sleeve_positions: idle_exit_trades, pending_idle_sleeve_positions = _close_idle_sleeve_positions( pending_idle_sleeve_positions, bars_by_ticker, date_str, params, enrichment, ) if idle_exit_trades: idle_exit_pnl = sum(trade.pnl for trade in idle_exit_trades) equity += idle_exit_pnl equity = max(equity, 1.0) peak_equity = max(peak_equity, equity) if settlement_enabled: for trade in idle_exit_trades: proceeds = (trade.total_capital_deployed or 0.0) + trade.pnl if proceeds <= 0: continue settle_idx = day_idx + params.settlement_days if settle_idx < len(trading_days): pending_settlements.append((trading_days[settle_idx], proceeds)) else: settled_cash += proceeds # Build blacklist from cooldown blacklisted: set[str] = set() if params.ticker_cooldown_days > 0: current_date = dt.date.fromisoformat(date_str) for ticker, last_dt in ticker_last_traded.items(): if (current_date - last_dt).days <= params.ticker_cooldown_days: blacklisted.add(ticker) # Extract SPY bars for regime filter spy_bars = ( bars_by_ticker.get("SPY") if params.market_regime_spy_threshold is not None else None ) # Rolling strategy loss filter: pause trading after sustained self-drawdown. # Uses rolling_pnl_window which persists across chunk boundaries (unlike results[]). if ( params.rolling_loss_days is not None and params.rolling_loss_threshold is not None and len(rolling_pnl_window) >= params.rolling_loss_days ): n_roll = params.rolling_loss_days rolling_pnl = sum(rolling_pnl_window[-n_roll:]) if params.daily_budget_reset or not params.compound_returns: sizing_capital_for_check = params.initial_capital else: sizing_capital_for_check = equity if sizing_capital_for_check > 0: rolling_return = rolling_pnl / sizing_capital_for_check if rolling_return < params.rolling_loss_threshold: skip_result = DayResult( date=date_str, trades=list(idle_exit_trades), daily_pnl=idle_exit_pnl, skip_reason="rolling_loss", ) return_base = ( params.initial_capital if (params.daily_budget_reset or not params.compound_returns) else equity ) if return_base > 0: skip_result.daily_return_pct = skip_result.daily_pnl / return_base results.append(skip_result) rolling_pnl_window.append(skip_result.daily_pnl) if settlement_enabled: still_pending = [] for settle_date, amount in pending_settlements: if settle_date <= date_str: settled_cash += amount else: still_pending.append((settle_date, amount)) pending_settlements = still_pending continue # Multi-day SPY trend filter: skip if SPY is in a sustained downtrend # Uses enrichment[spy_ticker][date]["prev_close"] for N-day cumulative return. # enrichment[D]["prev_close"] = close of trading day before D. # N-day return = (close_yesterday - close_N_days_ago) / close_N_days_ago # = (enrich[today]["prev_close"] - enrich[trading_days[day_idx-N+1]]["prev_close"]) # / enrich[trading_days[day_idx-N+1]]["prev_close"] if ( params.market_regime_spy_trend_days is not None and params.market_regime_spy_trend_threshold is not None and day_idx >= params.market_regime_spy_trend_days ): spy_trend_ticker = getattr(params, "market_regime_ticker", None) or "SPY" spy_enrich = enrichment.get(spy_trend_ticker, {}) close_yesterday = spy_enrich.get(date_str, {}).get("prev_close") n_days_back = params.market_regime_spy_trend_days look_back_date = trading_days[day_idx - n_days_back + 1] close_n_ago = spy_enrich.get(look_back_date, {}).get("prev_close") if close_yesterday and close_n_ago and close_n_ago > 0: spy_trend_return = (close_yesterday - close_n_ago) / close_n_ago if spy_trend_return < params.market_regime_spy_trend_threshold: skip_result = DayResult( date=date_str, trades=list(idle_exit_trades), daily_pnl=idle_exit_pnl, skip_reason="spy_trend", ) return_base = ( params.initial_capital if (params.daily_budget_reset or not params.compound_returns) else equity ) if return_base > 0: skip_result.daily_return_pct = skip_result.daily_pnl / return_base results.append(skip_result) rolling_pnl_window.append(skip_result.daily_pnl) if settlement_enabled: still_pending = [] for settle_date, amount in pending_settlements: if settle_date <= date_str: settled_cash += amount else: still_pending.append((settle_date, amount)) pending_settlements = still_pending continue available_cash = ( params.initial_capital if daily_cash_reset else (settled_cash if settlement_enabled else None) ) # Sizing mode resolution: # - daily_budget_reset: research mode, always initial_capital (ignores path) # - compound_returns: sizing_capital=None → simulate_orb_trade uses equity # - simple: fixed initial_capital if params.daily_budget_reset: sizing_capital = params.initial_capital elif params.compound_returns: sizing_capital = None else: sizing_capital = params.initial_capital # Drawdown governor: scale down sizing when equity drops below peak if params.drawdown_governor_threshold is not None and peak_equity > 0: dd_pct = (peak_equity - equity) / peak_equity # 0.0 = at peak, 0.05 = 5% DD if dd_pct > params.drawdown_governor_threshold: # Linear ramp from 1.0 at threshold to min_scale at 2× threshold dd_range = params.drawdown_governor_threshold # same width for the ramp dd_excess = dd_pct - params.drawdown_governor_threshold governor_scale = max( params.drawdown_governor_min_scale, 1.0 - (1.0 - params.drawdown_governor_min_scale) * min(dd_excess / dd_range, 1.0), ) if sizing_capital is not None: sizing_capital = sizing_capital * governor_scale else: # compound mode: scale equity for sizing sizing_capital = equity * governor_scale # Streak sizing: apply win/loss streak multiplier after governor if (params.streak_sizing_win_bonus is not None or params.streak_sizing_loss_penalty is not None) and streak_outcomes: # Count consecutive wins or losses from the END of the list streak_len = 0 is_winning = streak_outcomes[-1] for outcome in reversed(streak_outcomes): if outcome == is_winning: streak_len += 1 else: break streak_mult = 1.0 if is_winning and params.streak_sizing_win_bonus is not None: streak_mult = 1.0 + streak_len * params.streak_sizing_win_bonus elif not is_winning and params.streak_sizing_loss_penalty is not None: streak_mult = 1.0 - streak_len * params.streak_sizing_loss_penalty streak_mult = max(params.streak_sizing_min, min(params.streak_sizing_max, streak_mult)) if sizing_capital is not None: sizing_capital = sizing_capital * streak_mult else: sizing_capital = equity * streak_mult # Rolling WR sizing: apply bonus/penalty based on recent win rate if params.rolling_wr_sizing_window is not None and len(streak_outcomes) >= params.rolling_wr_sizing_window: recent = streak_outcomes[-params.rolling_wr_sizing_window:] rolling_wr = sum(recent) / len(recent) wr_mult = 1.0 if rolling_wr > params.rolling_wr_sizing_threshold: wr_mult = 1.0 + params.rolling_wr_sizing_bonus elif params.rolling_wr_sizing_penalty_threshold is not None and rolling_wr < params.rolling_wr_sizing_penalty_threshold: wr_mult = 1.0 - params.rolling_wr_sizing_penalty if wr_mult != 1.0: if sizing_capital is not None: sizing_capital = sizing_capital * wr_mult else: sizing_capital = equity * wr_mult # Single-trade loss cap: after all boosts, clamp sizing_capital so that # a single -1R trade cannot exceed the configured cap. Historical # strategies use initial_capital; compound-specific forks can opt into # current equity so the cap grows with the account. if ( params.single_trade_loss_cap_pct is not None and params.risk_per_trade_pct > 0 and params.initial_capital > 0 ): loss_cap_basis = str( getattr(params, "single_trade_loss_cap_basis", "initial") or "initial" ).lower() cap_base = equity if loss_cap_basis == "equity" else params.initial_capital max_risk = params.single_trade_loss_cap_pct * cap_base max_sizing = max_risk / params.risk_per_trade_pct if sizing_capital is not None: sizing_capital = min(sizing_capital, max_sizing) else: sizing_capital = min(equity, max_sizing) day_vix = vix_by_day.get(date_str) if vix_by_day else None day_result = simulate_orb_day( bars_by_ticker, date_str, params, enrichment, equity=equity, blacklisted_tickers=blacklisted if blacklisted else None, spy_bars=spy_bars, ticker_sectors=ticker_sectors, available_cash=available_cash, sizing_capital=sizing_capital, vix_value=day_vix, overlay_tickers=overlay_tickers_per_day.get(date_str) if overlay_tickers_per_day else None, ) orb_trade_count = len(day_result.trades) if idle_exit_trades: day_result.trades = list(idle_exit_trades) + day_result.trades day_result.daily_pnl += idle_exit_pnl # Override daily_return_pct with portfolio-level return. # In daily-reset/simple modes, the research budget is fixed, so the # return denominator is initial_capital rather than accumulated equity. # This keeps Sharpe/loss stats aligned with non-compounded sizing. return_base = ( params.initial_capital if (params.daily_budget_reset or not params.compound_returns) else equity ) if return_base > 0: day_result.daily_return_pct = day_result.daily_pnl / return_base idle_same_day_exit = _idle_sleeve_same_day_exit(params) next_idle_exit_date = ( trading_days[day_idx + 1] if day_idx + 1 < total_days else next_trading_day_after_window ) if ( getattr(params, "orb_idle_sleeve_enabled", False) and orb_trade_count == 0 and (idle_same_day_exit or next_idle_exit_date is not None) ): idle_budget_base = ( params.initial_capital if (params.daily_budget_reset or not params.compound_returns) else equity ) idle_budget = max( 0.0, idle_budget_base * max(0.0, float(getattr(params, "orb_idle_sleeve_total_budget_pct", 0.0) or 0.0)), ) if settlement_enabled: idle_budget = min(idle_budget, max(0.0, settled_cash)) opened_idle_positions = _open_idle_sleeve_positions( bars_by_ticker, date_str, params, enrichment, idle_budget, date_str if idle_same_day_exit else next_idle_exit_date, ) if opened_idle_positions: opened_idle_capital = sum(float(pos.get("cost") or 0.0) for pos in opened_idle_positions) if day_result.entry_diagnostics is None: day_result.entry_diagnostics = {} day_result.entry_diagnostics.update( { "orb_idle_sleeve_positions_opened": len(opened_idle_positions), "orb_idle_sleeve_capital_opened": round(opened_idle_capital, 2), "orb_idle_sleeve_symbols_opened": [ str(pos.get("ticker") or "") for pos in opened_idle_positions ], "orb_idle_sleeve_labels_opened": [ str(pos.get("sleeve") or "") for pos in opened_idle_positions ], } ) if idle_same_day_exit: same_day_idle_trades, _still_open = _close_idle_sleeve_positions( opened_idle_positions, bars_by_ticker, date_str, params, enrichment, ) if same_day_idle_trades: same_day_idle_pnl = sum(trade.pnl for trade in same_day_idle_trades) day_result.trades.extend(same_day_idle_trades) day_result.daily_pnl += same_day_idle_pnl day_result.capital_deployed += sum( float(trade.total_capital_deployed or 0.0) for trade in same_day_idle_trades ) if return_base > 0: day_result.daily_return_pct = day_result.daily_pnl / return_base else: pending_idle_sleeve_positions.extend(opened_idle_positions) if settlement_enabled: settled_cash -= opened_idle_capital results.append(day_result) rolling_pnl_for_window = day_result.daily_pnl market_veto_penalty_pct = getattr( params, "market_orb_quality_veto_rolling_loss_pct", None ) market_quality_vetoed_day = ( market_veto_penalty_pct is not None and len(day_result.trades) == 0 and day_result.market_orb_quality_scaler is not None and day_result.market_orb_quality_scaler <= 0 and ( day_result.market_orb_quality_divergence_active or day_result.market_orb_quality_primary_weak_secondary_strong_active or day_result.market_orb_quality_primary_lag_secondary_lead_active or day_result.market_orb_quality_joint_weak_active or day_result.market_orb_quality_joint_panic_active ) ) if market_quality_vetoed_day: rolling_pnl_for_window = float(market_veto_penalty_pct) * params.initial_capital day_result.rolling_loss_synthetic_pnl = rolling_pnl_for_window rolling_pnl_window.append(rolling_pnl_for_window) # Update streak outcomes from today's trades. Defensive soft-day fallback # sleeves can opt out so they do not contaminate the main allocator state. exclude_soft_day_from_streak = ( day_result.is_soft_day and bool(getattr(params, "soft_day_exclude_from_streak", False)) ) day_result.soft_day_excluded_from_streak = bool( exclude_soft_day_from_streak and day_result.trades ) if not exclude_soft_day_from_streak: for trade in day_result.trades: if ( getattr(trade, "orb_idle_sleeve_overnight", False) or getattr(trade, "trigger_type", None) == "orb_idle_sleeve" ): continue streak_outcomes.append(trade.pnl > 0) if ( getattr(params, "distressed_reclaim_streak_loss_on_trigger", False) and (day_result.candidate_filter_stats or {}).get("distressed_reclaim", 0) > 0 ): streak_outcomes.append(False) # Keep only last 20 outcomes to bound memory if len(streak_outcomes) > 20: streak_outcomes = streak_outcomes[-20:] # Update equity equity += day_result.daily_pnl - idle_exit_pnl equity = max(equity, 1.0) # prevent zero/negative equity from crashing peak_equity = max(peak_equity, equity) # Step 2: After the day, deduct deployed capital and schedule proceeds for settlement exclude_soft_day_from_settlement = ( day_result.is_soft_day and bool(getattr(params, "soft_day_exclude_from_settlement", False)) ) if settlement_enabled and not exclude_soft_day_from_settlement: deployed = day_result.capital_deployed settled_cash -= deployed # cash is now deployed (unsettled until proceeds settle) # Sale proceeds = cost basis + P&L; schedule settlement T+N trading days out proceeds = deployed + (day_result.daily_pnl - idle_exit_pnl) if proceeds > 0: settle_idx = day_idx + params.settlement_days if settle_idx < len(trading_days): pending_settlements.append((trading_days[settle_idx], proceeds)) else: # Settlement date falls beyond simulation window; credit immediately settled_cash += proceeds if params.ticker_cooldown_days > 0: current_date = dt.date.fromisoformat(date_str) for trade in day_result.trades: if ( getattr(trade, "orb_idle_sleeve_overnight", False) or getattr(trade, "trigger_type", None) == "orb_idle_sleeve" ): continue ticker_last_traded[trade.ticker] = current_date max_roll = params.rolling_loss_days or 0 next_state = ORBSimulationState( equity=equity, peak_equity=peak_equity, ticker_last_traded={ ticker: last_dt.isoformat() for ticker, last_dt in ticker_last_traded.items() }, settled_cash=settled_cash if settlement_enabled else None, pending_settlements=list(pending_settlements), recent_daily_pnl=rolling_pnl_window[-max_roll:] if max_roll > 0 else [], streak_outcomes=streak_outcomes[-20:], pending_idle_sleeve_positions=list(pending_idle_sleeve_positions), ) return results, next_state def run_orb_simulation( all_intraday: dict[str, dict[str, list[dict]]], trading_days: list[str], params: ORBStrategyParams, enrichment: dict[str, dict[str, dict]], ticker_sectors: dict[str, str] | None = None, vix_by_day: dict[str, float] | None = None, overlay_tickers_per_day: dict[str, set[str]] | None = None, ) -> list[DayResult]: """Run the full ORB backtest simulation across all trading days.""" results, _ = run_orb_simulation_with_state( all_intraday, trading_days, params, enrichment, ticker_sectors=ticker_sectors, vix_by_day=vix_by_day, overlay_tickers_per_day=overlay_tickers_per_day, ) return results