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