|
|
"""Core intraday simulation engine.
|
|
|
|
|
|
DST-aware (uses zoneinfo America/New_York throughout).
|
|
|
Pure functions — no API calls, no disk I/O.
|
|
|
run_simulation() takes pre-loaded data and returns DayResult list,
|
|
|
making sweep mode trivial (call once per parameter combination).
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import datetime as dt
|
|
|
import math
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
from libs.intraday.domain import DayResult, IntradayTrade, StrategyParams
|
|
|
|
|
|
|
|
|
_ET = ZoneInfo("America/New_York")
|
|
|
_MARKET_OPEN = dt.time(9, 30) # ET
|
|
|
_MARKET_CLOSE = dt.time(16, 0) # ET
|
|
|
_MIN_BARS = 5 # minimum market-hours bars required to simulate a stock
|
|
|
|
|
|
|
|
|
# ── Timestamp Parsing ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
def _parse_ts(ts_str: str) -> dt.datetime:
|
|
|
"""Parse Alpaca ISO 8601 timestamp to timezone-aware ET datetime."""
|
|
|
s = ts_str.replace("Z", "+00:00")
|
|
|
return dt.datetime.fromisoformat(s).astimezone(_ET)
|
|
|
|
|
|
|
|
|
# ── Market Hours Filtering ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
def filter_market_hours(bars: list[dict]) -> list[dict]:
|
|
|
"""Return only bars that fall within regular trading hours (9:30-16:00 ET).
|
|
|
|
|
|
Handles DST transitions correctly via zoneinfo.
|
|
|
"""
|
|
|
result = []
|
|
|
for b in bars:
|
|
|
ts = _parse_ts(b["timestamp"])
|
|
|
t = ts.time()
|
|
|
if _MARKET_OPEN <= t < _MARKET_CLOSE:
|
|
|
result.append(b)
|
|
|
return result
|
|
|
|
|
|
|
|
|
def _bar_at_offset(
|
|
|
bars: list[dict],
|
|
|
market_open_ts: dt.datetime,
|
|
|
offset_minutes: int,
|
|
|
tolerance_minutes: int = 7,
|
|
|
) -> dict | None:
|
|
|
"""Find the bar closest to (market_open + offset_minutes).
|
|
|
|
|
|
Returns None if no bar is within tolerance_minutes of the target.
|
|
|
"""
|
|
|
target = market_open_ts + dt.timedelta(minutes=offset_minutes)
|
|
|
best: dict | None = None
|
|
|
best_diff = float("inf")
|
|
|
for b in bars:
|
|
|
ts = _parse_ts(b["timestamp"])
|
|
|
diff = abs((ts - target).total_seconds())
|
|
|
if diff < best_diff and diff <= tolerance_minutes * 60:
|
|
|
best = b
|
|
|
best_diff = diff
|
|
|
return best
|
|
|
|
|
|
|
|
|
def _market_open_ts(date_str: str) -> dt.datetime:
|
|
|
"""Return 9:30 AM ET datetime for the given date string."""
|
|
|
d = dt.date.fromisoformat(date_str)
|
|
|
naive = dt.datetime.combine(d, _MARKET_OPEN)
|
|
|
return naive.replace(tzinfo=_ET)
|
|
|
|
|
|
|
|
|
def _volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float:
|
|
|
"""Sum volume of all bars up to and including entry_ts."""
|
|
|
total = 0.0
|
|
|
for b in bars:
|
|
|
ts = _parse_ts(b["timestamp"])
|
|
|
if ts <= entry_ts:
|
|
|
total += b.get("volume", 0) or 0
|
|
|
return total
|
|
|
|
|
|
|
|
|
def _dollar_volume_up_to_bar(bars: list[dict], entry_ts: dt.datetime) -> float:
|
|
|
"""Sum approximate dollar volume of all bars up to and including entry_ts."""
|
|
|
total = 0.0
|
|
|
for b in bars:
|
|
|
ts = _parse_ts(b["timestamp"])
|
|
|
if ts <= entry_ts:
|
|
|
close = b.get("close") or 0.0
|
|
|
volume = b.get("volume") or 0.0
|
|
|
total += float(close) * float(volume)
|
|
|
return total
|
|
|
|
|
|
|
|
|
def _linear_scaler(
|
|
|
value: float | None,
|
|
|
low: float | None,
|
|
|
high: float | None,
|
|
|
floor: float,
|
|
|
*,
|
|
|
invert: bool = False,
|
|
|
) -> float:
|
|
|
"""Piecewise-linear scaler bounded to [floor, 1.0].
|
|
|
|
|
|
When invert=False, values <= low map to 1.0 and values >= high map to floor.
|
|
|
When invert=True, values <= low map to floor and values >= high map to 1.0.
|
|
|
"""
|
|
|
if value is None or low is None or high is None or high <= low:
|
|
|
return 1.0
|
|
|
floor = max(0.0, min(1.0, floor))
|
|
|
if invert:
|
|
|
if value <= low:
|
|
|
return floor
|
|
|
if value >= high:
|
|
|
return 1.0
|
|
|
frac = (value - low) / (high - low)
|
|
|
return floor + frac * (1.0 - floor)
|
|
|
if value <= low:
|
|
|
return 1.0
|
|
|
if value >= high:
|
|
|
return floor
|
|
|
frac = (value - low) / (high - low)
|
|
|
return 1.0 - frac * (1.0 - floor)
|
|
|
|
|
|
|
|
|
def _vix_day_scaler(vix_value: float | None, strategy: StrategyParams) -> float:
|
|
|
return _linear_scaler(
|
|
|
vix_value,
|
|
|
strategy.vix_size_scale_low,
|
|
|
strategy.vix_size_scale_high,
|
|
|
strategy.vix_size_scale_min,
|
|
|
)
|
|
|
|
|
|
|
|
|
def _entropy_trade_scaler(entropy_20d: float | None, strategy: StrategyParams) -> float:
|
|
|
return _linear_scaler(
|
|
|
entropy_20d,
|
|
|
strategy.entropy_size_scale_low,
|
|
|
strategy.entropy_size_scale_high,
|
|
|
strategy.entropy_size_scale_min,
|
|
|
)
|
|
|
|
|
|
|
|
|
def _sparse_day_scaler(selected_count: int, strategy: StrategyParams) -> float:
|
|
|
threshold = strategy.full_size_positions_threshold
|
|
|
if threshold is None or threshold <= 0:
|
|
|
return 1.0
|
|
|
floor = max(0.0, min(1.0, strategy.sparse_day_size_floor))
|
|
|
if selected_count >= threshold:
|
|
|
return 1.0
|
|
|
ratio = selected_count / threshold
|
|
|
return max(floor, min(1.0, ratio))
|
|
|
|
|
|
|
|
|
def _intraday_regime_failed(
|
|
|
strategy: StrategyParams,
|
|
|
spy_bars: list[dict] | None,
|
|
|
date_str: str,
|
|
|
) -> bool:
|
|
|
if strategy.market_regime_spy_threshold is None or not spy_bars:
|
|
|
return False
|
|
|
spy_mkt = filter_market_hours(spy_bars)
|
|
|
if len(spy_mkt) < 2:
|
|
|
return False
|
|
|
spy_open = spy_mkt[0]["open"]
|
|
|
spy_entry_bar = _bar_at_offset(
|
|
|
spy_mkt,
|
|
|
_market_open_ts(date_str),
|
|
|
strategy.entry_minutes_after_open,
|
|
|
)
|
|
|
if spy_open <= 0 or spy_entry_bar is None:
|
|
|
return False
|
|
|
spy_gain = (spy_entry_bar["close"] - spy_open) / spy_open
|
|
|
return spy_gain < strategy.market_regime_spy_threshold
|
|
|
|
|
|
|
|
|
def _day_regime_scaler(
|
|
|
strategy: StrategyParams,
|
|
|
daily_features_by_ticker: dict[str, dict] | None,
|
|
|
) -> tuple[float, str | None]:
|
|
|
if not any(
|
|
|
value is not None and value != default
|
|
|
for value, default in [
|
|
|
(strategy.market_regime_gap_threshold, None),
|
|
|
(strategy.regime_size_scale_low, None),
|
|
|
(strategy.regime_size_scale_high, None),
|
|
|
(strategy.regime_skip_below, None),
|
|
|
]
|
|
|
):
|
|
|
return 1.0, None
|
|
|
regime_ticker = strategy.market_regime_gap_ticker or "SPY"
|
|
|
regime_features = (daily_features_by_ticker or {}).get(regime_ticker, {})
|
|
|
prev_close = regime_features.get("prev_close")
|
|
|
today_open = regime_features.get("today_open")
|
|
|
if not prev_close or not today_open or prev_close <= 0:
|
|
|
return 1.0, None
|
|
|
regime_gap = (today_open - prev_close) / prev_close
|
|
|
if strategy.regime_skip_below is not None and regime_gap < strategy.regime_skip_below:
|
|
|
return 1.0, "market_regime"
|
|
|
if (
|
|
|
strategy.regime_size_scale_low is None
|
|
|
and strategy.market_regime_gap_threshold is not None
|
|
|
and regime_gap < strategy.market_regime_gap_threshold
|
|
|
):
|
|
|
return 1.0, "market_regime"
|
|
|
scaler = _linear_scaler(
|
|
|
regime_gap,
|
|
|
strategy.regime_size_scale_low,
|
|
|
strategy.regime_size_scale_high,
|
|
|
strategy.regime_size_scale_min,
|
|
|
invert=True,
|
|
|
)
|
|
|
return scaler, None
|
|
|
|
|
|
|
|
|
def _day_breadth_scaler(
|
|
|
strategy: StrategyParams,
|
|
|
bars_by_ticker: dict[str, list[dict]],
|
|
|
daily_features_by_ticker: dict[str, dict] | None,
|
|
|
) -> tuple[float, str | None]:
|
|
|
if not any(
|
|
|
value is not None and value != default
|
|
|
for value, default in [
|
|
|
(strategy.min_candidate_breadth, None),
|
|
|
(strategy.breadth_size_scale_low, None),
|
|
|
(strategy.breadth_size_scale_high, None),
|
|
|
(strategy.breadth_skip_below, None),
|
|
|
]
|
|
|
):
|
|
|
return 1.0, None
|
|
|
pos_gap_count = 0
|
|
|
total_with_data = 0
|
|
|
for ticker in bars_by_ticker:
|
|
|
ticker_day = (daily_features_by_ticker or {}).get(ticker, {})
|
|
|
prev_close = ticker_day.get("prev_close")
|
|
|
today_open = ticker_day.get("today_open")
|
|
|
if prev_close and today_open and prev_close > 0:
|
|
|
total_with_data += 1
|
|
|
if today_open > prev_close:
|
|
|
pos_gap_count += 1
|
|
|
if total_with_data <= 0:
|
|
|
return 1.0, None
|
|
|
breadth_ratio = pos_gap_count / total_with_data
|
|
|
if strategy.breadth_skip_below is not None and breadth_ratio < strategy.breadth_skip_below:
|
|
|
return 1.0, "breadth"
|
|
|
if (
|
|
|
strategy.breadth_size_scale_low is None
|
|
|
and strategy.min_candidate_breadth is not None
|
|
|
and breadth_ratio < strategy.min_candidate_breadth
|
|
|
):
|
|
|
return 1.0, "breadth"
|
|
|
scaler = _linear_scaler(
|
|
|
breadth_ratio,
|
|
|
strategy.breadth_size_scale_low,
|
|
|
strategy.breadth_size_scale_high,
|
|
|
strategy.breadth_size_scale_min,
|
|
|
invert=True,
|
|
|
)
|
|
|
return scaler, None
|
|
|
|
|
|
|
|
|
def _basket_sector_scaler(
|
|
|
picks: list[tuple[str, str]],
|
|
|
ticker_sectors: dict[str, str] | None,
|
|
|
strategy: StrategyParams,
|
|
|
) -> float:
|
|
|
if not picks or not ticker_sectors:
|
|
|
return 1.0
|
|
|
counts: dict[str, int] = {}
|
|
|
known = 0
|
|
|
for ticker, _sleeve in picks:
|
|
|
sector = str(ticker_sectors.get(ticker) or "").strip()
|
|
|
if not sector or sector.upper() == "UNKNOWN":
|
|
|
continue
|
|
|
counts[sector] = counts.get(sector, 0) + 1
|
|
|
known += 1
|
|
|
if known <= 1 or not counts:
|
|
|
return 1.0
|
|
|
concentration = max(counts.values()) / known
|
|
|
return _linear_scaler(
|
|
|
concentration,
|
|
|
strategy.sector_concentration_scale_low,
|
|
|
strategy.sector_concentration_scale_high,
|
|
|
strategy.sector_concentration_scale_min,
|
|
|
)
|
|
|
|
|
|
|
|
|
def _safe_value(value: float | None, *, default: float = 0.0) -> float:
|
|
|
return default if value is None else float(value)
|
|
|
|
|
|
|
|
|
def _trade_trailing_stop_pct(info: dict, strategy: StrategyParams) -> float | None:
|
|
|
"""Return the per-trade trailing stop, tightening only overextended leaders."""
|
|
|
trailing_stop_pct = strategy.trailing_stop_pct
|
|
|
if (
|
|
|
trailing_stop_pct is None
|
|
|
or strategy.overextended_trailing_gain_pct is None
|
|
|
or strategy.overextended_trailing_stop_pct is None
|
|
|
):
|
|
|
return trailing_stop_pct
|
|
|
gain_pct = info.get("gain_pct")
|
|
|
if gain_pct is None or gain_pct < strategy.overextended_trailing_gain_pct:
|
|
|
return trailing_stop_pct
|
|
|
return strategy.overextended_trailing_stop_pct
|
|
|
|
|
|
|
|
|
def _trade_catastrophic_stop_price(info: dict, strategy: StrategyParams) -> float | None:
|
|
|
"""Return the initial catastrophic stop price for a trade, if any."""
|
|
|
entry_price_raw = info.get("entry_price_raw")
|
|
|
if entry_price_raw is None or entry_price_raw <= 0:
|
|
|
return None
|
|
|
if strategy.atr_stop_multiplier is not None:
|
|
|
atr_14 = info.get("atr_14")
|
|
|
if atr_14 is None or atr_14 <= 0:
|
|
|
return None
|
|
|
return max(0.0, float(entry_price_raw) - float(atr_14) * strategy.atr_stop_multiplier)
|
|
|
if strategy.opening_range_stop_multiplier is not None:
|
|
|
opening_range_width = info.get("opening_range_width")
|
|
|
if opening_range_width is None or opening_range_width <= 0:
|
|
|
return None
|
|
|
return max(
|
|
|
0.0,
|
|
|
float(entry_price_raw) - float(opening_range_width) * strategy.opening_range_stop_multiplier,
|
|
|
)
|
|
|
if strategy.stop_loss_pct is not None:
|
|
|
return max(0.0, float(entry_price_raw) * (1.0 + strategy.stop_loss_pct))
|
|
|
return None
|
|
|
|
|
|
|
|
|
def _five_sleeve_specs(strategy: StrategyParams) -> list[dict[str, object]]:
|
|
|
sleeves: list[dict[str, object]] = [
|
|
|
{
|
|
|
"label": "core",
|
|
|
"weight": strategy.five_sleeve_core_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
item[1]["gain_pct"],
|
|
|
item[1].get("volume_ratio_14d", 0.0),
|
|
|
item[1].get("entry_volume", 0.0),
|
|
|
),
|
|
|
"component": lambda info: info["gain_pct"],
|
|
|
},
|
|
|
{
|
|
|
"label": "gap",
|
|
|
"weight": strategy.five_sleeve_gap_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
_safe_value(item[1].get("gap_pct"), default=-999.0),
|
|
|
item[1]["gain_pct"],
|
|
|
item[1].get("volume_ratio_14d", 0.0),
|
|
|
),
|
|
|
"component": lambda info: max(info.get("gap_pct") or 0.0, 0.0),
|
|
|
},
|
|
|
{
|
|
|
"label": "volume",
|
|
|
"weight": strategy.five_sleeve_volume_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
item[1].get("volume_ratio_14d", 0.0),
|
|
|
item[1].get("entry_volume", 0.0),
|
|
|
item[1]["gain_pct"],
|
|
|
),
|
|
|
"component": lambda info: info.get("volume_ratio_14d") or 0.0,
|
|
|
},
|
|
|
{
|
|
|
"label": "entropy",
|
|
|
"weight": strategy.five_sleeve_entropy_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
-_safe_value(item[1].get("entropy_20d"), default=1.0),
|
|
|
item[1]["gain_pct"],
|
|
|
item[1].get("volume_ratio_14d", 0.0),
|
|
|
),
|
|
|
"component": lambda info: (
|
|
|
1.0 - info["entropy_20d"] if info.get("entropy_20d") is not None else 0.0
|
|
|
),
|
|
|
},
|
|
|
{
|
|
|
"label": "trend",
|
|
|
"weight": strategy.five_sleeve_trend_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
_safe_value(item[1].get("ret_5d"), default=-999.0),
|
|
|
item[1]["gain_pct"],
|
|
|
item[1].get("volume_ratio_14d", 0.0),
|
|
|
),
|
|
|
"component": lambda info: max(info.get("ret_5d") or 0.0, 0.0),
|
|
|
},
|
|
|
]
|
|
|
if strategy.use_event_sleeve and strategy.event_weight > 0:
|
|
|
sleeves.append(
|
|
|
{
|
|
|
"label": "event",
|
|
|
"weight": strategy.event_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
1 if item[1].get("is_event_candidate") else 0,
|
|
|
item[1].get("event_score", 0.0),
|
|
|
item[1].get("confirmation_return_pct", -999.0),
|
|
|
item[1].get("entry_dollar_volume", 0.0),
|
|
|
item[1].get("gain_pct", 0.0),
|
|
|
),
|
|
|
"component": lambda info: (
|
|
|
(
|
|
|
min(max(info.get("event_score") or 0.0, 0.0), 3.0)
|
|
|
+ max((info.get("confirmation_return_pct") or 0.0) * 20.0, 0.0)
|
|
|
+ min(
|
|
|
max((info.get("entry_dollar_volume") or 0.0) / 250_000_000.0, 0.0),
|
|
|
4.0,
|
|
|
)
|
|
|
+ max((info.get("gain_pct") or 0.0) * 10.0, 0.0)
|
|
|
)
|
|
|
if info.get("is_event_candidate")
|
|
|
else 0.0
|
|
|
),
|
|
|
}
|
|
|
)
|
|
|
if strategy.use_slow_ignite_sleeve and strategy.slow_ignite_weight > 0:
|
|
|
sleeves.append(
|
|
|
{
|
|
|
"label": "slow_ignite",
|
|
|
"weight": strategy.slow_ignite_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
1 if item[1].get("is_slow_ignite") else 0,
|
|
|
item[1].get("confirmation_return_pct", -999.0),
|
|
|
item[1].get("volume_ratio_14d", 0.0),
|
|
|
_safe_value(item[1].get("ret_5d"), default=-999.0),
|
|
|
item[1].get("entry_dollar_volume", 0.0),
|
|
|
),
|
|
|
"component": lambda info: (
|
|
|
(
|
|
|
max(info.get("confirmation_return_pct") or 0.0, 0.0) * 5.0
|
|
|
+ max(min(info.get("volume_ratio_14d") or 0.0, 0.5), 0.0)
|
|
|
+ max(min(info.get("ret_5d") or 0.0, 0.2), 0.0)
|
|
|
)
|
|
|
if info.get("is_slow_ignite")
|
|
|
else 0.0
|
|
|
),
|
|
|
}
|
|
|
)
|
|
|
if strategy.use_liquid_largecap_sleeve and strategy.liquid_largecap_weight > 0:
|
|
|
sleeves.append(
|
|
|
{
|
|
|
"label": "liquid_largecap",
|
|
|
"weight": strategy.liquid_largecap_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
1 if item[1].get("is_liquid_largecap") else 0,
|
|
|
item[1].get("entry_dollar_volume", 0.0),
|
|
|
item[1].get("confirmation_return_pct", -999.0),
|
|
|
item[1].get("gain_pct", 0.0),
|
|
|
item[1].get("avg_dollar_vol_30d", 0.0),
|
|
|
),
|
|
|
"component": lambda info: (
|
|
|
(
|
|
|
min(max((info.get("entry_dollar_volume") or 0.0) / 500_000_000.0, 0.0), 4.0)
|
|
|
+ max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0)
|
|
|
+ max((info.get("gain_pct") or 0.0) * 10.0, 0.0)
|
|
|
+ min(max((info.get("avg_dollar_vol_30d") or 0.0) / 1_000_000_000.0, 0.0), 3.0)
|
|
|
)
|
|
|
if info.get("is_liquid_largecap")
|
|
|
else 0.0
|
|
|
),
|
|
|
}
|
|
|
)
|
|
|
if strategy.use_moderate_gap_liquid_sleeve and strategy.moderate_gap_liquid_weight > 0:
|
|
|
sleeves.append(
|
|
|
{
|
|
|
"label": "moderate_gap_liquid",
|
|
|
"weight": strategy.moderate_gap_liquid_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
1 if item[1].get("is_moderate_gap_liquid") else 0,
|
|
|
item[1].get("confirmation_return_pct", -999.0),
|
|
|
item[1].get("entry_dollar_volume", 0.0),
|
|
|
item[1].get("gain_pct", 0.0),
|
|
|
-_safe_value(item[1].get("entropy_20d"), default=1.0),
|
|
|
item[1].get("avg_dollar_vol_30d", 0.0),
|
|
|
),
|
|
|
"component": lambda info: (
|
|
|
(
|
|
|
max((info.get("confirmation_return_pct") or 0.0) * 15.0, 0.0)
|
|
|
+ max((info.get("gain_pct") or 0.0) * 8.0, 0.0)
|
|
|
+ min(max((info.get("entry_dollar_volume") or 0.0) / 150_000_000.0, 0.0), 3.0)
|
|
|
+ min(max((info.get("avg_dollar_vol_30d") or 0.0) / 750_000_000.0, 0.0), 2.5)
|
|
|
+ max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0)
|
|
|
)
|
|
|
if info.get("is_moderate_gap_liquid")
|
|
|
else 0.0
|
|
|
),
|
|
|
}
|
|
|
)
|
|
|
if strategy.use_gap_reclaim_sleeve and strategy.gap_reclaim_weight > 0:
|
|
|
sleeves.append(
|
|
|
{
|
|
|
"label": "gap_reclaim",
|
|
|
"weight": strategy.gap_reclaim_weight,
|
|
|
"key_fn": lambda item: (
|
|
|
1 if item[1].get("is_gap_reclaim") else 0,
|
|
|
item[1].get("confirmation_return_pct", -999.0),
|
|
|
item[1].get("recovery_from_opening_low_pct", 0.0),
|
|
|
item[1].get("entry_dollar_volume", 0.0),
|
|
|
item[1].get("gap_pct", 0.0),
|
|
|
),
|
|
|
"component": lambda info: (
|
|
|
(
|
|
|
max((info.get("confirmation_return_pct") or 0.0) * 10.0, 0.0)
|
|
|
+ max((info.get("recovery_from_opening_low_pct") or 0.0) * 20.0, 0.0)
|
|
|
+ min(max((info.get("entry_dollar_volume") or 0.0) / 250_000_000.0, 0.0), 4.0)
|
|
|
+ min(max((info.get("gap_pct") or 0.0) * 5.0, 0.0), 2.0)
|
|
|
)
|
|
|
if info.get("is_gap_reclaim")
|
|
|
else 0.0
|
|
|
),
|
|
|
}
|
|
|
)
|
|
|
return sleeves
|
|
|
|
|
|
|
|
|
def _momentum_quality_score(info: dict, strategy: StrategyParams) -> float:
|
|
|
if strategy.use_five_sleeves:
|
|
|
score = 0.0
|
|
|
for sleeve in _five_sleeve_specs(strategy):
|
|
|
weight = float(sleeve["weight"])
|
|
|
if weight <= 0:
|
|
|
continue
|
|
|
score += weight * float(sleeve["component"](info))
|
|
|
return score
|
|
|
return (
|
|
|
max(float(info.get("gain_pct") or 0.0), 0.0) * 5.0
|
|
|
+ max(float(info.get("confirmation_return_pct") or 0.0), 0.0) * 10.0
|
|
|
+ min(max(float(info.get("volume_ratio_14d") or 0.0), 0.0), 0.5)
|
|
|
+ min(max(float(info.get("entry_dollar_volume") or 0.0) / 50_000_000.0, 0.0), 4.0)
|
|
|
+ max(float(info.get("ret_5d") or 0.0), 0.0)
|
|
|
+ max(1.0 - float(info.get("entropy_20d") or 1.0), 0.0)
|
|
|
)
|
|
|
|
|
|
|
|
|
def _clip_unit_score(value: float | None, cap: float) -> float:
|
|
|
if value is None or cap <= 0:
|
|
|
return 0.0
|
|
|
return min(max(float(value), 0.0), cap) / cap
|
|
|
|
|
|
|
|
|
def _clip_log_score(value: float | None, low: float, high: float) -> float:
|
|
|
if value is None or value <= 0 or low <= 0 or high <= low:
|
|
|
return 0.0
|
|
|
scaled = (math.log10(float(value)) - math.log10(low)) / (
|
|
|
math.log10(high) - math.log10(low)
|
|
|
)
|
|
|
return min(max(scaled, 0.0), 1.0)
|
|
|
|
|
|
|
|
|
def _same_day_support_score(info: dict) -> float:
|
|
|
"""Blend liquidity and same-day attention into one support score.
|
|
|
|
|
|
This is intentionally conservative: a thin single-name move only receives
|
|
|
meaningful support when both prior liquidity and entry-time liquidity are
|
|
|
decent, or when there is unusually strong same-day attention.
|
|
|
"""
|
|
|
if bool(info.get("is_liquid_largecap")):
|
|
|
return 1.0
|
|
|
|
|
|
prior_liquidity = _clip_log_score(
|
|
|
info.get("avg_dollar_vol_30d"),
|
|
|
30_000_000.0,
|
|
|
300_000_000.0,
|
|
|
)
|
|
|
entry_liquidity = _clip_log_score(
|
|
|
info.get("entry_dollar_volume"),
|
|
|
5_000_000.0,
|
|
|
50_000_000.0,
|
|
|
)
|
|
|
liquidity_support = min(prior_liquidity, entry_liquidity)
|
|
|
|
|
|
attention_support = max(
|
|
|
_clip_unit_score(info.get("attention_wiki_spike_10d"), 5.0),
|
|
|
_clip_unit_score(
|
|
|
max(
|
|
|
int(info.get("attention_article_count_3d") or 0),
|
|
|
int(info.get("attention_us_article_count_3d") or 0),
|
|
|
),
|
|
|
3.0,
|
|
|
),
|
|
|
)
|
|
|
catalyst_support = _clip_unit_score(info.get("event_score"), 1.25) * 0.25
|
|
|
return max(liquidity_support, attention_support, catalyst_support)
|
|
|
|
|
|
|
|
|
def _basket_quality_stats(
|
|
|
picks: list[tuple[str, str]],
|
|
|
morning_gains: dict[str, dict],
|
|
|
strategy: StrategyParams,
|
|
|
) -> dict[str, float]:
|
|
|
if not picks:
|
|
|
return {
|
|
|
"count": 0.0,
|
|
|
"avg_quality": 0.0,
|
|
|
"best_quality": 0.0,
|
|
|
"event_count": 0.0,
|
|
|
"liquid_largecap_count": 0.0,
|
|
|
"max_gain_pct": 0.0,
|
|
|
"avg_support": 0.0,
|
|
|
"max_entropy_20d": 0.0,
|
|
|
"max_confirmation_return_pct": 0.0,
|
|
|
}
|
|
|
qualities: list[float] = []
|
|
|
support_scores: list[float] = []
|
|
|
event_count = 0
|
|
|
liquid_largecap_count = 0
|
|
|
max_gain_pct = 0.0
|
|
|
max_entropy_20d = 0.0
|
|
|
max_confirmation_return_pct = 0.0
|
|
|
for ticker, _sleeve in picks:
|
|
|
info = morning_gains.get(ticker, {})
|
|
|
qualities.append(_momentum_quality_score(info, strategy))
|
|
|
support_scores.append(_same_day_support_score(info))
|
|
|
if info.get("is_event_candidate"):
|
|
|
event_count += 1
|
|
|
if info.get("is_liquid_largecap"):
|
|
|
liquid_largecap_count += 1
|
|
|
max_gain_pct = max(max_gain_pct, float(info.get("gain_pct") or 0.0))
|
|
|
max_entropy_20d = max(max_entropy_20d, float(info.get("entropy_20d") or 0.0))
|
|
|
max_confirmation_return_pct = max(
|
|
|
max_confirmation_return_pct,
|
|
|
float(info.get("confirmation_return_pct") or 0.0),
|
|
|
)
|
|
|
return {
|
|
|
"count": float(len(picks)),
|
|
|
"avg_quality": sum(qualities) / len(qualities),
|
|
|
"best_quality": max(qualities),
|
|
|
"event_count": float(event_count),
|
|
|
"liquid_largecap_count": float(liquid_largecap_count),
|
|
|
"max_gain_pct": max_gain_pct,
|
|
|
"avg_support": sum(support_scores) / len(support_scores),
|
|
|
"max_entropy_20d": max_entropy_20d,
|
|
|
"max_confirmation_return_pct": max_confirmation_return_pct,
|
|
|
}
|
|
|
|
|
|
|
|
|
def _should_enable_soft_day_event_sleeve(
|
|
|
picks: list[tuple[str, str]],
|
|
|
morning_gains: dict[str, dict],
|
|
|
strategy: StrategyParams,
|
|
|
*,
|
|
|
base_soft_day: bool,
|
|
|
) -> bool:
|
|
|
if not (
|
|
|
base_soft_day
|
|
|
and strategy.use_event_sleeve
|
|
|
and strategy.event_sleeve_soft_day_only
|
|
|
):
|
|
|
return False
|
|
|
stats = _basket_quality_stats(picks, morning_gains, strategy)
|
|
|
if (
|
|
|
strategy.event_sleeve_soft_day_max_trades is not None
|
|
|
and stats["count"] > strategy.event_sleeve_soft_day_max_trades
|
|
|
):
|
|
|
return False
|
|
|
if (
|
|
|
strategy.event_sleeve_soft_day_max_avg_quality is not None
|
|
|
and stats["avg_quality"] > strategy.event_sleeve_soft_day_max_avg_quality
|
|
|
):
|
|
|
return False
|
|
|
if (
|
|
|
strategy.event_sleeve_soft_day_require_no_existing_event
|
|
|
and stats["event_count"] > 0
|
|
|
):
|
|
|
return False
|
|
|
return True
|
|
|
|
|
|
|
|
|
def _tail_risk_day_scaler(
|
|
|
picks: list[tuple[str, str]],
|
|
|
morning_gains: dict[str, dict],
|
|
|
strategy: StrategyParams,
|
|
|
) -> float:
|
|
|
if not picks or strategy.tail_risk_day_scale >= 1.0:
|
|
|
return 1.0
|
|
|
if not any(
|
|
|
[
|
|
|
strategy.tail_risk_day_max_trades is not None,
|
|
|
strategy.tail_risk_day_min_max_gain_pct is not None,
|
|
|
strategy.tail_risk_day_max_avg_quality is not None,
|
|
|
strategy.tail_risk_day_max_support_score is not None,
|
|
|
strategy.tail_risk_day_min_max_entropy_20d is not None,
|
|
|
strategy.tail_risk_day_min_max_confirmation_return_pct is not None,
|
|
|
strategy.tail_risk_day_require_no_event,
|
|
|
strategy.tail_risk_day_exempt_largecap,
|
|
|
]
|
|
|
):
|
|
|
return 1.0
|
|
|
stats = _basket_quality_stats(picks, morning_gains, strategy)
|
|
|
if (
|
|
|
strategy.tail_risk_day_max_trades is not None
|
|
|
and stats["count"] > strategy.tail_risk_day_max_trades
|
|
|
):
|
|
|
return 1.0
|
|
|
if (
|
|
|
strategy.tail_risk_day_min_max_gain_pct is not None
|
|
|
and stats["max_gain_pct"] < strategy.tail_risk_day_min_max_gain_pct
|
|
|
):
|
|
|
return 1.0
|
|
|
if (
|
|
|
strategy.tail_risk_day_max_avg_quality is not None
|
|
|
and stats["avg_quality"] > strategy.tail_risk_day_max_avg_quality
|
|
|
):
|
|
|
return 1.0
|
|
|
if (
|
|
|
strategy.tail_risk_day_max_support_score is not None
|
|
|
and stats["avg_support"] > strategy.tail_risk_day_max_support_score
|
|
|
):
|
|
|
return 1.0
|
|
|
if (
|
|
|
strategy.tail_risk_day_min_max_entropy_20d is not None
|
|
|
and stats["max_entropy_20d"] < strategy.tail_risk_day_min_max_entropy_20d
|
|
|
):
|
|
|
return 1.0
|
|
|
if (
|
|
|
strategy.tail_risk_day_min_max_confirmation_return_pct is not None
|
|
|
and stats["max_confirmation_return_pct"] < strategy.tail_risk_day_min_max_confirmation_return_pct
|
|
|
):
|
|
|
return 1.0
|
|
|
if strategy.tail_risk_day_require_no_event and stats["event_count"] > 0:
|
|
|
return 1.0
|
|
|
if strategy.tail_risk_day_exempt_largecap and stats["liquid_largecap_count"] > 0:
|
|
|
return 1.0
|
|
|
return max(0.0, min(1.0, strategy.tail_risk_day_scale))
|
|
|
|
|
|
|
|
|
def _apply_basket_quality_floor(
|
|
|
picks: list[tuple[str, str]],
|
|
|
morning_gains: dict[str, dict],
|
|
|
strategy: StrategyParams,
|
|
|
) -> list[tuple[str, str]]:
|
|
|
floor = getattr(strategy, "basket_quality_relative_floor", None)
|
|
|
if floor is None or floor <= 0 or not picks:
|
|
|
return picks
|
|
|
|
|
|
quality_by_ticker = {
|
|
|
ticker: _momentum_quality_score(morning_gains[ticker], strategy)
|
|
|
for ticker, _sleeve in picks
|
|
|
}
|
|
|
best_quality = max(quality_by_ticker.values(), default=0.0)
|
|
|
if best_quality <= 0:
|
|
|
return picks
|
|
|
|
|
|
threshold = best_quality * float(floor)
|
|
|
blend_only = bool(getattr(strategy, "basket_quality_prune_blend_only", False))
|
|
|
prunable = [
|
|
|
(ticker, sleeve)
|
|
|
for ticker, sleeve in picks
|
|
|
if not blend_only or sleeve == "blend"
|
|
|
]
|
|
|
if not prunable:
|
|
|
return picks
|
|
|
|
|
|
protected = {
|
|
|
ticker
|
|
|
for ticker, sleeve in picks
|
|
|
if blend_only and sleeve != "blend"
|
|
|
}
|
|
|
min_count = max(0, min(int(getattr(strategy, "basket_quality_min_count", 0) or 0), len(picks)))
|
|
|
required_from_prunable = max(0, min_count - len(protected))
|
|
|
forced = {
|
|
|
ticker
|
|
|
for ticker, _score in sorted(
|
|
|
((ticker, quality_by_ticker[ticker]) for ticker, _sleeve in prunable),
|
|
|
key=lambda item: item[1],
|
|
|
reverse=True,
|
|
|
)[:required_from_prunable]
|
|
|
}
|
|
|
kept: list[tuple[str, str]] = []
|
|
|
for ticker, sleeve in picks:
|
|
|
if ticker in protected or ticker in forced or quality_by_ticker.get(ticker, 0.0) >= threshold:
|
|
|
kept.append((ticker, sleeve))
|
|
|
return kept
|
|
|
|
|
|
|
|
|
def _select_momentum_sleeves(
|
|
|
morning_gains: dict[str, dict],
|
|
|
strategy: StrategyParams,
|
|
|
ticker_sectors: dict[str, str] | None = None,
|
|
|
) -> list[tuple[str, str]]:
|
|
|
"""Return ordered (ticker, sleeve) picks for the day."""
|
|
|
if not morning_gains:
|
|
|
return []
|
|
|
sector_cap = strategy.max_positions_per_sector if strategy.max_positions_per_sector and strategy.max_positions_per_sector > 0 else None
|
|
|
sector_counts: dict[str, int] = {}
|
|
|
|
|
|
def _sector_for_ticker(ticker: str) -> str | None:
|
|
|
if not ticker_sectors:
|
|
|
return None
|
|
|
sector = str(ticker_sectors.get(ticker) or "").strip()
|
|
|
if not sector or sector.upper() == "UNKNOWN":
|
|
|
return None
|
|
|
return sector
|
|
|
|
|
|
def _can_pick_ticker(ticker: str) -> bool:
|
|
|
if sector_cap is None:
|
|
|
return True
|
|
|
sector = _sector_for_ticker(ticker)
|
|
|
if sector is None:
|
|
|
return True
|
|
|
return sector_counts.get(sector, 0) < sector_cap
|
|
|
|
|
|
def _record_pick(ticker: str) -> None:
|
|
|
if sector_cap is None:
|
|
|
return
|
|
|
sector = _sector_for_ticker(ticker)
|
|
|
if sector is None:
|
|
|
return
|
|
|
sector_counts[sector] = sector_counts.get(sector, 0) + 1
|
|
|
|
|
|
if not strategy.use_five_sleeves:
|
|
|
ranked = sorted(
|
|
|
morning_gains.keys(),
|
|
|
key=lambda t: (
|
|
|
morning_gains[t]["gain_pct"],
|
|
|
morning_gains[t].get("entry_volume", 0.0),
|
|
|
),
|
|
|
reverse=True,
|
|
|
)
|
|
|
picks: list[tuple[str, str]] = []
|
|
|
for ticker in ranked:
|
|
|
if not _can_pick_ticker(ticker):
|
|
|
continue
|
|
|
picks.append((ticker, "core"))
|
|
|
_record_pick(ticker)
|
|
|
if len(picks) >= strategy.top_n:
|
|
|
break
|
|
|
return _apply_basket_quality_floor(picks, morning_gains, strategy)
|
|
|
|
|
|
sleeves = _five_sleeve_specs(strategy)
|
|
|
|
|
|
picks: list[tuple[str, str]] = []
|
|
|
chosen: set[str] = set()
|
|
|
items = list(morning_gains.items())
|
|
|
forced_sleeves = [
|
|
|
sleeve
|
|
|
for sleeve in sorted(sleeves, key=lambda sleeve: float(sleeve["weight"]), reverse=True)
|
|
|
if float(sleeve["weight"]) > 0
|
|
|
][: max(0, min(strategy.five_sleeve_force_count, len(sleeves)))]
|
|
|
for sleeve in forced_sleeves:
|
|
|
key_fn = sleeve["key_fn"]
|
|
|
ranked = sorted(items, key=key_fn, reverse=True)
|
|
|
for ticker, _info in ranked:
|
|
|
if ticker in chosen:
|
|
|
continue
|
|
|
if not _can_pick_ticker(ticker):
|
|
|
continue
|
|
|
picks.append((ticker, str(sleeve["label"])))
|
|
|
chosen.add(ticker)
|
|
|
_record_pick(ticker)
|
|
|
break
|
|
|
if len(picks) >= strategy.top_n:
|
|
|
return _apply_basket_quality_floor(picks[: strategy.top_n], morning_gains, strategy)
|
|
|
|
|
|
fallback_slots = max(0, int(getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0))
|
|
|
fallback_trigger = max(0, int(getattr(strategy, "fallback_liquid_largecap_trigger_below", 0) or 0))
|
|
|
if (
|
|
|
fallback_slots > 0
|
|
|
and len(picks) < strategy.top_n
|
|
|
and len(picks) < fallback_trigger
|
|
|
):
|
|
|
ranked_liquid = sorted(
|
|
|
(
|
|
|
item for item in items
|
|
|
if item[1].get("is_liquid_largecap")
|
|
|
),
|
|
|
key=lambda item: (
|
|
|
item[1].get("entry_dollar_volume", 0.0),
|
|
|
item[1].get("confirmation_return_pct", 0.0),
|
|
|
item[1].get("gain_pct", 0.0),
|
|
|
item[1].get("avg_dollar_vol_30d", 0.0),
|
|
|
),
|
|
|
reverse=True,
|
|
|
)
|
|
|
added = 0
|
|
|
for ticker, _info in ranked_liquid:
|
|
|
if ticker in chosen:
|
|
|
continue
|
|
|
if not _can_pick_ticker(ticker):
|
|
|
continue
|
|
|
picks.append((ticker, "liquid_largecap_fallback"))
|
|
|
chosen.add(ticker)
|
|
|
_record_pick(ticker)
|
|
|
added += 1
|
|
|
if len(picks) >= strategy.top_n or added >= fallback_slots:
|
|
|
break
|
|
|
|
|
|
def blended_score(item: tuple[str, dict]) -> float:
|
|
|
_ticker, info = item
|
|
|
score = 0.0
|
|
|
for sleeve in sleeves:
|
|
|
weight = float(sleeve["weight"])
|
|
|
if weight <= 0:
|
|
|
continue
|
|
|
score += weight * float(sleeve["component"](info))
|
|
|
return score
|
|
|
|
|
|
ranked_fill = sorted(items, key=blended_score, reverse=True)
|
|
|
for ticker, _info in ranked_fill:
|
|
|
if ticker in chosen:
|
|
|
continue
|
|
|
if not _can_pick_ticker(ticker):
|
|
|
continue
|
|
|
picks.append((ticker, "blend"))
|
|
|
chosen.add(ticker)
|
|
|
_record_pick(ticker)
|
|
|
if len(picks) >= strategy.top_n:
|
|
|
break
|
|
|
return _apply_basket_quality_floor(picks, morning_gains, strategy)
|
|
|
|
|
|
|
|
|
# ── Trade Simulation ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
def _apply_slippage_entry(price: float, slippage_bps: float) -> float:
|
|
|
"""Long entry fill: price × (1 + bps/10000)."""
|
|
|
return price * (1.0 + slippage_bps / 10_000)
|
|
|
|
|
|
|
|
|
def _apply_slippage_exit(price: float, slippage_bps: float) -> float:
|
|
|
"""Long exit fill: price × (1 - bps/10000)."""
|
|
|
return price * (1.0 - slippage_bps / 10_000)
|
|
|
|
|
|
|
|
|
def simulate_trade(
|
|
|
bars: list[dict],
|
|
|
entry_bar: dict,
|
|
|
entry_price_raw: float,
|
|
|
exit_offset_minutes: int,
|
|
|
stop_loss_pct: float | None,
|
|
|
trailing_stop_pct: float | None,
|
|
|
catastrophic_stop_price_raw: float | None,
|
|
|
trailing_activation_gain_pct: float | None,
|
|
|
slippage_bps: float,
|
|
|
date_str: str,
|
|
|
) -> tuple[float, str, str]:
|
|
|
"""Simulate a single intraday trade.
|
|
|
|
|
|
Supports catastrophic/fixed stops plus optional delayed trailing stops.
|
|
|
|
|
|
Returns:
|
|
|
(exit_price_after_slippage, exit_time_str, exit_reason)
|
|
|
"""
|
|
|
entry_price = _apply_slippage_entry(entry_price_raw, slippage_bps)
|
|
|
entry_ts = _parse_ts(entry_bar["timestamp"])
|
|
|
|
|
|
# Compute exit target time
|
|
|
market_close = _market_open_ts(date_str).replace(hour=16, minute=0)
|
|
|
exit_target = market_close - dt.timedelta(minutes=exit_offset_minutes)
|
|
|
|
|
|
exit_price_raw = entry_price_raw
|
|
|
exit_time_str = entry_bar["timestamp"]
|
|
|
exit_reason = "close"
|
|
|
|
|
|
# Trailing stop state
|
|
|
peak_price = entry_price_raw
|
|
|
|
|
|
for b in bars:
|
|
|
ts = _parse_ts(b["timestamp"])
|
|
|
if ts <= entry_ts:
|
|
|
continue
|
|
|
|
|
|
# Update peak for trailing stop
|
|
|
if b["high"] > peak_price:
|
|
|
peak_price = b["high"]
|
|
|
|
|
|
peak_gain_pct = (peak_price - entry_price_raw) / entry_price_raw if entry_price_raw > 0 else 0.0
|
|
|
trailing_active = (
|
|
|
trailing_stop_pct is not None
|
|
|
and (
|
|
|
trailing_activation_gain_pct is None
|
|
|
or peak_gain_pct >= trailing_activation_gain_pct
|
|
|
)
|
|
|
)
|
|
|
|
|
|
# Determine effective stop level
|
|
|
if trailing_active:
|
|
|
# Trailing: stop = peak × (1 + trailing_pct), trails upward
|
|
|
stop_price = peak_price * (1.0 + trailing_stop_pct) # trailing_pct is negative
|
|
|
low_price = b["low"]
|
|
|
if low_price <= stop_price:
|
|
|
exit_price_raw = stop_price
|
|
|
exit_time_str = b["timestamp"]
|
|
|
exit_reason = "trailing_stop"
|
|
|
break
|
|
|
else:
|
|
|
stop_price = catastrophic_stop_price_raw
|
|
|
if stop_price is None and stop_loss_pct is not None:
|
|
|
stop_price = entry_price_raw * (1.0 + stop_loss_pct)
|
|
|
if stop_price is not None and b["low"] <= stop_price:
|
|
|
exit_price_raw = stop_price
|
|
|
exit_time_str = b["timestamp"]
|
|
|
exit_reason = "stop_loss"
|
|
|
break
|
|
|
|
|
|
# Check scheduled exit time
|
|
|
if ts >= exit_target:
|
|
|
exit_price_raw = b["close"]
|
|
|
exit_time_str = b["timestamp"]
|
|
|
exit_reason = "close"
|
|
|
break
|
|
|
|
|
|
# Update running exit (last bar before exit time)
|
|
|
exit_price_raw = b["close"]
|
|
|
exit_time_str = b["timestamp"]
|
|
|
|
|
|
exit_price = _apply_slippage_exit(exit_price_raw, slippage_bps)
|
|
|
return exit_price, exit_time_str, exit_reason
|
|
|
|
|
|
|
|
|
# ── Morning Gain Computation ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
def compute_morning_gains(
|
|
|
bars_by_ticker: dict[str, list[dict]],
|
|
|
strategy: StrategyParams,
|
|
|
date_str: str,
|
|
|
blacklisted_tickers: set[str] | None = None,
|
|
|
spy_bars: list[dict] | None = None,
|
|
|
daily_features_by_ticker: dict[str, dict] | None = None,
|
|
|
vix_value: float | None = None,
|
|
|
) -> dict[str, dict]:
|
|
|
"""Compute each ticker's gain from open to entry time, applying all filters.
|
|
|
|
|
|
Filters applied:
|
|
|
- Minimum market-hours bars (_MIN_BARS)
|
|
|
- min_morning_gain_pct: stock must be up enough to qualify
|
|
|
- max_morning_gain_pct: cap extreme gap-ups that tend to mean-revert
|
|
|
- min_entry_volume: require sufficient trading activity by entry time
|
|
|
- blacklisted_tickers: tickers in cooldown period (recently traded)
|
|
|
- market_regime_spy_threshold: skip if SPY is down too much
|
|
|
|
|
|
Returns:
|
|
|
{ticker: {gain_pct, entry_price_raw, entry_bar, mkt_bars, entry_volume}}
|
|
|
"""
|
|
|
market_open = _market_open_ts(date_str)
|
|
|
|
|
|
# Market regime check: compute SPY's morning return
|
|
|
if strategy.market_regime_spy_threshold is not None and spy_bars:
|
|
|
spy_mkt = filter_market_hours(spy_bars)
|
|
|
if len(spy_mkt) >= 2:
|
|
|
spy_open = spy_mkt[0]["open"]
|
|
|
spy_entry_bar = _bar_at_offset(spy_mkt, market_open, strategy.entry_minutes_after_open)
|
|
|
if spy_open > 0 and spy_entry_bar is not None:
|
|
|
spy_gain = (spy_entry_bar["close"] - spy_open) / spy_open
|
|
|
if spy_gain < strategy.market_regime_spy_threshold:
|
|
|
return {} # Skip this day entirely
|
|
|
|
|
|
if strategy.max_vix is not None and vix_value is not None and vix_value > strategy.max_vix:
|
|
|
return {}
|
|
|
|
|
|
result = {}
|
|
|
|
|
|
for ticker, all_bars in bars_by_ticker.items():
|
|
|
# Skip blacklisted tickers (cooldown)
|
|
|
if blacklisted_tickers and ticker in blacklisted_tickers:
|
|
|
continue
|
|
|
|
|
|
mkt_bars = filter_market_hours(all_bars)
|
|
|
if len(mkt_bars) < _MIN_BARS:
|
|
|
continue
|
|
|
|
|
|
open_price = mkt_bars[0]["open"]
|
|
|
if open_price <= 0:
|
|
|
continue
|
|
|
|
|
|
initial_entry_bar = _bar_at_offset(mkt_bars, market_open, strategy.entry_minutes_after_open)
|
|
|
if initial_entry_bar is None:
|
|
|
continue
|
|
|
|
|
|
entry_bar = initial_entry_bar
|
|
|
if strategy.confirmation_minutes_after_entry > 0:
|
|
|
confirmation_bar = _bar_at_offset(
|
|
|
mkt_bars,
|
|
|
market_open,
|
|
|
strategy.entry_minutes_after_open + strategy.confirmation_minutes_after_entry,
|
|
|
)
|
|
|
if confirmation_bar is None:
|
|
|
continue
|
|
|
confirmation_return = (
|
|
|
confirmation_bar["close"] - initial_entry_bar["close"]
|
|
|
) / initial_entry_bar["close"]
|
|
|
entry_bar = confirmation_bar
|
|
|
else:
|
|
|
confirmation_return = None
|
|
|
|
|
|
entry_price_raw = entry_bar["close"]
|
|
|
if entry_price_raw <= 0:
|
|
|
continue
|
|
|
|
|
|
gain_pct = (entry_price_raw - open_price) / open_price
|
|
|
|
|
|
# volume filter: cumulative volume up to entry time
|
|
|
entry_ts = _parse_ts(entry_bar["timestamp"])
|
|
|
entry_vol = _volume_up_to_bar(mkt_bars, entry_ts)
|
|
|
if strategy.min_entry_volume is not None and entry_vol < strategy.min_entry_volume:
|
|
|
continue
|
|
|
entry_dollar_vol = _dollar_volume_up_to_bar(mkt_bars, entry_ts)
|
|
|
if (
|
|
|
strategy.min_entry_dollar_volume is not None
|
|
|
and entry_dollar_vol < strategy.min_entry_dollar_volume
|
|
|
):
|
|
|
continue
|
|
|
|
|
|
daily_features = (daily_features_by_ticker or {}).get(ticker, {})
|
|
|
gap_pct = daily_features.get("gap_pct")
|
|
|
gap_min_ok = (
|
|
|
strategy.min_gap_pct is None
|
|
|
or (gap_pct is not None and gap_pct >= strategy.min_gap_pct)
|
|
|
)
|
|
|
gap_max_ok = (
|
|
|
strategy.max_gap_pct is None
|
|
|
or (gap_pct is not None and gap_pct <= strategy.max_gap_pct)
|
|
|
)
|
|
|
|
|
|
volume_ratio_14d = None
|
|
|
avg_daily_vol_14d = daily_features.get("avg_daily_vol_14d")
|
|
|
if avg_daily_vol_14d and avg_daily_vol_14d > 0:
|
|
|
volume_ratio_14d = entry_vol / avg_daily_vol_14d
|
|
|
if (
|
|
|
strategy.min_volume_ratio_14d is not None
|
|
|
and (volume_ratio_14d is None or volume_ratio_14d < strategy.min_volume_ratio_14d)
|
|
|
):
|
|
|
continue
|
|
|
|
|
|
ret_5d = daily_features.get("ret_5d")
|
|
|
if strategy.min_ret_5d is not None and (ret_5d is None or ret_5d < strategy.min_ret_5d):
|
|
|
continue
|
|
|
|
|
|
entropy_20d = daily_features.get("entropy_20d")
|
|
|
avg_dollar_vol_30d = daily_features.get("avg_dollar_vol_30d")
|
|
|
atr_14 = daily_features.get("atr_14")
|
|
|
event_flag = bool(daily_features.get("event_flag"))
|
|
|
event_score = float(daily_features.get("event_score") or 0.0)
|
|
|
attention_wiki_spike_10d = float(daily_features.get("attention_wiki_spike_10d") or 0.0)
|
|
|
attention_article_count_3d = int(daily_features.get("attention_article_count_3d") or 0)
|
|
|
attention_us_article_count_3d = int(daily_features.get("attention_us_article_count_3d") or 0)
|
|
|
attention_resolver_confidence = float(daily_features.get("attention_resolver_confidence") or 0.0)
|
|
|
is_event_candidate = False
|
|
|
if strategy.use_event_sleeve and event_flag:
|
|
|
if strategy.event_min_score is None or event_score >= strategy.event_min_score:
|
|
|
is_event_candidate = True
|
|
|
if strategy.min_entropy_20d is not None and (entropy_20d is None or entropy_20d < strategy.min_entropy_20d):
|
|
|
continue
|
|
|
global_max_entropy_ok = True
|
|
|
if strategy.max_entropy_20d is not None and (entropy_20d is None or entropy_20d > strategy.max_entropy_20d):
|
|
|
global_max_entropy_ok = False
|
|
|
|
|
|
opening_range_bars = [bar for bar in mkt_bars if _parse_ts(bar["timestamp"]) <= entry_ts]
|
|
|
if not opening_range_bars:
|
|
|
continue
|
|
|
opening_range_high = max(float(bar["high"]) for bar in opening_range_bars)
|
|
|
opening_range_low = min(float(bar["low"]) for bar in opening_range_bars)
|
|
|
opening_range_width = max(0.0, opening_range_high - opening_range_low)
|
|
|
recovery_from_opening_low_pct = (
|
|
|
(entry_price_raw - opening_range_low) / opening_range_low
|
|
|
if opening_range_low > 0
|
|
|
else None
|
|
|
)
|
|
|
|
|
|
if strategy.atr_stop_multiplier is not None and (atr_14 is None or atr_14 <= 0):
|
|
|
continue
|
|
|
if strategy.opening_range_stop_multiplier is not None and opening_range_width <= 0:
|
|
|
continue
|
|
|
|
|
|
confirmation_ok = (
|
|
|
strategy.min_confirmation_return_pct is None
|
|
|
or confirmation_return is None
|
|
|
or confirmation_return >= strategy.min_confirmation_return_pct
|
|
|
)
|
|
|
|
|
|
regular_ok = gap_min_ok and gap_max_ok and global_max_entropy_ok and confirmation_ok and gain_pct >= strategy.min_morning_gain_pct and (
|
|
|
strategy.max_morning_gain_pct is None or gain_pct <= strategy.max_morning_gain_pct
|
|
|
)
|
|
|
slow_ignite_ok = False
|
|
|
if (
|
|
|
strategy.use_slow_ignite_sleeve
|
|
|
and gap_min_ok
|
|
|
and gap_max_ok
|
|
|
and global_max_entropy_ok
|
|
|
and confirmation_ok
|
|
|
and gain_pct < strategy.min_morning_gain_pct
|
|
|
):
|
|
|
if strategy.slow_ignite_min_gain_pct is not None and gain_pct < strategy.slow_ignite_min_gain_pct:
|
|
|
pass
|
|
|
elif strategy.slow_ignite_max_gain_pct is not None and gain_pct > strategy.slow_ignite_max_gain_pct:
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.slow_ignite_min_entry_dollar_volume is not None
|
|
|
and entry_dollar_vol < strategy.slow_ignite_min_entry_dollar_volume
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.slow_ignite_min_volume_ratio_14d is not None
|
|
|
and (volume_ratio_14d is None or volume_ratio_14d < strategy.slow_ignite_min_volume_ratio_14d)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.slow_ignite_min_ret_5d is not None
|
|
|
and (ret_5d is None or ret_5d < strategy.slow_ignite_min_ret_5d)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.slow_ignite_max_entropy_20d is not None
|
|
|
and (entropy_20d is None or entropy_20d > strategy.slow_ignite_max_entropy_20d)
|
|
|
):
|
|
|
pass
|
|
|
else:
|
|
|
slow_ignite_ok = True
|
|
|
|
|
|
liquid_largecap_ok = False
|
|
|
liquid_largecap_enabled = (
|
|
|
strategy.use_liquid_largecap_sleeve
|
|
|
or (getattr(strategy, "fallback_liquid_largecap_slots", 0) or 0) > 0
|
|
|
)
|
|
|
if liquid_largecap_enabled and gap_min_ok and gap_max_ok and confirmation_ok:
|
|
|
liquid_largecap_entropy_cap = strategy.liquid_largecap_max_entropy_20d
|
|
|
if liquid_largecap_entropy_cap is None:
|
|
|
liquid_largecap_entropy_cap = strategy.max_entropy_20d
|
|
|
if (
|
|
|
strategy.liquid_largecap_min_gain_pct is not None
|
|
|
and gain_pct < strategy.liquid_largecap_min_gain_pct
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.liquid_largecap_max_gain_pct is not None
|
|
|
and gain_pct > strategy.liquid_largecap_max_gain_pct
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.liquid_largecap_min_confirmation_return_pct is not None
|
|
|
and confirmation_return < strategy.liquid_largecap_min_confirmation_return_pct
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.liquid_largecap_min_entry_dollar_volume is not None
|
|
|
and entry_dollar_vol < strategy.liquid_largecap_min_entry_dollar_volume
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.liquid_largecap_min_avg_dollar_vol_30d is not None
|
|
|
and (
|
|
|
avg_dollar_vol_30d is None
|
|
|
or avg_dollar_vol_30d < strategy.liquid_largecap_min_avg_dollar_vol_30d
|
|
|
)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
liquid_largecap_entropy_cap is not None
|
|
|
and (entropy_20d is None or entropy_20d > liquid_largecap_entropy_cap)
|
|
|
):
|
|
|
pass
|
|
|
else:
|
|
|
liquid_largecap_ok = True
|
|
|
|
|
|
moderate_gap_liquid_ok = False
|
|
|
if strategy.use_moderate_gap_liquid_sleeve:
|
|
|
moderate_entropy_cap = strategy.moderate_gap_liquid_max_entropy_20d
|
|
|
if moderate_entropy_cap is None:
|
|
|
moderate_entropy_cap = strategy.max_entropy_20d
|
|
|
atr_pct = (
|
|
|
float(atr_14) / float(open_price)
|
|
|
if atr_14 is not None and open_price > 0
|
|
|
else None
|
|
|
)
|
|
|
if strategy.moderate_gap_liquid_min_gap_pct is not None and (
|
|
|
gap_pct is None or gap_pct < strategy.moderate_gap_liquid_min_gap_pct
|
|
|
):
|
|
|
pass
|
|
|
elif strategy.moderate_gap_liquid_max_gap_pct is not None and (
|
|
|
gap_pct is None or gap_pct > strategy.moderate_gap_liquid_max_gap_pct
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.moderate_gap_liquid_min_gain_pct is not None
|
|
|
and gain_pct < strategy.moderate_gap_liquid_min_gain_pct
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.moderate_gap_liquid_max_gain_pct is not None
|
|
|
and gain_pct > strategy.moderate_gap_liquid_max_gain_pct
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.moderate_gap_liquid_min_confirmation_return_pct is not None
|
|
|
and (
|
|
|
confirmation_return is None
|
|
|
or confirmation_return < strategy.moderate_gap_liquid_min_confirmation_return_pct
|
|
|
)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.moderate_gap_liquid_min_entry_dollar_volume is not None
|
|
|
and entry_dollar_vol < strategy.moderate_gap_liquid_min_entry_dollar_volume
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.moderate_gap_liquid_min_avg_dollar_vol_30d is not None
|
|
|
and (
|
|
|
avg_dollar_vol_30d is None
|
|
|
or avg_dollar_vol_30d < strategy.moderate_gap_liquid_min_avg_dollar_vol_30d
|
|
|
)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.moderate_gap_liquid_max_avg_dollar_vol_30d is not None
|
|
|
and (
|
|
|
avg_dollar_vol_30d is None
|
|
|
or avg_dollar_vol_30d > strategy.moderate_gap_liquid_max_avg_dollar_vol_30d
|
|
|
)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.moderate_gap_liquid_min_volume_ratio_14d is not None
|
|
|
and (
|
|
|
volume_ratio_14d is None
|
|
|
or volume_ratio_14d < strategy.moderate_gap_liquid_min_volume_ratio_14d
|
|
|
)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.moderate_gap_liquid_min_atr_pct is not None
|
|
|
and (atr_pct is None or atr_pct < strategy.moderate_gap_liquid_min_atr_pct)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
moderate_entropy_cap is not None
|
|
|
and (entropy_20d is None or entropy_20d > moderate_entropy_cap)
|
|
|
):
|
|
|
pass
|
|
|
else:
|
|
|
moderate_gap_liquid_ok = True
|
|
|
|
|
|
gap_reclaim_ok = False
|
|
|
if strategy.use_gap_reclaim_sleeve:
|
|
|
if strategy.gap_reclaim_min_gap_pct is not None and (
|
|
|
gap_pct is None or gap_pct < strategy.gap_reclaim_min_gap_pct
|
|
|
):
|
|
|
pass
|
|
|
elif strategy.gap_reclaim_min_gain_pct is not None and gain_pct < strategy.gap_reclaim_min_gain_pct:
|
|
|
pass
|
|
|
elif strategy.gap_reclaim_max_gain_pct is not None and gain_pct > strategy.gap_reclaim_max_gain_pct:
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.gap_reclaim_min_confirmation_return_pct is not None
|
|
|
and (
|
|
|
confirmation_return is None
|
|
|
or confirmation_return < strategy.gap_reclaim_min_confirmation_return_pct
|
|
|
)
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.gap_reclaim_min_entry_dollar_volume is not None
|
|
|
and entry_dollar_vol < strategy.gap_reclaim_min_entry_dollar_volume
|
|
|
):
|
|
|
pass
|
|
|
elif (
|
|
|
strategy.gap_reclaim_min_recovery_from_opening_low_pct is not None
|
|
|
and (
|
|
|
recovery_from_opening_low_pct is None
|
|
|
or recovery_from_opening_low_pct < strategy.gap_reclaim_min_recovery_from_opening_low_pct
|
|
|
)
|
|
|
):
|
|
|
pass
|
|
|
else:
|
|
|
gap_reclaim_ok = True
|
|
|
|
|
|
if (
|
|
|
not regular_ok
|
|
|
and not slow_ignite_ok
|
|
|
and not liquid_largecap_ok
|
|
|
and not moderate_gap_liquid_ok
|
|
|
and not gap_reclaim_ok
|
|
|
):
|
|
|
continue
|
|
|
|
|
|
result[ticker] = {
|
|
|
"gain_pct": gain_pct,
|
|
|
"entry_price_raw": entry_price_raw,
|
|
|
"entry_bar": entry_bar,
|
|
|
"mkt_bars": mkt_bars,
|
|
|
"entry_volume": entry_vol,
|
|
|
"entry_dollar_volume": entry_dollar_vol,
|
|
|
"gap_pct": gap_pct,
|
|
|
"volume_ratio_14d": volume_ratio_14d,
|
|
|
"ret_5d": ret_5d,
|
|
|
"entropy_20d": entropy_20d,
|
|
|
"avg_dollar_vol_30d": avg_dollar_vol_30d,
|
|
|
"atr_14": atr_14,
|
|
|
"event_flag": event_flag,
|
|
|
"event_score": event_score,
|
|
|
"attention_wiki_spike_10d": attention_wiki_spike_10d,
|
|
|
"attention_article_count_3d": attention_article_count_3d,
|
|
|
"attention_us_article_count_3d": attention_us_article_count_3d,
|
|
|
"attention_resolver_confidence": attention_resolver_confidence,
|
|
|
"is_event_candidate": is_event_candidate,
|
|
|
"opening_range_width": opening_range_width,
|
|
|
"recovery_from_opening_low_pct": recovery_from_opening_low_pct,
|
|
|
"confirmation_return_pct": confirmation_return,
|
|
|
"is_slow_ignite": slow_ignite_ok,
|
|
|
"is_liquid_largecap": liquid_largecap_ok,
|
|
|
"is_moderate_gap_liquid": moderate_gap_liquid_ok,
|
|
|
"is_gap_reclaim": gap_reclaim_ok,
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
# ── Day Simulation ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
def simulate_day(
|
|
|
bars_by_ticker: dict[str, list[dict]],
|
|
|
date_str: str,
|
|
|
strategy: StrategyParams,
|
|
|
blacklisted_tickers: set[str] | None = None,
|
|
|
spy_bars: list[dict] | None = None,
|
|
|
daily_features_by_ticker: dict[str, dict] | None = None,
|
|
|
vix_value: float | None = None,
|
|
|
current_equity: float | None = None,
|
|
|
ticker_sectors: dict[str, str] | None = None,
|
|
|
) -> DayResult:
|
|
|
"""Simulate one full trading day.
|
|
|
|
|
|
1. Apply all filters to find qualified morning gainers.
|
|
|
2. Rank by gain, pick top N.
|
|
|
3. Simulate each trade with stop-loss / trailing stop.
|
|
|
4. Compute daily P&L.
|
|
|
|
|
|
Args:
|
|
|
current_equity: Current portfolio equity for compound position sizing.
|
|
|
When strategy.compound_returns=True and this is provided,
|
|
|
position sizes scale with current equity. Otherwise uses
|
|
|
strategy.initial_capital (simple/단리 mode).
|
|
|
"""
|
|
|
result = DayResult(date=date_str)
|
|
|
|
|
|
if strategy.max_vix is not None and vix_value is not None and vix_value > strategy.max_vix:
|
|
|
result.skip_reason = "vix_gate"
|
|
|
return result
|
|
|
|
|
|
if _intraday_regime_failed(strategy, spy_bars, date_str):
|
|
|
result.skip_reason = "market_regime"
|
|
|
return result
|
|
|
|
|
|
regime_scaler, regime_skip = _day_regime_scaler(strategy, daily_features_by_ticker)
|
|
|
if regime_skip is not None:
|
|
|
result.skip_reason = regime_skip
|
|
|
return result
|
|
|
breadth_scaler, breadth_skip = _day_breadth_scaler(
|
|
|
strategy,
|
|
|
bars_by_ticker,
|
|
|
daily_features_by_ticker,
|
|
|
)
|
|
|
if breadth_skip is not None:
|
|
|
result.skip_reason = breadth_skip
|
|
|
return result
|
|
|
result.regime_scaler = regime_scaler
|
|
|
result.breadth_scaler = breadth_scaler
|
|
|
|
|
|
morning_gains = compute_morning_gains(
|
|
|
bars_by_ticker,
|
|
|
strategy,
|
|
|
date_str,
|
|
|
blacklisted_tickers=blacklisted_tickers,
|
|
|
spy_bars=None,
|
|
|
daily_features_by_ticker=daily_features_by_ticker,
|
|
|
vix_value=None,
|
|
|
)
|
|
|
result.candidates_found = len(morning_gains)
|
|
|
|
|
|
if not morning_gains:
|
|
|
result.skip_reason = "no_candidates"
|
|
|
return result
|
|
|
|
|
|
selection_strategy = strategy
|
|
|
if strategy.use_event_sleeve and strategy.event_sleeve_soft_day_only:
|
|
|
selection_strategy = strategy.model_copy(update={"use_event_sleeve": False, "event_weight": 0.0})
|
|
|
|
|
|
top_tickers = _select_momentum_sleeves(
|
|
|
morning_gains,
|
|
|
selection_strategy,
|
|
|
ticker_sectors=ticker_sectors,
|
|
|
)
|
|
|
if not top_tickers:
|
|
|
result.skip_reason = "no_candidates"
|
|
|
return result
|
|
|
if len(top_tickers) < max(1, strategy.min_positions_to_trade):
|
|
|
result.skip_reason = "below_min_candidates"
|
|
|
return result
|
|
|
|
|
|
sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy)
|
|
|
result.sector_scaler = sector_scaler
|
|
|
base_soft_day = (
|
|
|
regime_scaler * breadth_scaler * sector_scaler
|
|
|
) < strategy.soft_day_scaler_threshold
|
|
|
result.is_soft_day = base_soft_day
|
|
|
if _should_enable_soft_day_event_sleeve(
|
|
|
top_tickers,
|
|
|
morning_gains,
|
|
|
strategy,
|
|
|
base_soft_day=base_soft_day,
|
|
|
):
|
|
|
soft_day_tickers = _select_momentum_sleeves(
|
|
|
morning_gains,
|
|
|
strategy,
|
|
|
ticker_sectors=ticker_sectors,
|
|
|
)
|
|
|
if soft_day_tickers:
|
|
|
top_tickers = soft_day_tickers
|
|
|
sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy)
|
|
|
result.sector_scaler = sector_scaler
|
|
|
result.is_soft_day = (
|
|
|
regime_scaler * breadth_scaler * sector_scaler
|
|
|
) < strategy.soft_day_scaler_threshold
|
|
|
if result.is_soft_day and strategy.soft_day_max_trades is not None and strategy.soft_day_max_trades > 0:
|
|
|
top_tickers = top_tickers[: strategy.soft_day_max_trades]
|
|
|
sector_scaler = _basket_sector_scaler(top_tickers, ticker_sectors, strategy)
|
|
|
result.sector_scaler = sector_scaler
|
|
|
result.is_soft_day = (regime_scaler * breadth_scaler * sector_scaler) < strategy.soft_day_scaler_threshold
|
|
|
tail_risk_scaler = _tail_risk_day_scaler(top_tickers, morning_gains, strategy)
|
|
|
result.tail_risk_scaler = tail_risk_scaler
|
|
|
|
|
|
if strategy.daily_budget_reset:
|
|
|
# Research mode: every day resets to initial_capital (ignore prior-day PnL).
|
|
|
sizing_capital = strategy.initial_capital
|
|
|
elif strategy.compound_returns and current_equity is not None:
|
|
|
sizing_capital = max(current_equity, 0.0)
|
|
|
elif current_equity is not None:
|
|
|
# Simple mode: fixed at initial_capital, but cannot exceed actual equity
|
|
|
# (can't invest money you don't have after drawdowns).
|
|
|
sizing_capital = min(strategy.initial_capital, max(current_equity, 0.0))
|
|
|
else:
|
|
|
sizing_capital = strategy.initial_capital
|
|
|
capital_budget = (
|
|
|
sizing_capital
|
|
|
* _vix_day_scaler(vix_value, strategy)
|
|
|
* regime_scaler
|
|
|
* breadth_scaler
|
|
|
* sector_scaler
|
|
|
* tail_risk_scaler
|
|
|
* _sparse_day_scaler(len(top_tickers), strategy)
|
|
|
)
|
|
|
capital_per_trade = capital_budget / len(top_tickers)
|
|
|
|
|
|
for ticker, sleeve in top_tickers:
|
|
|
info = morning_gains[ticker]
|
|
|
entry_price_raw = info["entry_price_raw"]
|
|
|
entry_bar = info["entry_bar"]
|
|
|
mkt_bars = info["mkt_bars"]
|
|
|
|
|
|
exit_price, exit_time_str, exit_reason = simulate_trade(
|
|
|
mkt_bars,
|
|
|
entry_bar,
|
|
|
entry_price_raw,
|
|
|
strategy.exit_minutes_before_close,
|
|
|
strategy.stop_loss_pct,
|
|
|
_trade_trailing_stop_pct(info, strategy),
|
|
|
_trade_catastrophic_stop_price(info, strategy),
|
|
|
strategy.trailing_activation_gain_pct,
|
|
|
strategy.slippage_bps,
|
|
|
date_str,
|
|
|
)
|
|
|
|
|
|
entry_price_filled = _apply_slippage_entry(entry_price_raw, strategy.slippage_bps)
|
|
|
trade_capital = capital_per_trade * _entropy_trade_scaler(info.get("entropy_20d"), strategy)
|
|
|
shares = trade_capital / entry_price_filled
|
|
|
pnl_pct = (exit_price - entry_price_filled) / entry_price_filled
|
|
|
pnl = pnl_pct * trade_capital
|
|
|
|
|
|
slippage_cost = (
|
|
|
(entry_price_filled - entry_price_raw) +
|
|
|
(entry_price_raw * strategy.slippage_bps / 10_000)
|
|
|
) * shares
|
|
|
|
|
|
trade = IntradayTrade(
|
|
|
date=date_str,
|
|
|
ticker=ticker,
|
|
|
entry_price=round(entry_price_filled, 4),
|
|
|
exit_price=round(exit_price, 4),
|
|
|
entry_time=entry_bar["timestamp"],
|
|
|
exit_time=exit_time_str,
|
|
|
shares=round(shares, 4),
|
|
|
pnl=round(pnl, 4),
|
|
|
pnl_pct=round(pnl_pct, 6),
|
|
|
exit_reason=exit_reason,
|
|
|
morning_gain_pct=round(info["gain_pct"], 6),
|
|
|
slippage_cost=round(slippage_cost, 4),
|
|
|
trade_sleeve=sleeve,
|
|
|
total_capital_deployed=round(trade_capital, 4),
|
|
|
)
|
|
|
result.trades.append(trade)
|
|
|
result.daily_pnl += trade.pnl
|
|
|
|
|
|
if result.trades:
|
|
|
total_deployed = sum((t.total_capital_deployed or (t.shares * t.entry_price)) for t in result.trades)
|
|
|
result.capital_deployed = round(total_deployed, 4)
|
|
|
# Daily return should reflect portfolio-level exposure, not just deployed capital.
|
|
|
# This keeps sparse-day / VIX / entropy size scaling visible in Sharpe and loss metrics.
|
|
|
if sizing_capital > 0:
|
|
|
result.daily_return_pct = result.daily_pnl / sizing_capital
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
# ── Full Backtest Simulation ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
def run_simulation(
|
|
|
all_intraday: dict[str, dict[str, list[dict]]],
|
|
|
trading_days: list[str],
|
|
|
strategy: StrategyParams,
|
|
|
*,
|
|
|
daily_enrichment: dict[str, dict[str, dict]] | None = None,
|
|
|
vix_by_day: dict[str, float] | None = None,
|
|
|
ticker_sectors: dict[str, str] | None = None,
|
|
|
) -> list[DayResult]:
|
|
|
"""Run the full backtest simulation across all trading days.
|
|
|
|
|
|
Pure computation — no API calls, no disk I/O.
|
|
|
Safe to call repeatedly with different strategy params for sweep mode.
|
|
|
|
|
|
Implements:
|
|
|
- Ticker cooldown (blackout period after trading a ticker)
|
|
|
- Market regime filter via SPY bars
|
|
|
- All strategy filters (max gain, min volume, trailing stop, etc.)
|
|
|
|
|
|
Args:
|
|
|
all_intraday: {date: {ticker: [bars]}} — pre-loaded intraday data.
|
|
|
trading_days: Ordered list of dates to simulate.
|
|
|
strategy: Strategy parameters.
|
|
|
|
|
|
Returns:
|
|
|
List of DayResult objects (one per trading day; days without intraday data get a 0% return result).
|
|
|
"""
|
|
|
results: list[DayResult] = []
|
|
|
|
|
|
# Ticker cooldown: map ticker -> last traded date
|
|
|
ticker_last_traded: dict[str, dt.date] = {}
|
|
|
|
|
|
# Compound return tracking: equity grows with each day's P&L
|
|
|
equity = strategy.initial_capital
|
|
|
rolling_pnl_window: list[float] = []
|
|
|
|
|
|
for date_str in trading_days:
|
|
|
bars_by_ticker = all_intraday.get(date_str)
|
|
|
if not bars_by_ticker:
|
|
|
# No intraday data for this day — still record it (0% return, no trades)
|
|
|
results.append(DayResult(date=date_str))
|
|
|
rolling_pnl_window.append(0.0)
|
|
|
continue
|
|
|
|
|
|
# Build blacklist from cooldown
|
|
|
blacklisted: set[str] = set()
|
|
|
if strategy.ticker_cooldown_days > 0:
|
|
|
current_date = dt.date.fromisoformat(date_str)
|
|
|
for ticker, last_dt in ticker_last_traded.items():
|
|
|
days_since = (current_date - last_dt).days
|
|
|
if days_since <= strategy.ticker_cooldown_days:
|
|
|
blacklisted.add(ticker)
|
|
|
|
|
|
if (
|
|
|
strategy.rolling_loss_days is not None
|
|
|
and strategy.rolling_loss_threshold is not None
|
|
|
and len(rolling_pnl_window) >= strategy.rolling_loss_days
|
|
|
):
|
|
|
n_roll = strategy.rolling_loss_days
|
|
|
rolling_pnl = sum(rolling_pnl_window[-n_roll:])
|
|
|
if strategy.daily_budget_reset or not strategy.compound_returns:
|
|
|
sizing_capital_for_check = strategy.initial_capital
|
|
|
else:
|
|
|
sizing_capital_for_check = equity
|
|
|
if sizing_capital_for_check > 0:
|
|
|
rolling_return = rolling_pnl / sizing_capital_for_check
|
|
|
if rolling_return < strategy.rolling_loss_threshold:
|
|
|
results.append(DayResult(date=date_str, skip_reason="rolling_loss"))
|
|
|
rolling_pnl_window.append(0.0)
|
|
|
continue
|
|
|
|
|
|
# Extract SPY bars for regime filter
|
|
|
spy_bars = bars_by_ticker.get("SPY") if strategy.market_regime_spy_threshold is not None else None
|
|
|
|
|
|
day_features_by_ticker = (
|
|
|
{
|
|
|
ticker: daily_enrichment.get(ticker, {}).get(date_str, {})
|
|
|
for ticker in bars_by_ticker.keys()
|
|
|
}
|
|
|
if daily_enrichment else None
|
|
|
)
|
|
|
if daily_enrichment and (
|
|
|
strategy.market_regime_gap_threshold is not None
|
|
|
or strategy.regime_size_scale_low is not None
|
|
|
or strategy.regime_skip_below is not None
|
|
|
):
|
|
|
regime_ticker = strategy.market_regime_gap_ticker or "SPY"
|
|
|
day_features_by_ticker = day_features_by_ticker or {}
|
|
|
day_features_by_ticker.setdefault(
|
|
|
regime_ticker,
|
|
|
daily_enrichment.get(regime_ticker, {}).get(date_str, {}),
|
|
|
)
|
|
|
|
|
|
day_result = simulate_day(
|
|
|
bars_by_ticker,
|
|
|
date_str,
|
|
|
strategy,
|
|
|
blacklisted_tickers=blacklisted if blacklisted else None,
|
|
|
spy_bars=spy_bars,
|
|
|
daily_features_by_ticker=day_features_by_ticker,
|
|
|
vix_value=(vix_by_day or {}).get(date_str),
|
|
|
current_equity=equity,
|
|
|
ticker_sectors=ticker_sectors,
|
|
|
)
|
|
|
results.append(day_result)
|
|
|
equity += day_result.daily_pnl
|
|
|
rolling_pnl_window.append(day_result.daily_pnl)
|
|
|
|
|
|
# Update cooldown tracker
|
|
|
if strategy.ticker_cooldown_days > 0:
|
|
|
current_date = dt.date.fromisoformat(date_str)
|
|
|
for trade in day_result.trades:
|
|
|
ticker_last_traded[trade.ticker] = current_date
|
|
|
|
|
|
return results
|