You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

363 lines
16 KiB
Python

"""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,
},
)