9.9 KiB
9.9 KiB
In [1]:
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
# Source of truth for model inputs (must match training order exactly)
FEATURE_COLUMNS = [
"SPY_ret_5",
"SPY_ret_20",
"SPY_dist_sma50",
"VIX_change_5",
"VIX_rank_20",
"TLT_ret_10",
"USO_ret_5",
"SPY_TLT_ratio_ret",
]
DEFAULT_SYMBOLS = {
"SPY": "SPY",
"VIXY": "VIX", # Change to "VIX": "VIX" if using raw VIX Parquet
"TLT": "TLT",
"USO": "USO",
}
def load_raw_close_prices(
raw_data_dir: Path, symbols: dict[str, str]
) -> pd.DataFrame:
"""Reads raw Parquet files and merges close prices into a single inner-joined DataFrame."""
frames = []
for symbol, alias in symbols.items():
path = raw_data_dir / f"{symbol}.parquet"
if not path.exists():
raise FileNotFoundError(f"Missing raw data file: {path}")
frame = pd.read_parquet(path)
if "date" in frame.columns:
frame = frame.set_index("date")
frame.index = pd.to_datetime(frame.index)
frame = frame.sort_index()
frames.append(frame[["close"]].rename(columns={"close": f"{alias}_close"}))
return pd.concat(frames, axis=1, join="inner")
def compute_features(prices: pd.DataFrame) -> pd.DataFrame:
"""Computes engineered features from raw merged price history."""
df = prices.copy()
# Target asset features
df["SPY_ret_5"] = df["SPY_close"].pct_change(5)
df["SPY_ret_20"] = df["SPY_close"].pct_change(20)
sma_50 = df["SPY_close"].rolling(50).mean()
df["SPY_dist_sma50"] = (df["SPY_close"] - sma_50) / sma_50
# Volatility / Market Stress
df["VIX_change_5"] = df["VIX_close"].pct_change(5)
df["VIX_rank_20"] = df["VIX_close"].rolling(20).rank(pct=True)
# Macro & Relative ratios
df["TLT_ret_10"] = df["TLT_close"].pct_change(10)
df["USO_ret_5"] = df["USO_close"].pct_change(5)
df["SPY_TLT_ratio_ret"] = (df["SPY_close"] / df["TLT_close"]).pct_change(5)
return df[FEATURE_COLUMNS]
def get_latest_inference_features(
raw_data_dir: Path,
symbols: dict[str, str] | None = None,
max_age_days: int = 1,
) -> pd.DataFrame:
"""Loads raw prices, computes features, verifies date freshness,
and returns the latest single row for model prediction.
"""
symbols = symbols or DEFAULT_SYMBOLS
# 1. Load prices & compute rolling features
prices = load_raw_close_prices(raw_data_dir, symbols)
features = compute_features(prices).dropna()
if features.empty:
raise ValueError(
"Not enough historical rows to compute 50-day rolling window features."
)
# 2. Extract latest available row as a 1-row DataFrame
latest_row = features.iloc[[-1]]
latest_date = latest_row.index[0]
# 3. Check data freshness and raise a warning if stale
now = pd.Timestamp.now()
latest_date_naive = (
latest_date.tz_localize(None)
if latest_date.tz is not None
else latest_date
)
days_old = (now.floor("D") - latest_date_naive.floor("D")).days
if days_old > max_age_days:
warnings.warn(
f"STALE DATA WARNING: Latest feature row is from {latest_date.strftime('%Y-%m-%d')} "
f"({days_old} day(s) old). Update raw Parquet files before executing trades.",
UserWarning,
stacklevel=2,
)
return latest_row
def get_target_exposure(
p_pred: float, p_base: float, sensitivity: float = 5.0
) -> float:
"""Maps predicted probability to a target portfolio equity allocation (0.0 to 1.0).
- p_pred == p_base --> 50% Target Exposure (Neutral)
- p_pred > p_base --> Scale up toward 100% (Bullish)
- p_pred < p_base --> Scale down toward 0% (Bearish / Cash)
"""
# Calculate deviation from the historical average
delta = p_pred - p_base
# Base target allocation is 50% equity / 50% cash
base_allocation = 0.50
# Sensitivity controls how aggressively probability changes alter allocation
# e.g., a +0.08 delta * 5.0 = +0.40 -> 90% Equity Allocation
target_allocation = base_allocation + (delta * sensitivity)
# Clamp bounds strictly between 0% (full cash) and 100% (full SPY)
return float(np.clip(target_allocation, 0.0, 1.0))In [2]:
import json
import xgboost as xgb
# 1. Load model and metadata
model = xgb.XGBClassifier()
model.load_model("models/spy_xgb_v1.json")
with open("models/spy_xgb_v1_meta.json", "r") as f:
meta = json.load(f)
# 2. Fetch latest features from raw parquet files
RAW_DATA_DIR = Path("../data/ibkr/daily")
X_latest = get_latest_inference_features(RAW_DATA_DIR, max_age_days=1)
# 3. Predict probability
p_pred = float(model.predict_proba(X_latest[meta["feature_cols"]])[0, 1])
p_base = meta["p_base"]
print(
f"Date: {X_latest.index[0].date()} | Prob: {p_pred:.4f} | Base: {p_base:.4f}"
)
get_target_exposure(p_pred, p_base, sensitivity=5.0)Out [2]:
Date: 2026-07-27 | Prob: 0.5843 | Base: 0.5830
0.5064256139268726
In [4]:
X_latestOut [4]:
| SPY_ret_5 | SPY_ret_20 | SPY_dist_sma50 | VIX_change_5 | VIX_rank_20 | TLT_ret_10 | USO_ret_5 | SPY_TLT_ratio_ret | |
|---|---|---|---|---|---|---|---|---|
| date | ||||||||
| 2026-07-27 | -0.004043 | 0.013855 | -0.007938 | 0.007065 | 0.75 | -0.00262 | -0.005976 | -0.002378 |
In [ ]: