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.

1725 lines
65 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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