Files
trading-bot/notebooks/bot-prototype.ipynb
T

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_latest
Out [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 [ ]: