"""MacroLongScreener: generates synthetic ETF long candidates on broad market rallies. Extracted from BacktestRunner._schedule_macro_long_candidates() so both the backtester and paper trader can reuse the same logic. Trigger conditions (example preset: idle_macro_breadth_smh_postalloc): - SMH reaction_day_return >= 1.8% - SMH close_location >= 0.64 - SMH volume_ratio_20d >= 1.1 - Breadth: QQQ, XLK, SMH — at least 2 pass the same checks - SMH leadership vs SPY >= +0.4% """ from __future__ import annotations import datetime as dt import math from typing import Any from libs.backtest.domain import Candidate, StrategyEngineConfig def value_fails_bounds( value: float | None, minimum: float | None, maximum: float | None, ) -> bool: """Return True if value is out of [minimum, maximum] bounds.""" if value is None: return minimum is not None or maximum is not None if minimum is not None and value < minimum: return True if maximum is not None and value > maximum: return True return False def compute_market_features_from_bars( bars: dict[dt.date, dict[str, Any]], as_of_date: dt.date, ) -> dict[str, Any]: """Compute reaction_day_return, volume_ratio_20d, gap_size, close_location, event_close, atr_14, avg_dollar_volume_20d from daily bars dict. bars: {date -> {"open", "high", "low", "close", "volume"}} Returns dict with market features or {} if insufficient data. """ sorted_dates = sorted(d for d in bars if d <= as_of_date) if not sorted_dates or sorted_dates[-1] != as_of_date: return {} today_bar = bars[as_of_date] today_close = float(today_bar.get("close") or 0.0) today_open = float(today_bar.get("open") or today_close) today_high = float(today_bar.get("high") or today_close) today_low = float(today_bar.get("low") or today_close) today_vol = float(today_bar.get("volume") or 0.0) if today_close <= 0: return {} prev_dates = [d for d in sorted_dates if d < as_of_date] if not prev_dates: return {} prev_close = float(bars[prev_dates[-1]].get("close") or today_close) # reaction_day_return: today's close vs prev close reaction_day_return = (today_close - prev_close) / prev_close if prev_close > 0 else 0.0 # gap_size: open vs prev close gap_size = (today_open - prev_close) / prev_close if prev_close > 0 else 0.0 # close_location: (close - low) / (high - low) hl = today_high - today_low close_location = (today_close - today_low) / hl if hl > 0 else 0.5 # volume_ratio_20d and avg_dollar_volume_20d from last 20 bars lookback = sorted_dates[-21:-1] # up to 20 previous bars if lookback: recent_vols = [float(bars[d].get("volume") or 0) for d in lookback] recent_advs = [ float(bars[d].get("volume") or 0) * float(bars[d].get("close") or 0) for d in lookback ] avg_vol = sum(recent_vols) / len(recent_vols) if recent_vols else 0.0 volume_ratio_20d = today_vol / avg_vol if avg_vol > 0 else 1.0 avg_dollar_volume = sum(recent_advs) / len(recent_advs) if recent_advs else today_close * today_vol else: volume_ratio_20d = 1.0 avg_dollar_volume = today_close * today_vol # ATR-14 (simplified: average of |high - low| over last 14 bars) atr_bars = [bars[d] for d in sorted_dates[-15:] if d <= as_of_date] if atr_bars: true_ranges = [] for i, b in enumerate(atr_bars): h = float(b.get("high") or 0) lo = float(b.get("low") or 0) true_ranges.append(max(h - lo, 0.001)) atr_14 = sum(true_ranges) / len(true_ranges) else: atr_14 = today_close * 0.02 return { "reaction_day_return": reaction_day_return, "volume_ratio_20d": volume_ratio_20d, "gap_size": gap_size, "close_location": close_location, "event_close": today_close, "atr_14": atr_14, "avg_dollar_volume_20d": avg_dollar_volume, } class MacroLongScreener: """Check macro_long engine trigger conditions and generate synthetic Candidate. Extracted from BacktestRunner._schedule_macro_long_candidates() (run.py:5509). Both backtester and paper trader use this to avoid code duplication. """ def screen( self, *, signal_date: dt.date, execution_date: dt.date, engine: StrategyEngineConfig, macro_vix: float | None, market_features: dict[str, Any], breadth_features: dict[str, dict[str, Any]], open_symbols: set[str], event_breadth_count: int = 0, event_breadth_unique_sectors: int = 0, execution_bar: dict[str, Any] | None = None, ) -> Candidate | None: """Return a synthetic Candidate if macro_long trigger fires, else None. Args: signal_date: The date on which the trigger is evaluated (reaction date). execution_date: The date on which the trade would be entered (next open). engine: StrategyEngineConfig with macro_long_* fields set. macro_vix: Current VIX value (or None if unavailable). market_features: Features for the trigger symbol (e.g. SMH). breadth_features: {symbol -> features} for breadth symbols (QQQ, XLK, SMH). open_symbols: Symbols already in open positions (skip if already held). event_breadth_count: Number of qualifying PEAD candidates today. event_breadth_unique_sectors: Number of unique sectors in today's PEAD candidates. execution_bar: Bar data for execution_date (used for entry price). If None, uses event_close from market_features. """ trigger_symbol = str(engine.macro_long_symbol or "").upper() if not trigger_symbol: return None # VIX gate if value_fails_bounds( float(macro_vix) if macro_vix is not None else None, getattr(engine, "macro_vix_min", None), engine.macro_vix_max, ): return None reaction_return = market_features.get("reaction_day_return") volume_ratio = market_features.get("volume_ratio_20d") gap_size = market_features.get("gap_size") close_location = market_features.get("close_location") event_close = market_features.get("event_close") atr_14 = market_features.get("atr_14") avg_dollar_volume = market_features.get("avg_dollar_volume_20d") if not market_features: return None # Trigger symbol bounds checks for val, mn, mx in [ (reaction_return, engine.macro_long_reaction_day_return_min, engine.macro_long_reaction_day_return_max), (volume_ratio, engine.macro_long_volume_ratio_min, engine.macro_long_volume_ratio_max), (gap_size, engine.macro_long_gap_size_min, engine.macro_long_gap_size_max), (close_location, engine.macro_long_close_location_min, engine.macro_long_close_location_max), ]: if value_fails_bounds(float(val) if val is not None else None, mn, mx): return None # Breadth check breadth_symbols = [ str(s).upper() for s in (engine.macro_long_breadth_symbols or []) if str(s).strip() ] breadth_match_symbols: list[str] = [] breadth_count = event_breadth_count breadth_unique_sectors = event_breadth_unique_sectors breadth_feature_map: dict[str, dict[str, Any]] = {} if breadth_symbols: for bs in dict.fromkeys(breadth_symbols): bfeat = breadth_features.get(bs, {}) if not bfeat: continue breadth_feature_map[bs] = bfeat if value_fails_bounds( float(bfeat.get("reaction_day_return")) if bfeat.get("reaction_day_return") is not None else None, engine.macro_long_breadth_reaction_day_return_min, engine.macro_long_breadth_reaction_day_return_max, ): continue if value_fails_bounds( float(bfeat.get("volume_ratio_20d")) if bfeat.get("volume_ratio_20d") is not None else None, engine.macro_long_breadth_volume_ratio_min, engine.macro_long_breadth_volume_ratio_max, ): continue if value_fails_bounds( float(bfeat.get("gap_size")) if bfeat.get("gap_size") is not None else None, engine.macro_long_breadth_gap_size_min, engine.macro_long_breadth_gap_size_max, ): continue if value_fails_bounds( float(bfeat.get("close_location")) if bfeat.get("close_location") is not None else None, engine.macro_long_breadth_close_location_min, engine.macro_long_breadth_close_location_max, ): continue breadth_match_symbols.append(bs) breadth_count = len(breadth_match_symbols) breadth_unique_sectors = breadth_count if ( engine.macro_long_min_breadth_count is not None and breadth_count < engine.macro_long_min_breadth_count ): return None else: if ( engine.macro_long_min_daily_candidate_count is not None and breadth_count < engine.macro_long_min_daily_candidate_count ): return None if ( engine.macro_long_min_unique_sector_count is not None and breadth_unique_sectors < engine.macro_long_min_unique_sector_count ): return None # Leadership vs SPY leadership_vs_spy: float | None = None if engine.macro_long_leadership_vs_spy_min is not None: spy_feat = breadth_features.get("SPY", {}) spy_ret = spy_feat.get("reaction_day_return") if reaction_return is None or spy_ret is None: return None leadership_vs_spy = float(reaction_return) - float(spy_ret) if leadership_vs_spy < engine.macro_long_leadership_vs_spy_min: return None # Trade symbol selection trade_symbol = trigger_symbol trade_symbol_mode = str(engine.macro_long_trade_symbol_mode or "fixed").lower() if trade_symbol_mode == "leader" and breadth_match_symbols: def _leader_key(sym: str) -> tuple[float, float, float]: f = breadth_feature_map.get(sym, {}) return ( float(f.get("reaction_day_return") or 0.0), float(f.get("close_location") or 0.0), float(f.get("volume_ratio_20d") or 0.0), ) trade_symbol = max(breadth_match_symbols, key=_leader_key) tf = breadth_feature_map.get(trade_symbol, market_features) reaction_return = tf.get("reaction_day_return", reaction_return) volume_ratio = tf.get("volume_ratio_20d", volume_ratio) gap_size = tf.get("gap_size", gap_size) close_location = tf.get("close_location", close_location) event_close = tf.get("event_close", event_close) atr_14 = tf.get("atr_14", atr_14) avg_dollar_volume = tf.get("avg_dollar_volume_20d", avg_dollar_volume) if trade_symbol.upper() in open_symbols: return None # Score computation reaction_scale = abs(engine.macro_long_reaction_day_return_min or 0.015) or 0.015 reaction_quality = min(1.0, max(0.0, float(reaction_return or 0.0)) / reaction_scale) if engine.macro_long_volume_ratio_min: volume_quality = min(1.0, float(volume_ratio or 0.0) / engine.macro_long_volume_ratio_min) else: volume_quality = 0.5 if volume_ratio is None else min(1.0, float(volume_ratio) / 2.0) close_quality = 0.5 if close_location is None else max(0.0, min(1.0, float(close_location))) breadth_components: list[float] = [] if breadth_symbols and engine.macro_long_min_breadth_count: breadth_components.append(min(1.0, breadth_count / float(engine.macro_long_min_breadth_count))) elif not breadth_symbols: if engine.macro_long_min_daily_candidate_count: breadth_components.append(min(1.0, breadth_count / float(engine.macro_long_min_daily_candidate_count))) if engine.macro_long_min_unique_sector_count: breadth_components.append(min(1.0, breadth_unique_sectors / float(engine.macro_long_min_unique_sector_count))) breadth_quality = sum(breadth_components) / len(breadth_components) if breadth_components else 0.5 score = min(0.99, 0.35 + 0.30 * reaction_quality + 0.15 * volume_quality + 0.10 * close_quality + 0.10 * breadth_quality) score_bucket = "high" if score >= 0.8 else "medium_high" if score >= 0.6 else "medium" close_value = float(event_close) if event_close is not None else 0.0 if close_value <= 0: if execution_bar: close_value = float(execution_bar.get("close") or 0.0) if close_value <= 0: return None atr_value = float(atr_14) if atr_14 is not None and float(atr_14) > 0 else close_value * 0.02 adv_value = float(avg_dollar_volume) if avg_dollar_volume is not None and float(avg_dollar_volume) > 0 else 1e9 return Candidate( event_id=f"synth_macro_long_{trade_symbol.lower()}_{signal_date.isoformat()}", symbol=trade_symbol, source_symbol=trigger_symbol, score=score, sector="MACRO", event_type="macro_bullish_event", event_timestamp=dt.datetime.combine(signal_date, dt.time(16, 0), tzinfo=dt.timezone.utc), event_date=signal_date, filing_time_bucket="after_close", reaction_date=signal_date, execution_date=execution_date, entry_price_est=close_value, avg_dollar_volume=adv_value, atr_14=atr_value, score_bucket=score_bucket, engine_id=engine.engine_id, entry_timing_policy="next_open", trade_direction="long", engine_max_holding_days=engine.max_holding_days, engine_risk_budget_pct=engine.engine_risk_budget_pct, engine_per_trade_risk_pct=engine.per_trade_risk_pct_override, engine_target_1_r=engine.target_1_r_override, engine_target_1_fraction=engine.target_1_fraction_override, engine_trailing_model=engine.trailing_model_override, engine_trailing_warmup_days=engine.trailing_warmup_days_override, engine_stop_atr_multiplier=engine.stop_atr_multiplier_override, engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct, engine_use_reaction_day_low_stop=False, engine_early_failure_close_below_entry_and_reaction_close=False, engine_early_failure_no_progress_days=999, shadow_only=engine.shadow_only, features={ "macro_long_symbol": trigger_symbol, "macro_long_trade_symbol": trade_symbol, "macro_long_trade_symbol_mode": trade_symbol_mode, "macro_long_reaction_day_return": reaction_return, "macro_long_volume_ratio_20d": volume_ratio, "macro_long_gap_size": gap_size, "macro_long_close_location": close_location, "macro_long_breadth_symbols": breadth_match_symbols, "macro_long_breadth_count": breadth_count, "macro_long_leadership_vs_spy": leadership_vs_spy, "macro_long_daily_candidate_count": breadth_count, "macro_long_daily_unique_sector_count": breadth_unique_sectors, "macro_long_event_candidate_count": event_breadth_count, "macro_long_event_unique_sector_count": event_breadth_unique_sectors, "macro_long_vix": macro_vix, "macro_long_event_close": close_value, }, )