Skip to content

Commit 9d6ddd8

Browse files
committed
fix: address quant review — train full universe, add IC gate, extend embargo, widen brackets, add VaR compliance scaling
P1: Train on all ~500 S&P 500 tickers instead of 100 random sample P2: VaR breach now scales positions proportionally instead of skipping cycle P3: IC quality gate (min 0.02) — falls back to equal-weight if model is weak P4: Embargo extended from 5 to 22 business days to match feature lookback P5: OCO brackets widened to 3x/5x ATR (from 2x/3x) for weekly holding period P6: Estimated spread cost tracking at 2 bps (configurable via ESTIMATED_SPREAD_BPS) P7: Winsorize bounds logged as MLflow artifact for version consistency P8: Ledoit-Wolf shrinkage now unconditional (was gated by n_obs < n_assets*3)
1 parent d539e47 commit 9d6ddd8

4 files changed

Lines changed: 104 additions & 20 deletions

File tree

examples/live_bot.py

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,15 @@ def _env_int(key: str, default: int) -> int:
8585
MAX_POSITION_WEIGHT = _env_float("MAX_POSITION_WEIGHT", 0.30)
8686
MAX_PORTFOLIO_VAR_95 = _env_float("MAX_PORTFOLIO_VAR_95", 0.06)
8787
MAX_DRAWDOWN_LIMIT = _env_float("MAX_DRAWDOWN_LIMIT", 0.15)
88-
STOP_LOSS_PCT = _env_float("STOP_LOSS_PCT", 0.05)
89-
TAKE_PROFIT_PCT = _env_float("TAKE_PROFIT_PCT", 0.15)
90-
ATR_SL_MULTIPLIER = _env_float("ATR_SL_MULTIPLIER", 2.0)
91-
ATR_TP_MULTIPLIER = _env_float("ATR_TP_MULTIPLIER", 3.0)
88+
# Bracket defaults are sized for a weekly holding period. Tighter brackets
89+
# (e.g., 2x/3x ATR) trigger on normal 1-2 day volatility spikes, prematurely
90+
# stopping out positions before the 5-day prediction horizon plays out.
91+
# 3x ATR SL / 5x ATR TP gives ~3:5 risk/reward and fewer whipsaws.
92+
# Fixed fallback percentages apply when ATR is unavailable.
93+
STOP_LOSS_PCT = _env_float("STOP_LOSS_PCT", 0.07)
94+
TAKE_PROFIT_PCT = _env_float("TAKE_PROFIT_PCT", 0.20)
95+
ATR_SL_MULTIPLIER = _env_float("ATR_SL_MULTIPLIER", 3.0)
96+
ATR_TP_MULTIPLIER = _env_float("ATR_TP_MULTIPLIER", 5.0)
9297
SLEEP_AFTER_TRADE_HOURS = _env_int("SLEEP_AFTER_TRADE_HOURS", 12)
9398
SLEEP_MARKET_CLOSED_HOURS = _env_int("SLEEP_MARKET_CLOSED_HOURS", 1)
9499
ORDER_POLL_INTERVAL_SECS = _env_int("ORDER_POLL_INTERVAL_SECS", 2)
@@ -1077,9 +1082,14 @@ def _handle_sigterm(signum, frame):
10771082

10781083
# Create execution bridge once — persists equity curve, P&L, and weight
10791084
# history across cycles. Each cycle syncs positions/equity from broker.
1085+
# Alpaca is commission-free for equities, but we track estimated spread
1086+
# costs at 2 bps per trade as a proxy for real-world execution costs.
10801087
account = broker.get_account()
1088+
estimated_spread_bps = _env_float("ESTIMATED_SPREAD_BPS", 0.0002)
10811089
bridge = ExecutionBridge(
1082-
risk_manager=risk_manager, initial_capital=account.equity, commission_rate=0.0
1090+
risk_manager=risk_manager,
1091+
initial_capital=account.equity,
1092+
commission_rate=estimated_spread_bps,
10831093
)
10841094
_bridge_ref[0] = bridge # C3 fix: make bridge accessible to SIGTERM handler
10851095

