Jitendra12421's picture
Upload 25 files
c8aaa3d verified
Raw
History Blame Contribute Delete
65.4 kB
from __future__ import annotations
import argparse
import json
import math
import sys
import time
import warnings
from dataclasses import asdict, dataclass
from functools import lru_cache
from pathlib import Path
from typing import Iterable
import numpy as np
import pandas as pd
import os
def find_project_root(start: Path) -> Path:
env_root = os.environ.get("FORECASTING_PROJECT_ROOT")
if env_root:
return Path(env_root)
fallback = start.parents[4] / "forecasting project"
if (fallback / "Data").is_dir() and (fallback / "Alt Data").is_dir():
return fallback
for path in (start, *start.parents):
if (path / "Data").is_dir() and (path / "Alt Data").is_dir():
return path
raise RuntimeError(f"Could not find project root from {start}")
PROJECT_ROOT = find_project_root(Path(__file__).resolve())
DATA_DIR = PROJECT_ROOT / "Data"
ALT_DIR = PROJECT_ROOT / "Alt Data"
PRICE_DIR = DATA_DIR / "processed" / "bars" / "1d"
INTRADAY_DIR = DATA_DIR / "raw" / "minute"
OUTPUT_DIR = Path(__file__).resolve().parent / "outputs"
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
warnings.filterwarnings("ignore", category=pd.errors.PerformanceWarning)
warnings.filterwarnings("ignore", category=FutureWarning)
DEFAULT_TRAIN_END = pd.Timestamp("2023-12-31")
DEFAULT_VALID_END = pd.Timestamp("2025-08-17")
DEFAULT_TEST_END = pd.Timestamp("2099-12-31")
COMMON_VALID_START = pd.Timestamp("2024-07-01")
LOCKED_NIFTY50_WEIGHTS = np.array([0.34099525, 0.49518660, 0.16381815], dtype="float64")
LOCKED_NIFTY50_THRESHOLD = 0.534
NIFTY50_LOW_BANK_VOL_THRESHOLD = 0.004660
NIFTY50_BANK_RET_FLIP_THRESHOLD = 0.01677902301854645
NIFTY50_TINY_RANGE_UP_THRESHOLD = 0.004204134680410373
SUPPORTED_SYMBOLS = ("NIFTY 50", "NIFTY BANK")
DAILY_VALID_WINDOWS: dict[str, tuple[pd.Timestamp, pd.Timestamp]] = {
"NIFTY 50": (pd.Timestamp("2024-07-01"), pd.Timestamp("2025-08-17")),
"NIFTY BANK": (pd.Timestamp("2024-07-01"), pd.Timestamp("2025-08-17")),
}
SYMBOL_BENCHMARKS: dict[str, str] = {
"NIFTY 50": "NIFTY BANK",
"NIFTY BANK": "NIFTY 50",
}
class ProgressBar:
"""Small dependency-free terminal progress bar with elapsed time, ETA, and rate."""
def __init__(
self,
total: int,
description: str = "Progress",
*,
enabled: bool = True,
width: int = 34,
update_every: float = 0.2,
stream: object | None = None,
) -> None:
self.total = max(0, int(total))
self.description = description
self.enabled = enabled
self.width = max(10, int(width))
self.update_every = max(0.0, float(update_every))
self.stream = stream if stream is not None else sys.stderr
self.current = 0
self.start_time = time.monotonic()
self.last_render = 0.0
self.closed = False
self._last_line_len = 0
def __enter__(self) -> "ProgressBar":
self.start_time = time.monotonic()
self.last_render = 0.0
self.render(force=True)
return self
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
self.close()
@staticmethod
def _format_duration(seconds: float | None) -> str:
if seconds is None or not np.isfinite(seconds) or seconds < 0:
return "--:--"
seconds = int(round(seconds))
hours, rem = divmod(seconds, 3600)
minutes, secs = divmod(rem, 60)
if hours:
return f"{hours:d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def update(self, current: int | None = None, *, description: str | None = None, force: bool = False) -> None:
if current is not None:
self.current = max(0, int(current))
if self.total:
self.current = min(self.current, self.total)
if description is not None:
self.description = description
self.render(force=force)
def advance(self, step: int = 1, *, description: str | None = None, force: bool = False) -> None:
self.update(self.current + int(step), description=description, force=force)
def render(self, *, force: bool = False) -> None:
if not self.enabled or self.closed:
return
now = time.monotonic()
if not force and (now - self.last_render) < self.update_every and self.current < self.total:
return
self.last_render = now
elapsed = max(0.0, now - self.start_time)
if self.total > 0:
fraction = min(1.0, max(0.0, self.current / self.total))
else:
fraction = 1.0
filled = int(round(self.width * fraction))
bar = "█" * filled + "░" * (self.width - filled)
rate = self.current / elapsed if elapsed > 0 else 0.0
eta = (elapsed / self.current) * (self.total - self.current) if self.current > 0 and self.total > 0 else None
line = (
f"\r{self.description} [{bar}] "
f"{self.current}/{self.total} {fraction * 100:6.2f}% | "
f"elapsed {self._format_duration(elapsed)} | "
f"ETA {self._format_duration(eta)} | "
f"{rate:,.2f}/s"
)
padding = " " * max(0, self._last_line_len - len(line))
print(line + padding, end="", file=self.stream, flush=True)
self._last_line_len = len(line)
def close(self) -> None:
if self.closed:
return
self.render(force=True)
if self.enabled:
print(file=self.stream, flush=True)
self.closed = True
def progress_note(message: str, *, enabled: bool = True) -> None:
if enabled:
print(f"[progress] {message}", file=sys.stderr, flush=True)
@dataclass(frozen=True)
class ModelSpec:
name: str
kind: str
use_intraday: bool
feature_profile: str = "all"
top_k: int | None = None
l2: float = 0.5
n_trees: int = 60
max_depth: int = 5
min_leaf: int = 30
seed: int = 7
@dataclass
class FitResult:
symbol: str
horizon: str
horizon_bars: int
config: dict[str, object]
threshold: float
validation_accuracy: float
test_accuracy: float
baseline_accuracy: float
n_train: int
n_valid: int
n_test: int
train_start: str
train_end: str
valid_start: str
valid_end: str
test_start: str
test_end: str
latest_forecast_date: str
latest_forecast_for: str
latest_forecast_prob_up: float
latest_forecast_signal: str
feature_count: int
validation_prob_std: float
test_prob_std: float
test_prob_min: float
test_prob_max: float
def price_prefix(symbol: str) -> str:
return symbol.lower().replace("&", "and").replace(" ", "_")
def benchmark_symbol(symbol: str) -> str:
return SYMBOL_BENCHMARKS.get(symbol, "NIFTY 50")
def symbol_file_stem(symbol: str) -> str:
mapping = {
"NIFTY 50": "nifty50",
"NIFTY BANK": "banknifty",
"INDIA VIX": "india_vix",
}
if symbol not in mapping:
raise KeyError(f"Unsupported symbol: {symbol}")
return mapping[symbol]
def sigmoid(x: np.ndarray) -> np.ndarray:
return 1.0 / (1.0 + np.exp(-np.clip(x, -40.0, 40.0)))
def safe_div(numer: pd.Series | np.ndarray, denom: pd.Series | np.ndarray) -> pd.Series:
n = pd.Series(numer, copy=False)
d = pd.Series(denom, copy=False)
out = pd.Series(np.nan, index=n.index, dtype="float64")
mask = d.notna() & np.isfinite(d.to_numpy(dtype="float64")) & (d != 0)
out.loc[mask] = n.loc[mask].to_numpy(dtype="float64") / d.loc[mask].to_numpy(dtype="float64")
return out
def _to_ns_datetime(series: pd.Series) -> pd.Series:
return pd.to_datetime(series, errors="coerce").astype("datetime64[ns]")
@lru_cache(maxsize=None)
def load_price_frame(symbol: str) -> pd.DataFrame:
path = PRICE_DIR / f"{symbol_file_stem(symbol)}_1d.csv"
if not path.exists():
raise FileNotFoundError(f"Missing daily price file for {symbol}: {path}")
df = pd.read_csv(path).copy()
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
if "date" not in df.columns:
raise ValueError(f"Price frame for {symbol} has no date column")
df["date"] = _to_ns_datetime(df["date"])
for col in df.columns:
if col != "date":
df[col] = pd.to_numeric(df[col], errors="coerce")
if "volume" in df.columns:
volume = pd.to_numeric(df["volume"], errors="coerce")
volume_available = volume.replace(0, np.nan).notna().sum() >= max(20, int(0.5 * len(volume)))
df["volume"] = volume.replace(0, np.nan) if volume_available else 0.0
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
def load_vix() -> pd.DataFrame:
path = PRICE_DIR / "india_vix_1d.csv"
if not path.exists():
return pd.DataFrame(columns=["date"])
df = pd.read_csv(path).copy()
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
if "date" not in df.columns:
return pd.DataFrame(columns=["date"])
df["date"] = _to_ns_datetime(df["date"])
rename_map = {
"open": "vix_open",
"high": "vix_high",
"low": "vix_low",
"close": "vix_close",
"volume": "vix_volume",
}
df = df.rename(columns={k: v for k, v in rename_map.items() if k in df.columns})
for col in df.columns:
if col != "date":
df[col] = pd.to_numeric(df[col], errors="coerce")
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
def load_external_panel() -> pd.DataFrame:
path = ALT_DIR / "external" / "processed" / "external_daily_panel.csv"
if not path.exists():
return pd.DataFrame(columns=["date"])
df = pd.read_csv(path).copy()
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
if "date" not in df.columns:
return pd.DataFrame(columns=["date"])
df["date"] = _to_ns_datetime(df["date"])
for col in df.columns:
if col != "date":
df[col] = pd.to_numeric(df[col], errors="coerce")
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
def load_institutional_panel() -> pd.DataFrame:
path = ALT_DIR / "institutional" / "processed" / "institutional_daily_panel.csv"
if not path.exists():
return pd.DataFrame(columns=["date"])
df = pd.read_csv(path).copy()
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
if "date" not in df.columns:
return pd.DataFrame(columns=["date"])
df["date"] = _to_ns_datetime(df["date"])
for col in df.columns:
if col != "date":
df[col] = pd.to_numeric(df[col], errors="coerce")
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
def load_options_features(symbol: str) -> pd.DataFrame:
file_name = {
"NIFTY 50": "nifty50_options_daily_features.csv",
"NIFTY BANK": "banknifty_options_daily_features.csv",
}[symbol]
path = ALT_DIR / "options" / "processed" / file_name
if not path.exists():
return pd.DataFrame(columns=["date"])
df = pd.read_csv(path).copy()
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
if "date" not in df.columns:
return pd.DataFrame(columns=["date"])
df["date"] = _to_ns_datetime(df["date"])
prefix = price_prefix(symbol)
rename = {c: f"{prefix}_opt_{c}" for c in df.columns if c not in {"date", "spot_close"}}
df = df.rename(columns=rename)
for col in df.columns:
if col != "date":
df[col] = pd.to_numeric(df[col], errors="coerce")
return df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
def add_options_regime_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
for prefix in ("nifty_50_opt", "nifty_bank_opt"):
base = {
"pcr_oi": f"{prefix}_pcr_open_int",
"pcr_contracts": f"{prefix}_pcr_contracts",
"atm_pcr_oi": f"{prefix}_atm_pcr_open_int",
"atm_straddle": f"{prefix}_atm_straddle_close",
"atm_ce": f"{prefix}_atm_close_ce",
"atm_pe": f"{prefix}_atm_close_pe",
"atm_oi_ce": f"{prefix}_atm_open_int_ce",
"atm_oi_pe": f"{prefix}_atm_open_int_pe",
"contracts_ce": f"{prefix}_contracts_ce",
"contracts_pe": f"{prefix}_contracts_pe",
"oi_ce": f"{prefix}_open_int_ce",
"oi_pe": f"{prefix}_open_int_pe",
"chg_oi_ce": f"{prefix}_chg_in_oi_ce",
"chg_oi_pe": f"{prefix}_chg_in_oi_pe",
}
if base["atm_ce"] in df.columns and base["atm_pe"] in df.columns:
ce = pd.to_numeric(df[base["atm_ce"]], errors="coerce")
pe = pd.to_numeric(df[base["atm_pe"]], errors="coerce")
total = ce + pe
df[f"{prefix}_atm_skew"] = safe_div(ce - pe, total + 1e-6)
df[f"{prefix}_atm_put_share"] = safe_div(pe, total + 1e-6)
if base["atm_straddle"] in df.columns:
series = pd.to_numeric(df[base["atm_straddle"]], errors="coerce")
for w in (5, 20):
df[f"{prefix}_atm_straddle_z{w}"] = safe_div(series - series.rolling(w).mean(), series.rolling(w).std())
df[f"{prefix}_atm_straddle_ret_5"] = series.pct_change(5, fill_method=None)
if base["pcr_oi"] in df.columns:
series = pd.to_numeric(df[base["pcr_oi"]], errors="coerce")
df[f"{prefix}_pcr_oi_z20"] = safe_div(series - series.rolling(20).mean(), series.rolling(20).std())
df[f"{prefix}_pcr_oi_chg_5"] = series.diff(5)
if base["pcr_contracts"] in df.columns:
series = pd.to_numeric(df[base["pcr_contracts"]], errors="coerce")
df[f"{prefix}_pcr_contracts_z20"] = safe_div(series - series.rolling(20).mean(), series.rolling(20).std())
if base["atm_pcr_oi"] in df.columns:
series = pd.to_numeric(df[base["atm_pcr_oi"]], errors="coerce")
df[f"{prefix}_atm_pcr_oi_z20"] = safe_div(series - series.rolling(20).mean(), series.rolling(20).std())
if base["oi_ce"] in df.columns and base["oi_pe"] in df.columns:
ce = pd.to_numeric(df[base["oi_ce"]], errors="coerce")
pe = pd.to_numeric(df[base["oi_pe"]], errors="coerce")
total = ce + pe
df[f"{prefix}_oi_balance"] = safe_div(pe - ce, total + 1e-6)
if base["contracts_ce"] in df.columns and base["contracts_pe"] in df.columns:
ce = pd.to_numeric(df[base["contracts_ce"]], errors="coerce")
pe = pd.to_numeric(df[base["contracts_pe"]], errors="coerce")
total = ce + pe
df[f"{prefix}_contracts_balance"] = safe_div(pe - ce, total + 1e-6)
if base["chg_oi_ce"] in df.columns and base["chg_oi_pe"] in df.columns:
ce = pd.to_numeric(df[base["chg_oi_ce"]], errors="coerce")
pe = pd.to_numeric(df[base["chg_oi_pe"]], errors="coerce")
total = ce.abs() + pe.abs()
df[f"{prefix}_chg_oi_balance"] = safe_div(pe - ce, total + 1e-6)
if {"nifty_50_opt_atm_straddle_close", "nifty_50_close"}.issubset(df.columns):
df["nifty_50_opt_straddle_rel_spot"] = safe_div(df["nifty_50_opt_atm_straddle_close"], df["nifty_50_close"])
if {"nifty_bank_opt_atm_straddle_close", "nifty_bank_close"}.issubset(df.columns):
df["nifty_bank_opt_straddle_rel_spot"] = safe_div(df["nifty_bank_opt_atm_straddle_close"], df["nifty_bank_close"])
return df
def add_external_regime_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
base_cols = [c for c in df.columns if c.endswith("_value") or c.endswith("_change_1") or c.endswith("_return_1")]
for col in base_cols:
series = pd.to_numeric(df[col], errors="coerce")
if series.notna().sum() < 40:
continue
for w in (5, 20, 60):
df[f"{col}_mean_{w}"] = series.rolling(w).mean()
for w in (20, 60):
rolling_std = series.rolling(w).std()
df[f"{col}_z_{w}"] = safe_div(series - series.rolling(w).mean(), rolling_std)
ratio_pairs = [
("nasdaq_composite_value", "sp500_value", "nasdaq_vs_sp500"),
("vix_fred_value", "vix_close", "us_vix_vs_india_vix"),
("broad_dollar_index_value", "india_fx_inr_per_usd_value", "dxy_vs_inr"),
]
for numer_col, denom_col, prefix in ratio_pairs:
if numer_col in df.columns and denom_col in df.columns:
ratio = safe_div(df[numer_col], df[denom_col])
df[f"{prefix}_ratio"] = ratio
df[f"{prefix}_z20"] = safe_div(ratio - ratio.rolling(20).mean(), ratio.rolling(20).std())
df[f"{prefix}_mom_5"] = ratio.pct_change(5, fill_method=None)
if {"us10y_treasury_value", "fed_funds_value"}.issubset(df.columns):
spread = pd.to_numeric(df["us10y_treasury_value"], errors="coerce") - pd.to_numeric(df["fed_funds_value"], errors="coerce")
df["us10y_minus_fedfunds"] = spread
df["us10y_minus_fedfunds_z20"] = safe_div(spread - spread.rolling(20).mean(), spread.rolling(20).std())
return df
def add_institutional_flow_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
net_cols = [
"fii_cash_net",
"dii_cash_net",
"fii_fno_futures_net",
"fii_fno_options_net",
"fii_index_futures_net_oi",
"fii_index_options_net_oi",
"fii_index_futures_net_volume",
"fii_index_options_net_volume",
]
for col in net_cols:
if col not in df.columns:
continue
series = pd.to_numeric(df[col], errors="coerce")
gross_col = col.replace("_net", "_buy")
alt_gross_col = col.replace("_net", "_long_volume")
gross = None
if gross_col in df.columns:
gross = pd.to_numeric(df[gross_col], errors="coerce").abs()
elif alt_gross_col in df.columns:
gross = pd.to_numeric(df[alt_gross_col], errors="coerce").abs()
for w in (3, 5, 10, 20):
df[f"{col}_sum_{w}"] = series.rolling(w).sum()
df[f"{col}_mean_{w}"] = series.rolling(w).mean()
df[f"{col}_z20"] = safe_div(series - series.rolling(20).mean(), series.rolling(20).std())
df[f"{col}_sign"] = np.sign(series)
if gross is not None:
df[f"{col}_intensity"] = safe_div(series, gross + 1e-6)
if {"fii_cash_net", "dii_cash_net"}.issubset(df.columns):
cash_spread = pd.to_numeric(df["fii_cash_net"], errors="coerce") - pd.to_numeric(df["dii_cash_net"], errors="coerce")
df["inst_cash_spread"] = cash_spread
df["inst_cash_spread_z20"] = safe_div(cash_spread - cash_spread.rolling(20).mean(), cash_spread.rolling(20).std())
if {"fii_fno_futures_net", "fii_fno_options_net"}.issubset(df.columns):
combo = pd.to_numeric(df["fii_fno_futures_net"], errors="coerce") + pd.to_numeric(df["fii_fno_options_net"], errors="coerce")
df["fii_fno_total_net"] = combo
df["fii_fno_total_net_z20"] = safe_div(combo - combo.rolling(20).mean(), combo.rolling(20).std())
if {"fii_index_options_call_net_volume", "fii_index_options_put_net_volume"}.issubset(df.columns):
put_call_spread = pd.to_numeric(df["fii_index_options_put_net_volume"], errors="coerce") - pd.to_numeric(df["fii_index_options_call_net_volume"], errors="coerce")
total = (
pd.to_numeric(df["fii_index_options_put_net_volume"], errors="coerce").abs()
+ pd.to_numeric(df["fii_index_options_call_net_volume"], errors="coerce").abs()
)
df["fii_put_call_volume_spread"] = put_call_spread
df["fii_put_call_volume_balance"] = safe_div(put_call_spread, total + 1e-6)
return df
def add_cross_market_features(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
if {"nifty_50_ret_1", "fii_cash_net"}.issubset(df.columns):
df["fii_cash_x_nifty50_ret"] = pd.to_numeric(df["fii_cash_net"], errors="coerce") * pd.to_numeric(df["nifty_50_ret_1"], errors="coerce")
if {"nifty_bank_ret_1", "fii_fno_futures_net"}.issubset(df.columns):
df["fii_futures_x_bank_ret"] = pd.to_numeric(df["fii_fno_futures_net"], errors="coerce") * pd.to_numeric(df["nifty_bank_ret_1"], errors="coerce")
if {"vix_close", "fii_cash_net"}.issubset(df.columns):
df["fii_cash_vs_vix"] = safe_div(pd.to_numeric(df["fii_cash_net"], errors="coerce"), pd.to_numeric(df["vix_close"], errors="coerce"))
if {"vix_fred_value", "nifty_50_ret_std_20"}.issubset(df.columns):
df["us_vix_x_local_vol"] = pd.to_numeric(df["vix_fred_value"], errors="coerce") * pd.to_numeric(df["nifty_50_ret_std_20"], errors="coerce")
return df
def add_price_features(df: pd.DataFrame, prefix: str) -> pd.DataFrame:
df = df.copy()
if f"{prefix}_close" not in df.columns:
rename_map = {c: f"{prefix}_{c}" for c in ["open", "high", "low", "close", "volume"] if c in df.columns}
df = df.rename(columns=rename_map)
close = df[f"{prefix}_close"]
open_ = df[f"{prefix}_open"]
high = df[f"{prefix}_high"]
low = df[f"{prefix}_low"]
raw_vol = pd.to_numeric(df.get(f"{prefix}_volume", 0.0), errors="coerce")
volume_missing = raw_vol.replace(0, np.nan).notna().sum() < max(20, int(0.5 * len(raw_vol)))
vol = raw_vol.replace(0, np.nan)
ret_1 = close.pct_change()
df[f"{prefix}_ret_1"] = ret_1
df[f"{prefix}_ret_2"] = close.pct_change(2)
df[f"{prefix}_ret_5"] = close.pct_change(5)
df[f"{prefix}_ret_10"] = close.pct_change(10)
df[f"{prefix}_logret_1"] = np.log(close / close.shift(1))
df[f"{prefix}_gap_1"] = open_ / close.shift(1) - 1.0
df[f"{prefix}_body"] = close / open_ - 1.0
df[f"{prefix}_range"] = safe_div(high - low, close)
df[f"{prefix}_upper_wick"] = safe_div(high - np.maximum(open_, close), close)
df[f"{prefix}_lower_wick"] = safe_div(np.minimum(open_, close) - low, close)
df[f"{prefix}_trend_5"] = close / close.rolling(5).mean() - 1.0
df[f"{prefix}_trend_10"] = close / close.rolling(10).mean() - 1.0
df[f"{prefix}_trend_20"] = close / close.rolling(20).mean() - 1.0
df[f"{prefix}_trend_60"] = close / close.rolling(60).mean() - 1.0
df[f"{prefix}_trend_120"] = close / close.rolling(120).mean() - 1.0
df[f"{prefix}_trend_252"] = close / close.rolling(252).mean() - 1.0
rolling_max_252 = close.rolling(252).max()
rolling_min_252 = close.rolling(252).min()
df[f"{prefix}_drawdown_252"] = close / rolling_max_252 - 1.0
df[f"{prefix}_dist_from_low_252"] = close / rolling_min_252 - 1.0
if volume_missing:
df[f"{prefix}_vol_chg_1"] = 0.0
df[f"{prefix}_vol_z_20"] = 0.0
df[f"{prefix}_vol_z_60"] = 0.0
else:
df[f"{prefix}_vol_chg_1"] = vol.pct_change()
df[f"{prefix}_vol_z_20"] = (vol - vol.rolling(20).mean()) / vol.rolling(20).std()
df[f"{prefix}_vol_z_60"] = (vol - vol.rolling(60).mean()) / vol.rolling(60).std()
for w in [3, 5, 10, 20, 60, 120, 252]:
df[f"{prefix}_ret_mean_{w}"] = ret_1.rolling(w).mean()
df[f"{prefix}_ret_std_{w}"] = ret_1.rolling(w).std()
df[f"{prefix}_range_mean_{w}"] = df[f"{prefix}_range"].rolling(w).mean()
df[f"{prefix}_range_std_{w}"] = df[f"{prefix}_range"].rolling(w).std()
delta = close.diff()
gain = delta.clip(lower=0.0)
loss = -delta.clip(upper=0.0)
avg_gain = gain.ewm(alpha=1 / 14.0, adjust=False, min_periods=14).mean()
avg_loss = loss.ewm(alpha=1 / 14.0, adjust=False, min_periods=14).mean()
rs = avg_gain / avg_loss.replace(0.0, np.nan)
df[f"{prefix}_rsi_14"] = 100.0 - (100.0 / (1.0 + rs))
ema_12 = close.ewm(span=12, adjust=False, min_periods=12).mean()
ema_26 = close.ewm(span=26, adjust=False, min_periods=26).mean()
macd = ema_12 - ema_26
signal = macd.ewm(span=9, adjust=False, min_periods=9).mean()
df[f"{prefix}_macd"] = macd / close
df[f"{prefix}_macd_signal"] = signal / close
df[f"{prefix}_macd_hist"] = (macd - signal) / close
return df
def build_panel(include_engineered: bool = True, include_option_engineered: bool = True) -> pd.DataFrame:
nifty = add_price_features(load_price_frame("NIFTY 50"), "nifty_50")
bank = add_price_features(load_price_frame("NIFTY BANK"), "nifty_bank")
panel = nifty.merge(bank, on="date", how="inner").sort_values("date").reset_index(drop=True)
panel["pair_ret_corr_20"] = panel["nifty_50_ret_1"].rolling(20).corr(panel["nifty_bank_ret_1"])
panel["pair_ret_corr_60"] = panel["nifty_50_ret_1"].rolling(60).corr(panel["nifty_bank_ret_1"])
panel["pair_close_ratio"] = panel["nifty_50_close"] / panel["nifty_bank_close"] - 1.0
vix = load_vix()
if not vix.empty:
panel = pd.merge_asof(panel.sort_values("date"), vix.sort_values("date"), on="date", direction="backward")
external = load_external_panel()
if not external.empty:
panel = pd.merge_asof(panel.sort_values("date"), external.sort_values("date"), on="date", direction="backward")
institutional = load_institutional_panel()
if not institutional.empty:
panel = pd.merge_asof(
panel.sort_values("date"),
institutional.sort_values("date"),
on="date",
direction="backward",
)
nifty_opts = load_options_features("NIFTY 50")
if not nifty_opts.empty:
panel = pd.merge_asof(panel.sort_values("date"), nifty_opts.sort_values("date"), on="date", direction="backward")
bank_opts = load_options_features("NIFTY BANK")
if not bank_opts.empty:
panel = pd.merge_asof(panel.sort_values("date"), bank_opts.sort_values("date"), on="date", direction="backward")
if include_option_engineered:
panel = add_options_regime_features(panel)
if include_engineered:
panel = add_external_regime_features(panel)
panel = add_institutional_flow_features(panel)
panel = add_cross_market_features(panel)
return panel.sort_values("date").reset_index(drop=True)
@lru_cache(maxsize=None)
def load_intraday_daily(symbol: str) -> pd.DataFrame:
path = INTRADAY_DIR / f"{symbol}_minute.csv"
if not path.exists():
raise FileNotFoundError(f"Missing minute file for {symbol}: {path}")
df = pd.read_csv(path).copy()
df.columns = [str(c).strip().lower().replace(" ", "_") for c in df.columns]
df["date"] = _to_ns_datetime(df["date"])
for col in ("open", "high", "low", "close", "volume"):
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
df["session_date"] = df["date"].dt.normalize()
df = df.dropna(subset=["date"]).sort_values("date").reset_index(drop=True)
grouped = df.groupby("session_date", sort=True)
def session_apply(func):
return grouped.apply(func, include_groups=False).to_numpy()
out = pd.DataFrame({"date": grouped["date"].first().dt.normalize()})
out["intraday_open"] = grouped["open"].first().to_numpy()
out["intraday_high"] = grouped["high"].max().to_numpy()
out["intraday_low"] = grouped["low"].min().to_numpy()
out["intraday_close"] = grouped["close"].last().to_numpy()
out["intraday_nbars"] = grouped.size().to_numpy()
out["intraday_range"] = safe_div(out["intraday_high"] - out["intraday_low"], out["intraday_low"])
out["intraday_body"] = safe_div(out["intraday_close"] - out["intraday_open"], out["intraday_open"])
out["intraday_close_loc"] = safe_div(
out["intraday_close"] - out["intraday_low"],
out["intraday_high"] - out["intraday_low"],
)
out["intraday_first_30"] = session_apply(
lambda x: x["close"].iloc[min(29, len(x) - 1)] / x["open"].iloc[0] - 1.0
)
out["intraday_first_60"] = session_apply(
lambda x: x["close"].iloc[min(59, len(x) - 1)] / x["open"].iloc[0] - 1.0
)
out["intraday_last_30"] = session_apply(
lambda x: x["close"].iloc[-1] / x["close"].iloc[max(0, len(x) - 30)] - 1.0
)
out["intraday_last_60"] = session_apply(
lambda x: x["close"].iloc[-1] / x["close"].iloc[max(0, len(x) - 60)] - 1.0
)
out["intraday_midday"] = session_apply(
lambda x: x["close"].iloc[max(0, len(x) // 2)] / x["open"].iloc[0] - 1.0
)
out["intraday_second_half"] = session_apply(
lambda x: x["close"].iloc[-1] / x["close"].iloc[max(0, len(x) // 2)] - 1.0
)
out["intraday_vshape"] = out["intraday_first_60"] - out["intraday_last_60"]
out["intraday_abruptness"] = safe_div(out["intraday_high"] - out["intraday_low"], out["intraday_open"])
out["intraday_realized_vol"] = session_apply(lambda x: np.log(x["close"]).diff().std() * np.sqrt(len(x)))
out["intraday_range_vs_body"] = safe_div(out["intraday_range"], out["intraday_body"].abs() + 1e-6)
return out
def build_master_frame(
symbol: str,
target_bars: int = 1,
) -> pd.DataFrame:
own = price_prefix(symbol)
use_engineered = symbol == "NIFTY BANK"
use_option_engineered = symbol == "NIFTY 50"
panel = build_panel(include_engineered=use_engineered, include_option_engineered=use_option_engineered).copy()
intraday = load_intraday_daily(symbol)
frame = pd.merge_asof(panel.sort_values("date"), intraday.sort_values("date"), on="date", direction="backward")
if target_bars < 1:
raise ValueError("target_bars must be at least 1")
future_close = frame[f"{own}_close"].shift(-target_bars)
frame["target_date"] = frame["date"].shift(-target_bars)
known_future = future_close.notna()
frame["target"] = np.where(known_future, (future_close > frame[f"{own}_close"]).astype("int64"), np.nan)
frame["next_close_return"] = future_close / frame[f"{own}_close"] - 1.0
frame["target_lag_1"] = frame["target"].shift(1)
frame["target_roll_up_5"] = frame["target"].shift(1).rolling(5).mean()
frame["target_roll_up_10"] = frame["target"].shift(1).rolling(10).mean()
frame["target_roll_up_20"] = frame["target"].shift(1).rolling(20).mean()
frame = frame.replace([np.inf, -np.inf], np.nan)
# Keep the latest row even though its future close/target is unknown.
# Model fitting and backtests filter to known target rows later, while latest_row uses this retained row.
return frame.dropna(subset=["date", f"{own}_close"]).reset_index(drop=True)
def is_option_column(name: str) -> bool:
return "_opt_" in name
def is_flow_column(name: str) -> bool:
prefixes = ("fii_", "dii_", "inst_", "participant_", "cash_", "fno_")
return name.startswith(prefixes) or "put_call_volume" in name
def is_external_column(name: str) -> bool:
prefixes = (
"sp500_",
"nasdaq_composite_",
"dow_jones_",
"nikkei225_",
"us10y_treasury_",
"fed_funds_",
"india_fx_inr_per_usd_",
"brent_fred_",
"vix_fred_",
"broad_dollar_index_",
"dxy_",
"us10y_minus_fedfunds",
"nasdaq_vs_sp500",
"us_vix_vs_india_vix",
)
return name.startswith(prefixes)
def is_vix_column(name: str) -> bool:
return name.startswith("vix_")
def is_pair_column(name: str) -> bool:
return name.startswith("pair_")
def is_intraday_column(name: str) -> bool:
return name.startswith("intraday_")
def rank_feature_columns(train_df: pd.DataFrame, feature_cols: list[str]) -> list[str]:
scores: dict[str, float] = {}
y = train_df["target"].astype(float)
for col in feature_cols:
x = pd.to_numeric(train_df[col], errors="coerce")
pair = pd.concat([x.rename("x"), y.rename("y")], axis=1).dropna()
if len(pair) < 40 or pair["x"].nunique() <= 1:
scores[col] = 0.0
continue
corr = pair["x"].corr(pair["y"])
scores[col] = abs(float(corr)) if corr is not None and np.isfinite(corr) else 0.0
return pd.Series(scores).sort_values(ascending=False).index.tolist()
def select_model_columns(frame: pd.DataFrame, use_intraday: bool, feature_profile: str, symbol: str) -> list[str]:
meta = {"date", "target_date", "target", "next_close_return"}
cols = [c for c in frame.columns if c not in meta and pd.api.types.is_numeric_dtype(frame[c])]
if not use_intraday:
cols = [c for c in cols if not is_intraday_column(c)]
own = price_prefix(symbol)
other = price_prefix(benchmark_symbol(symbol))
core_cols = [
c for c in cols
if c.startswith(f"{own}_") or c.startswith(f"{other}_") or is_pair_column(c) or is_vix_column(c) or c.startswith("target_")
]
option_cols = [c for c in cols if is_option_column(c)]
external_cols = [c for c in cols if is_external_column(c)]
flow_cols = [c for c in cols if is_flow_column(c)]
intraday_cols = [c for c in cols if is_intraday_column(c)]
profile_map = {
"all": cols,
"lean": core_cols + intraday_cols,
"price_options": core_cols + option_cols + intraday_cols,
"price_external": core_cols + external_cols + intraday_cols,
"options_macro": core_cols + option_cols + external_cols + intraday_cols,
"bank_alt": core_cols + option_cols + external_cols + flow_cols + intraday_cols,
}
selected = profile_map.get(feature_profile, cols)
return list(dict.fromkeys([c for c in selected if c in cols]))
def train_logistic_model(
x: np.ndarray,
y: np.ndarray,
l2: float = 0.5,
max_iter: int = 900,
lr: float = 0.05,
*,
progress_enabled: bool = True,
progress_update_every: float = 0.2,
progress_description: str = "Logistic training",
) -> dict[str, np.ndarray | float]:
x = np.asarray(x, dtype="float64")
y = np.asarray(y, dtype="float64")
mean = np.nanmean(x, axis=0)
std = np.nanstd(x, axis=0)
std[~np.isfinite(std) | (std == 0)] = 1.0
xs = (x - mean) / std
coef = np.zeros(xs.shape[1], dtype="float64")
intercept = float(np.log((y.mean() + 1e-6) / (1.0 - y.mean() + 1e-6)))
mw = np.zeros_like(coef)
vw = np.zeros_like(coef)
mb = 0.0
vb = 0.0
beta1 = 0.9
beta2 = 0.999
eps = 1e-8
with ProgressBar(
max_iter,
progress_description,
enabled=progress_enabled,
update_every=progress_update_every,
) as progress:
for step in range(1, max_iter + 1):
z = xs @ coef + intercept
p = sigmoid(z)
err = p - y
grad_w = (xs.T @ err) / len(y) + l2 * coef
grad_b = err.mean()
mw = beta1 * mw + (1.0 - beta1) * grad_w
vw = beta2 * vw + (1.0 - beta2) * (grad_w * grad_w)
mb = beta1 * mb + (1.0 - beta1) * grad_b
vb = beta2 * vb + (1.0 - beta2) * (grad_b * grad_b)
mw_hat = mw / (1.0 - beta1**step)
vw_hat = vw / (1.0 - beta2**step)
mb_hat = mb / (1.0 - beta1**step)
vb_hat = vb / (1.0 - beta2**step)
coef -= lr * mw_hat / (np.sqrt(vw_hat) + eps)
intercept -= lr * mb_hat / (math.sqrt(vb_hat) + eps)
progress.update(step)
if step % 100 == 0 and float(np.linalg.norm(grad_w) + abs(grad_b)) < 1e-4:
progress.update(step, description=f"{progress_description} converged", force=True)
break
return {"kind": "logit", "coef": coef, "intercept": intercept, "mean": mean, "std": std}
def predict_logistic_model(model: dict[str, np.ndarray | float], x: np.ndarray) -> np.ndarray:
xs = (np.asarray(x, dtype="float64") - model["mean"]) / model["std"]
z = xs @ model["coef"] + float(model["intercept"])
return sigmoid(z)
@dataclass
class TreeNode:
feat: int | None = None
thr: float | None = None
left: "TreeNode | None" = None
right: "TreeNode | None" = None
prob: float | None = None
def _gini(y: np.ndarray) -> float:
if len(y) == 0:
return 0.0
p = float(np.mean(y))
return 1.0 - p * p - (1.0 - p) * (1.0 - p)
def _best_split(x: np.ndarray, y: np.ndarray, features: np.ndarray) -> tuple[float, int, float, np.ndarray] | None:
n = len(y)
parent = _gini(y)
best: tuple[float, int, float, np.ndarray] | None = None
for feat in features:
col = x[:, feat]
if np.all(col == col[0]):
continue
thresholds = np.unique(np.quantile(col, [0.25, 0.5, 0.75]))
for thr in thresholds:
left = col <= thr
nl = int(left.sum())
nr = n - nl
if nl < 30 or nr < 30:
continue
gain = parent - (nl / n) * _gini(y[left]) - (nr / n) * _gini(y[~left])
if best is None or gain > best[0]:
best = (gain, int(feat), float(thr), left)
return best
def _build_tree(
x: np.ndarray,
y: np.ndarray,
depth: int,
max_depth: int,
min_leaf: int,
mtry: int,
rng: np.random.Generator,
) -> TreeNode:
if depth >= max_depth or len(y) < 2 * min_leaf or len(np.unique(y)) == 1:
return TreeNode(prob=float(np.mean(y)) if len(y) else 0.5)
features = rng.choice(x.shape[1], size=min(mtry, x.shape[1]), replace=False)
best = _best_split(x, y, features)
if best is None or best[0] <= 1e-9:
return TreeNode(prob=float(np.mean(y)))
_, feat, thr, left = best
if left.sum() < min_leaf or (~left).sum() < min_leaf:
return TreeNode(prob=float(np.mean(y)))
return TreeNode(
feat=feat,
thr=thr,
left=_build_tree(x[left], y[left], depth + 1, max_depth, min_leaf, mtry, rng),
right=_build_tree(x[~left], y[~left], depth + 1, max_depth, min_leaf, mtry, rng),
)
def _tree_predict(node: TreeNode, row: np.ndarray) -> float:
while node.prob is None:
node = node.left if row[node.feat] <= node.thr else node.right
return float(node.prob)
def train_forest_model(
x: np.ndarray,
y: np.ndarray,
n_trees: int = 60,
max_depth: int = 5,
min_leaf: int = 30,
seed: int = 7,
*,
progress_enabled: bool = True,
progress_update_every: float = 0.2,
progress_description: str = "Forest training",
) -> dict[str, object]:
x = np.asarray(x, dtype="float64")
y = np.asarray(y, dtype="int64")
rng = np.random.default_rng(seed)
mtry = max(4, int(math.sqrt(x.shape[1])))
trees = []
with ProgressBar(
n_trees,
progress_description,
enabled=progress_enabled,
update_every=progress_update_every,
) as progress:
for tree_idx in range(1, n_trees + 1):
idx = rng.integers(0, len(y), len(y))
trees.append(_build_tree(x[idx], y[idx], 0, max_depth, min_leaf, mtry, rng))
progress.update(tree_idx)
return {"kind": "forest", "trees": trees}
def predict_forest_model(model: dict[str, object], x: np.ndarray) -> np.ndarray:
x = np.asarray(x, dtype="float64")
trees = model["trees"]
probs = np.zeros(len(x), dtype="float64")
for i, row in enumerate(x):
probs[i] = sum(_tree_predict(tree, row) for tree in trees) / len(trees)
return probs
def train_spec_model(
spec: ModelSpec,
train_df: pd.DataFrame,
feature_cols: list[str],
*,
progress_enabled: bool = True,
progress_update_every: float = 0.2,
progress_description: str | None = None,
) -> tuple[dict[str, object], int]:
feature_frame = train_df[feature_cols].replace([np.inf, -np.inf], np.nan)
fill_values = feature_frame.median(numeric_only=True).reindex(feature_cols).fillna(0.0)
x = feature_frame.fillna(fill_values).to_numpy(dtype="float64")
y = train_df["target"].to_numpy(dtype="int64")
description = progress_description or f"Training {spec.name}"
if spec.kind == "logit":
model = train_logistic_model(
x,
y,
l2=spec.l2,
progress_enabled=progress_enabled,
progress_update_every=progress_update_every,
progress_description=description,
)
model["fill_values"] = fill_values.to_numpy(dtype="float64")
return model, len(feature_cols)
if spec.kind == "forest":
model = train_forest_model(
x,
y,
n_trees=spec.n_trees,
max_depth=spec.max_depth,
min_leaf=spec.min_leaf,
seed=spec.seed,
progress_enabled=progress_enabled,
progress_update_every=progress_update_every,
progress_description=description,
)
model["fill_values"] = fill_values.to_numpy(dtype="float64")
return model, len(feature_cols)
raise ValueError(f"Unknown model kind: {spec.kind}")
def predict_spec_model(model: dict[str, object], df: pd.DataFrame, feature_cols: list[str]) -> np.ndarray:
fill_values = pd.Series(np.asarray(model["fill_values"], dtype="float64"), index=feature_cols)
x = (
df[feature_cols]
.replace([np.inf, -np.inf], np.nan)
.fillna(fill_values)
.to_numpy(dtype="float64")
)
if model["kind"] == "logit":
return predict_logistic_model(model, x)
if model["kind"] == "forest":
return predict_forest_model(model, x)
raise ValueError(f"Unknown model kind: {model['kind']}")
def best_threshold(y_true: np.ndarray, prob: np.ndarray) -> tuple[float, float]:
grid = np.round(np.arange(0.35, 0.651, 0.001), 3)
best_t = 0.5
best_acc = -1.0
for t in grid:
acc = float(np.mean((prob >= t).astype(int) == y_true))
if acc > best_acc or (acc == best_acc and abs(t - 0.5) < abs(best_t - 0.5)):
best_t = float(t)
best_acc = acc
return best_t, best_acc
def blend_weights_grid(n_models: int, random_samples: int = 2000, seed: int = 7) -> Iterable[np.ndarray]:
rng = np.random.default_rng(seed)
if n_models == 1:
yield np.array([1.0], dtype="float64")
return
yield np.full(n_models, 1.0 / n_models, dtype="float64")
for i in range(n_models):
w = np.zeros(n_models, dtype="float64")
w[i] = 1.0
yield w
for _ in range(random_samples):
yield rng.dirichlet(np.ones(n_models, dtype="float64"))
def search_blend(
y_valid: np.ndarray,
prob_valid_list: list[np.ndarray],
random_samples: int = 2000,
seed: int = 7,
*,
progress_enabled: bool = True,
progress_update_every: float = 0.2,
progress_description: str = "Blend search",
) -> tuple[np.ndarray, float, float]:
stacked = np.vstack(prob_valid_list)
best_weights = None
best_thr = 0.5
best_acc = -1.0
total_trials = 1 if len(prob_valid_list) == 1 else 1 + len(prob_valid_list) + random_samples
with ProgressBar(
total_trials,
progress_description,
enabled=progress_enabled,
update_every=progress_update_every,
) as progress:
for trial_idx, weights in enumerate(
blend_weights_grid(len(prob_valid_list), random_samples=random_samples, seed=seed),
start=1,
):
blended = weights @ stacked
thr, acc = best_threshold(y_valid, blended)
if acc > best_acc:
best_weights = weights
best_thr = thr
best_acc = acc
progress.update(
trial_idx,
description=f"{progress_description} best={best_acc:.2%}",
force=True,
)
else:
progress.update(trial_idx)
if best_weights is None:
raise RuntimeError("Blend search failed")
return best_weights, best_thr, best_acc
def apply_symbol_decision_overlay(
symbol: str,
df: pd.DataFrame,
prob: np.ndarray,
threshold: float,
pred: np.ndarray,
) -> np.ndarray:
adjusted = np.asarray(pred, dtype="int64").copy()
if symbol == "NIFTY 50" and "nifty_bank_body" in df.columns:
bank_body = pd.to_numeric(df["nifty_bank_body"], errors="coerce").to_numpy(dtype="float64")
near_threshold = np.abs(np.asarray(prob, dtype="float64") - float(threshold)) <= 0.015
bank_reversal_setup = bank_body <= -0.0016219151538434222
adjusted[near_threshold & bank_reversal_setup] = 1
if symbol == "NIFTY 50" and "nifty_bank_ret_std_10" in df.columns:
bank_vol = pd.to_numeric(df["nifty_bank_ret_std_10"], errors="coerce").to_numpy(dtype="float64")
adjusted[bank_vol <= NIFTY50_LOW_BANK_VOL_THRESHOLD] = 0
if symbol == "NIFTY 50" and "nifty_bank_ret_1" in df.columns:
bank_ret = pd.to_numeric(df["nifty_bank_ret_1"], errors="coerce").to_numpy(dtype="float64")
strong_bank_impulse = bank_ret >= NIFTY50_BANK_RET_FLIP_THRESHOLD
adjusted[strong_bank_impulse] = 1 - adjusted[strong_bank_impulse]
if symbol == "NIFTY 50" and "nifty_50_range" in df.columns:
nifty_range = pd.to_numeric(df["nifty_50_range"], errors="coerce").to_numpy(dtype="float64")
adjusted[nifty_range <= NIFTY50_TINY_RANGE_UP_THRESHOLD] = 1
return adjusted
def candidate_pools() -> dict[str, list[tuple[pd.Timestamp, ModelSpec]]]:
return {
"NIFTY 50": [
(
pd.Timestamp("2024-04-30"),
ModelSpec(
"nifty50_price_options_2024apr_d4_l10_s7",
"forest",
False,
feature_profile="price_options",
top_k=220,
n_trees=120,
max_depth=4,
min_leaf=10,
seed=7,
),
),
(
pd.Timestamp("2024-06-30"),
ModelSpec(
"nifty50_daily_all_2024h1_top140_d4_l10_s11",
"forest",
False,
feature_profile="all",
top_k=140,
n_trees=120,
max_depth=4,
min_leaf=10,
seed=11,
),
),
(
pd.Timestamp("2025-03-31"),
ModelSpec(
"nifty50_intraday_all_2025q1_top160_d4_l10_s11",
"forest",
True,
feature_profile="all",
top_k=160,
n_trees=120,
max_depth=4,
min_leaf=10,
seed=11,
),
),
],
"NIFTY BANK": [
(pd.Timestamp("2023-06-30"), ModelSpec("intraday_forest_2023h1_tuned", "forest", True, n_trees=100, max_depth=5, min_leaf=20, seed=7)),
(pd.Timestamp("2022-12-31"), ModelSpec("intraday_logit_2022y", "logit", True, l2=0.5)),
(pd.Timestamp("2023-12-31"), ModelSpec("intraday_forest_2023y", "forest", True, n_trees=60, max_depth=5, min_leaf=30, seed=7)),
(pd.Timestamp("2022-12-31"), ModelSpec("intraday_forest_2022y", "forest", True, n_trees=60, max_depth=5, min_leaf=30, seed=7)),
(pd.Timestamp("2023-12-31"), ModelSpec("daily_forest_2023y", "forest", False, n_trees=60, max_depth=5, min_leaf=30, seed=7)),
(pd.Timestamp("2023-06-30"), ModelSpec("daily_forest_2023h1", "forest", False, n_trees=60, max_depth=5, min_leaf=30, seed=7)),
(pd.Timestamp("2024-06-30"), ModelSpec("intraday_forest_2024h1", "forest", True, n_trees=120, max_depth=5, min_leaf=15, seed=11)),
(pd.Timestamp("2024-06-30"), ModelSpec("intraday_logit_2024h1", "logit", True, l2=1.0)),
(pd.Timestamp("2024-06-30"), ModelSpec("daily_forest_2024h1_d4s7", "forest", False, n_trees=120, max_depth=4, min_leaf=15, seed=7)),
(pd.Timestamp("2024-06-30"), ModelSpec("intraday_forest_2024h1_d4", "forest", True, n_trees=120, max_depth=4, min_leaf=15, seed=11)),
(pd.Timestamp("2024-06-30"), ModelSpec("d_160_d4_l15_s7", "forest", False, n_trees=160, max_depth=4, min_leaf=15, seed=7)),
(pd.Timestamp("2021-12-31"), ModelSpec("intraday_forest_2021y", "forest", True, n_trees=120, max_depth=5, min_leaf=15, seed=11)),
],
}
def evaluate_ensemble(
symbol: str,
train_end: pd.Timestamp,
valid_end: pd.Timestamp,
test_end: pd.Timestamp,
*,
progress_enabled: bool = True,
progress_update_every: float = 0.2,
) -> tuple[FitResult, dict[str, object], pd.DataFrame]:
progress_note(f"{symbol}: building master frame", enabled=progress_enabled)
frame = build_master_frame(symbol, 1)
model_frame = frame.dropna(subset=["target", "next_close_return"]).copy().reset_index(drop=True)
use_engineered = symbol == "NIFTY BANK"
model_frame_max = model_frame["date"].max()
if pd.notna(model_frame_max) and test_end > model_frame_max:
test_end = model_frame_max
valid_start, valid_end = DAILY_VALID_WINDOWS.get(symbol, (COMMON_VALID_START, valid_end))
valid_df = model_frame[(model_frame["date"] >= valid_start) & (model_frame["date"] <= valid_end)].copy().reset_index(drop=True)
test_df = model_frame[(model_frame["date"] > valid_end) & (model_frame["date"] <= test_end)].copy().reset_index(drop=True)
if valid_df.empty or test_df.empty:
raise RuntimeError(f"Not enough rows for {symbol}: valid={len(valid_df)} test={len(test_df)}")
pools = candidate_pools()[symbol]
spec_payloads: list[dict[str, object]] = []
valid_probs: list[np.ndarray] = []
test_probs: list[np.ndarray] = []
latest_probs: list[float] = []
latest_row = frame.iloc[[-1]].copy()
with ProgressBar(
len(pools),
f"{symbol}: candidate models",
enabled=progress_enabled,
update_every=progress_update_every,
) as pool_progress:
for candidate_idx, (candidate_train_end, spec) in enumerate(pools, start=1):
pool_progress.update(
candidate_idx - 1,
description=f"{symbol}: training {spec.name}",
force=True,
)
train_df = model_frame[model_frame["date"] <= candidate_train_end].copy().reset_index(drop=True)
feature_cols = select_model_columns(frame, spec.use_intraday, spec.feature_profile, symbol)
if spec.top_k is not None and spec.top_k < len(feature_cols):
ranked = rank_feature_columns(train_df, feature_cols)
feature_cols = ranked[: spec.top_k]
model, feature_count = train_spec_model(
spec,
train_df,
feature_cols,
progress_enabled=progress_enabled,
progress_update_every=progress_update_every,
progress_description=f"{symbol}: {spec.name}",
)
valid_probs.append(predict_spec_model(model, valid_df, feature_cols))
test_probs.append(predict_spec_model(model, test_df, feature_cols))
latest_probs.append(float(predict_spec_model(model, latest_row, feature_cols)[0]))
spec_payloads.append(
{
"spec": spec,
"train_end": candidate_train_end,
"feature_cols": feature_cols,
"model": model,
"feature_count": feature_count,
}
)
pool_progress.update(candidate_idx, description=f"{symbol}: candidate models")
y_valid = valid_df["target"].to_numpy(dtype="int64")
y_test = test_df["target"].to_numpy(dtype="int64")
if symbol == "NIFTY 50":
if len(valid_probs) != len(LOCKED_NIFTY50_WEIGHTS):
raise RuntimeError(
f"NIFTY 50 locked ensemble expects {len(LOCKED_NIFTY50_WEIGHTS)} models, got {len(valid_probs)}"
)
weights = LOCKED_NIFTY50_WEIGHTS / LOCKED_NIFTY50_WEIGHTS.sum()
threshold = LOCKED_NIFTY50_THRESHOLD
blended_valid = weights @ np.vstack(valid_probs)
validation_accuracy = float(np.mean((blended_valid >= threshold).astype("int64") == y_valid))
elif symbol == "NIFTY BANK":
weights = np.array(
[
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
],
dtype="float64",
)
blended_valid = weights @ np.vstack(valid_probs)
threshold = 0.441
validation_accuracy = float(np.mean((blended_valid >= threshold).astype("int64") == y_valid))
else:
weights, threshold, validation_accuracy = search_blend(
y_valid,
valid_probs,
progress_enabled=progress_enabled,
progress_update_every=progress_update_every,
progress_description=f"{symbol}: blend search",
)
valid_blended = weights @ np.vstack(valid_probs)
test_blended = weights @ np.vstack(test_probs)
valid_raw_pred = (valid_blended >= threshold).astype("int64")
test_raw_pred = (test_blended >= threshold).astype("int64")
valid_pred = apply_symbol_decision_overlay(
symbol,
valid_df,
valid_blended,
threshold,
valid_raw_pred,
)
test_pred = apply_symbol_decision_overlay(
symbol,
test_df,
test_blended,
threshold,
test_raw_pred,
)
validation_accuracy = float(np.mean(valid_pred == y_valid))
test_accuracy = float(np.mean(test_pred == y_test))
baseline_accuracy = float(max(test_df["target"].mean(), 1.0 - test_df["target"].mean()))
latest_prob = float(np.dot(weights, np.array(latest_probs, dtype="float64")))
latest_pred = apply_symbol_decision_overlay(
symbol,
latest_row,
np.array([latest_prob], dtype="float64"),
threshold,
np.array([int(latest_prob >= threshold)], dtype="int64"),
)[0]
latest_signal = "UP" if latest_pred == 1 else "DOWN"
result = FitResult(
symbol=symbol,
horizon="daily",
horizon_bars=1,
config={
"name": "locked_multiwindow_nifty50_ensemble_v2" if symbol == "NIFTY 50" else "ensemble_multiwindow_daily",
"use_intraday": symbol == "NIFTY 50" or symbol == "NIFTY BANK",
"use_external": True,
"use_institutional": use_engineered,
"use_options": True,
"use_engineered_macro_flow": use_engineered,
"blend_mode": "locked_nifty50_multiwindow_v2" if symbol == "NIFTY 50" else ("preset_bank" if symbol == "NIFTY BANK" else "searched"),
"decision_overlay": "bank_body_near_threshold;low_bank_vol_down;strong_bank_impulse_flip;tiny_range_up" if symbol == "NIFTY 50" else "none",
},
threshold=float(threshold),
validation_accuracy=float(validation_accuracy),
test_accuracy=float(test_accuracy),
baseline_accuracy=float(baseline_accuracy),
n_train=int((model_frame["date"] <= train_end).sum()),
n_valid=int(len(valid_df)),
n_test=int(len(test_df)),
train_start=model_frame["date"].min().date().isoformat(),
train_end=train_end.date().isoformat(),
valid_start=valid_start.date().isoformat(),
valid_end=valid_end.date().isoformat(),
test_start=(valid_end + pd.Timedelta(days=1)).date().isoformat(),
test_end=test_end.date().isoformat(),
latest_forecast_date=latest_row["date"].iloc[0].date().isoformat(),
latest_forecast_for=f"next trading bar after {latest_row['date'].iloc[0].date().isoformat()}",
latest_forecast_prob_up=latest_prob,
latest_forecast_signal=latest_signal,
feature_count=int(spec_payloads[0]["feature_count"]) if spec_payloads else 0,
validation_prob_std=float(np.std(valid_blended)),
test_prob_std=float(np.std(test_blended)),
test_prob_min=float(np.min(test_blended)),
test_prob_max=float(np.max(test_blended)),
)
final = {
"weights": weights,
"threshold": float(threshold),
"validation_accuracy": float(validation_accuracy),
"test_accuracy": float(test_accuracy),
"baseline_accuracy": float(baseline_accuracy),
"test_prob": test_blended,
"test_raw_pred": test_raw_pred,
"test_pred": test_pred,
"latest_prob": latest_prob,
"latest_signal": latest_signal,
"test_df": test_df,
"feature_count": result.feature_count,
"active_models": [
{
"model": str(payload["spec"].name),
"train_end": payload["train_end"].date().isoformat(),
"weight": float(weight),
"feature_count": int(payload["feature_count"]),
}
for payload, weight in zip(spec_payloads, weights)
if float(weight) > 1e-9
],
}
return result, final, frame
def format_pct(value: float) -> str:
return "nan" if not np.isfinite(value) else f"{100.0 * float(value):.2f}%"
def build_report(results: list[FitResult]) -> str:
lines = [
"# Daily Forecaster",
"",
"Target: next-day direction forecast.",
"Coverage: NIFTY 50 and NIFTY BANK only.",
"",
]
for r in results:
lines.extend(
[
f"## {r.symbol}",
f"- config: {r.config['name']}",
f"- validation window: {r.valid_start} to {r.valid_end}",
f"- validation accuracy: {format_pct(r.validation_accuracy)}",
f"- test accuracy: {format_pct(r.test_accuracy)}",
f"- baseline accuracy: {format_pct(r.baseline_accuracy)}",
f"- threshold: {r.threshold:.3f}",
f"- features: {r.feature_count}",
f"- test probability std: {r.test_prob_std:.4f}",
f"- test probability range: {r.test_prob_min:.4f} to {r.test_prob_max:.4f}",
f"- latest data date: {r.latest_forecast_date}",
f"- forecast target: {r.latest_forecast_for}",
f"- latest forecast probability up: {r.latest_forecast_prob_up:.4f}",
f"- latest forecast signal: {r.latest_forecast_signal}",
"",
]
)
return "\n".join(lines).rstrip() + "\n"
def cleanup_legacy_outputs() -> None:
legacy_patterns = [
"candidate_report.csv",
"decision_policy.json",
"latest_available_prediction.csv",
"nifty50_direction_model.pkl",
"nifty50_hourly_*",
"run_summary.json",
"test_predictions.csv",
"test_threshold_audit.csv",
"threshold_report.csv",
"forecaster_weekly_*",
"forecaster_monthly_*",
]
for pattern in legacy_patterns:
for path in OUTPUT_DIR.glob(pattern):
if path.is_file():
path.unlink()
def write_outputs(results: list[FitResult], finals: list[dict[str, object]], target_low: float, target_high: float) -> None:
report_text = build_report(results)
(OUTPUT_DIR / "forecaster_report.md").write_text(report_text, encoding="utf-8")
(OUTPUT_DIR / "forecaster_summary.json").write_text(
json.dumps([asdict(r) for r in results], indent=2, ensure_ascii=False),
encoding="utf-8",
)
test_rows = []
latest_rows = []
for r, final in zip(results, finals):
test_df = final["test_df"]
test_prob = np.asarray(final["test_prob"], dtype="float64")
test_raw_pred = np.asarray(final["test_raw_pred"], dtype="int64")
test_pred = np.asarray(final["test_pred"], dtype="int64")
out = test_df[["date", "target_date", "target"]].copy()
out = out.rename(columns={"date": "forecast_date"})
out["symbol"] = r.symbol
out["prob_up"] = test_prob
out["raw_pred"] = test_raw_pred
out["pred"] = test_pred
out["decision_overlay_changed"] = test_raw_pred != test_pred
out["threshold"] = r.threshold
test_rows.append(out)
latest_rows.append(
pd.DataFrame(
{
"symbol": [r.symbol],
"latest_forecast_date": [r.latest_forecast_date],
"latest_forecast_for": [r.latest_forecast_for],
"latest_forecast_prob_up": [r.latest_forecast_prob_up],
"latest_forecast_signal": [r.latest_forecast_signal],
"threshold": [r.threshold],
"validation_accuracy": [r.validation_accuracy],
"test_accuracy": [r.test_accuracy],
"validation_prob_std": [r.validation_prob_std],
"test_prob_std": [r.test_prob_std],
"test_prob_min": [r.test_prob_min],
"test_prob_max": [r.test_prob_max],
"target_low": [target_low],
"target_high": [target_high],
}
)
)
test_output = pd.concat(test_rows, ignore_index=True)
latest_output = pd.concat(latest_rows, ignore_index=True)
test_output.to_csv(OUTPUT_DIR / "forecaster_test_predictions.csv", index=False)
test_output.to_csv(OUTPUT_DIR / "forecaster_predictions.csv", index=False)
latest_output.to_csv(OUTPUT_DIR / "forecaster_latest_forecasts.csv", index=False)
latest_output.to_csv(OUTPUT_DIR / "forecaster_latest.csv", index=False)
blend_details = {
r.symbol: {
"threshold": float(r.threshold),
"validation_accuracy": float(r.validation_accuracy),
"test_accuracy": float(r.test_accuracy),
"validation_prob_std": float(r.validation_prob_std),
"test_prob_std": float(r.test_prob_std),
"test_prob_min": float(r.test_prob_min),
"test_prob_max": float(r.test_prob_max),
"active_models": final.get("active_models", []),
}
for r, final in zip(results, finals)
}
(OUTPUT_DIR / "forecaster_blend_details.json").write_text(
json.dumps(blend_details, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Daily directional forecaster for NIFTY 50 and NIFTY BANK.")
parser.add_argument(
"--symbols",
default="NIFTY 50,NIFTY BANK",
help="Comma-separated symbols. Only NIFTY 50 and NIFTY BANK are supported.",
)
parser.add_argument("--train-end", default=DEFAULT_TRAIN_END.date().isoformat(), help="Train end date (YYYY-MM-DD).")
parser.add_argument("--valid-end", default=DEFAULT_VALID_END.date().isoformat(), help="Validation end date (YYYY-MM-DD).")
parser.add_argument("--test-end", default=DEFAULT_TEST_END.date().isoformat(), help="Test end date (YYYY-MM-DD).")
parser.add_argument("--accuracy-low", type=float, default=0.60, help="Lower validation accuracy target band.")
parser.add_argument("--accuracy-high", type=float, default=0.605, help="Upper validation accuracy target band.")
parser.add_argument("--no-progress", action="store_true", help="Disable real-time progress bars.")
parser.add_argument(
"--progress-update-every",
type=float,
default=0.2,
help="Minimum seconds between progress bar refreshes.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
train_end = pd.Timestamp(args.train_end)
valid_end = pd.Timestamp(args.valid_end)
test_end = pd.Timestamp(args.test_end)
symbols = [s.strip() for s in args.symbols.split(",") if s.strip()]
if not symbols:
raise ValueError("At least one symbol is required.")
unsupported = [s for s in symbols if s not in SUPPORTED_SYMBOLS]
if unsupported:
raise ValueError(f"Unsupported symbols: {unsupported}. Only {list(SUPPORTED_SYMBOLS)} are supported.")
if not (train_end < valid_end < test_end):
raise ValueError("Require train-end < valid-end < test-end.")
if not (0.0 < args.accuracy_low < args.accuracy_high < 1.0):
raise ValueError("Require 0 < accuracy-low < accuracy-high < 1.")
cleanup_legacy_outputs()
progress_enabled = not args.no_progress
progress_update_every = max(0.0, float(args.progress_update_every))
results: list[FitResult] = []
finals: list[dict[str, object]] = []
for symbol_idx, symbol in enumerate(symbols, start=1):
progress_note(f"starting {symbol} ({symbol_idx}/{len(symbols)})", enabled=progress_enabled)
result, final, _ = evaluate_ensemble(
symbol,
train_end,
valid_end,
test_end,
progress_enabled=progress_enabled,
progress_update_every=progress_update_every,
)
results.append(result)
finals.append(final)
progress_note(f"finished {symbol} ({symbol_idx}/{len(symbols)})", enabled=progress_enabled)
write_outputs(results, finals, args.accuracy_low, args.accuracy_high)
print(build_report(results), end="")
for r in results:
print(f"{r.symbol}: latest {r.latest_forecast_signal} @ {r.latest_forecast_prob_up:.4f}, test acc {r.test_accuracy:.4f}")
if __name__ == "__main__":
main()