@@ -1153,8 +1163,36 @@ def _handle_sigterm(signum, frame):
11531163
f"{drawdown_violations[0].message}. Liquidating all positions."
11541164
)
11551165
_liquidate_all_positions(broker)
1166+
time.sleep(60 * 30)
1167+
continue
1168+
1169+
# Non-drawdown violations (VaR, leverage): reduce exposure to
1170+
# compliance instead of skipping the cycle entirely. Scale
1171+
# all current positions proportionally so the breached metric
1172+
# falls within limits.
1173+
var_violations = [v for v in critical_violations if v.rule == "MAX_VAR_95"]
1174+
if var_violations and weights is not None and len(weights) > 0:
1175+
v = var_violations[0]
1176+
# scale_factor = limit / actual, clamped to (0.1, 0.95)
1177+
scale = max(0.1, min(0.95, v.limit_value / max(v.metric_value, 1e-9)))
1178+
scaled = weights * scale
1179+
logger.warning(
1180+
f"VaR breach — scaling positions by {scale:.2f} "
1181+
f"(VaR {v.metric_value:.2%} → target {v.limit_value:.2%})"
1182+
)
1183+
_send_alert(
1184+
f"[LiveBot] VaR breach: scaling positions by {scale:.2f}. "
1185+
f"VaR={v.metric_value:.2%}, limit={v.limit_value:.2%}"
1186+
)
1187+
bridge.reconcile_target_weights(
1188+
target_weights=scaled.to_dict(),
1189+
broker=broker,
1190+
)
11561191
else:
1157-
logger.warning("Skipping trade cycle due to critical risk violations.")
1192+
logger.warning(
1193+
"Skipping trade cycle due to critical risk violations "
1194+
"(no reduce-to-compliance path available)."
1195+
)
11581196
time.sleep(60 * 30)
11591197
continue
11601198

python/alpha/predict.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import hashlib
88
import json
99
import logging
10-
import random
1110
import tempfile
1211
from datetime import date, datetime, timezone
1312
from pathlib import Path
@@ -51,6 +50,11 @@
5150
# training data (~500 dates × 500 tickers = 250k samples).
5251
TRAINING_LOOKBACK = "2y"
5352

53+
# IC quality gate: if validation IC is below this threshold, the model
54+
# is too weak to trust for live trading. Fall back to equal-weight.
55+
# 0.02 is conservative — typical cross-sectional ICs are 0.03-0.08.
56+
MIN_VALIDATION_IC = 0.02
57+
5458
# R3-P-9/P-10 fix: resolve paths relative to project root, not CWD
5559
_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
5660
MODEL_CACHE_DIR = _PROJECT_ROOT / "data" / "models"
@@ -392,12 +396,12 @@ def train_model(
392396
"Use a point-in-time membership table for unbiased backtests."
393397
)
394398

395-
# M12 fix: use a random sample instead of the first 100 alphabetically.
396-
# Alphabetical slicing (A-D) introduces systematic sector/name bias.
397-
# Seed is fixed for reproducibility across runs on the same day.
399+
# Train on the full S&P 500 universe. Previous versions sampled 100
400+
# tickers for speed, but this causes distribution mismatch at inference
401+
# time when the model scores all ~500 stocks. LightGBM handles 500
402+
# tickers × 2yr daily data (~250k rows) in under a minute.
398403
all_tickers = fetch_sp500_tickers()
399-
random.seed(42)
400-
tickers = training_tickers or random.sample(all_tickers, min(100, len(all_tickers)))
404+
tickers = training_tickers or all_tickers
401405
raw = fetch_ohlcv(tickers, period=TRAINING_LOOKBACK)
402406
long = reshape_ohlcv_wide_to_long(raw)
403407

@@ -438,15 +442,23 @@ def train_model(
438442
# with 5-day embargo to prevent target leakage (same approach as train.py)
439443
dates = labeled.index.get_level_values(0).unique().sort_values()
440444
split_date = dates[int(len(dates) * 0.8)]
441-
embargo_offset = pd.tseries.offsets.BDay(5)
445+
# Embargo must cover the longest feature lookback window to prevent
446+
# information leakage from features that straddle the train/val boundary.
447+
# Feature set includes 20-day returns, 20-day vol, 20-day Bollinger, and
448+
# 60-day moving averages. 22 business days (~1 calendar month) provides
449+
# a safe margin above the 20-day features while being conservative enough
450+
# not to waste too much data. (The 60-day MA creates backward dependence
451+
# only, not forward leakage, so 22 days is sufficient.)
452+
embargo_offset = pd.tseries.offsets.BDay(22)
442453
embargo_date = split_date + embargo_offset
443454

444455
train_data = labeled.loc[labeled.index.get_level_values(0) <= split_date]
445456
val_data = labeled.loc[labeled.index.get_level_values(0) >= embargo_date]
446457

447458
logger.info(
448459
f"Training on {len(train_data)} samples (up to {split_date.date()}), "
449-
f"validating on {len(val_data)} samples (from {embargo_date.date()})"
460+
f"validating on {len(val_data)} samples (from {embargo_date.date()}, "
461+
f"embargo=22 business days)"
450462
)
451463

452464
model = CrossSectionalModel(model_type="lightgbm", feature_cols=available_cols)
@@ -462,6 +474,9 @@ def train_model(
462474
logger.warning("No validation data available — training without early stopping")
463475
model.fit(train_data, target_col="target_5d")
464476

477+
# Attach IC to model so callers can gate on quality
478+
model.validation_ic = ic # type: ignore[attr-defined]
479+
465480
# --- MLflow tracking (best-effort: never crash training) ---
466481
try:
467482
import mlflow
@@ -488,6 +503,15 @@ def train_model(
488503
except Exception as e:
489504
logger.debug(f"Could not log feature importance artifact: {e}")
490505

506+
# Log winsorize bounds alongside the model so rollbacks keep
507+
# bounds in sync with the model version that produced them.
508+
try:
509+
bounds_path = Path("data/models/winsorize_bounds.json")
510+
if bounds_path.exists():
511+
mlflow.log_artifact(str(bounds_path), artifact_path="winsorize_bounds")
512+
except Exception as e:
513+
logger.debug(f"Could not log winsorize bounds artifact: {e}")
514+
491515
logger.info("MLflow run logged successfully for live_lgbm_alpha")
492516
except Exception as e:
493517
logger.warning(f"MLflow tracking failed (training unaffected): {e}")
@@ -707,6 +731,22 @@ def get_ml_weights(
707731
logger.info("Step 1/4: Training model...")
708732
model = train_model(data_path=training_data_path)
709733

734+
# IC quality gate: if the model's validation IC is too low, fall back
735+
# to equal-weight portfolio. A weak model is worse than no model.
736+
model_ic = getattr(model, "validation_ic", None)
737+
if isinstance(model_ic, (int, float)) and model_ic < MIN_VALIDATION_IC:
738+
logger.warning(
739+
f"Model validation IC ({model_ic:.4f}) below minimum "
740+
f"({MIN_VALIDATION_IC}). Falling back to equal-weight."
741+
)
742+
# Return equal-weight across top_n current holdings if available,
743+
# otherwise return empty (no trades).
744+
if current_weights:
745+
tickers = list(current_weights.keys())[:top_n]
746+
eq_wt = 1.0 / len(tickers) if tickers else 0.0
747+
return {t: eq_wt for t in tickers}
748+
return {}
749+
710750
# Step 2: Fetch recent data for the full universe to rank
711751
logger.info("Step 2/4: Fetching recent data for universe ranking...")
712752
from python.data.ingestion import fetch_sp500_tickers

python/alpha/train.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ def run_training(data_path: str = "data/raw/sp500_ohlcv.parquet") -> "CrossSecti
8989
# in both train and val sets. Instead, sort dates and split by date.
9090
dates = labeled.index.get_level_values(0).unique().sort_values()
9191
split_date = dates[int(len(dates) * 0.8)]
92-
# 5-day embargo gap to avoid target leakage across the boundary
93-
embargo_offset = pd.tseries.offsets.BDay(5)
92+
# 22-day embargo gap to cover the longest feature lookback window
93+
# (20-day returns, volatility, Bollinger) and prevent information leakage
94+
# from features that straddle the train/val boundary.
95+
embargo_offset = pd.tseries.offsets.BDay(22)
9496
embargo_date = split_date + embargo_offset
9597

9698
train = labeled.loc[labeled.index.get_level_values(0) <= split_date]
@@ -99,7 +101,7 @@ def run_training(data_path: str = "data/raw/sp500_ohlcv.parquet") -> "CrossSecti
99101
logger.info(
100102
f"Train: {len(train)} rows up to {split_date.date()}, "
101103
f"Val: {len(val)} rows from {embargo_date.date()} "
102-
f"(5-day embargo)"
104+
f"(22-day embargo)"
103105
)
104106

105107
with mlflow.start_run(run_name="lgbm_alpha158"):

python/portfolio/optimizer.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,16 @@ def __init__(
6969
self.current_weights = current_weights
7070
self.turnover_threshold = turnover_threshold
7171

72-
# H-HRP fix: apply Ledoit-Wolf shrinkage for small-sample covariance.
73-
# R3-O-1 fix: store the estimator so optimizer methods can use it.
72+
# Apply Ledoit-Wolf shrinkage unconditionally when enabled.
73+
# Sample covariance is always a noisy estimator — shrinkage toward
74+
# a structured target (diagonal) always reduces estimation error for
75+
# portfolio optimization, regardless of n_obs/n_assets ratio. The
76+
# previous n_obs < n_assets*3 condition meant shrinkage never fired
77+
# for the typical 10-stock portfolio with 252 observations.
7478
n_obs, n_assets = self.returns.shape
7579
self._shrunk = False
7680
self._lw_estimator = None # sklearn LedoitWolf estimator (if applied)
77-
if shrink_covariance and n_assets > 2 and n_obs < n_assets * 3:
81+
if shrink_covariance and n_assets > 2:
7882
try:
7983
from sklearn.covariance import LedoitWolf
8084

0 commit comments

Comments
 (0)