| import modal |
| import os |
| import uuid |
| from datetime import datetime |
| import yfinance as yf |
| import pandas as pd |
| import requests |
| import io |
| import os |
| import math |
| import json |
| import warnings |
| import random |
| import matplotlib |
| matplotlib.use('Agg') |
| import matplotlib.pyplot as plt |
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from datetime import datetime |
| from typing import Optional, Tuple, Dict, List, Union |
| from dataclasses import dataclass, asdict |
| from pathlib import Path |
| from torch.utils.data import Dataset, DataLoader |
| from torch.amp import autocast, GradScaler |
| from transformers import AutoModelForSeq2SeqLM, AutoConfig |
| from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score |
| import matplotlib.pyplot as plt |
|
|
| |
| DOWNLOAD_DATA = False |
| RAW_DATA_DIR = "raw_data" |
| os.makedirs(RAW_DATA_DIR, exist_ok=True) |
|
|
| |
| |
| COV_COLS = [ |
| |
| "ratio_mva5", "ratio_mva10", "ratio_mva15", "ratio_mva20", "ratio_mva30", |
| |
| "Volume_ratio", |
| |
| "rsi14", |
| |
| "BB_PercentB", |
| |
| "MACD_norm", "Signal_norm", "Hist_norm", |
| ] |
|
|
| START_DATE = "2013-01-01" |
| HEADERS = { |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" |
| } |
|
|
|
|
| def run_download_pipeline(): |
| """Encapsulated downloader logic.""" |
| if not DOWNLOAD_DATA: |
| print(f"Skipping download (DOWNLOAD_DATA={DOWNLOAD_DATA}).") |
| return |
|
|
| |
| symbols = get_combined_market_tickers() |
|
|
| if len(symbols) > 0: |
| |
| historical_data = get_stock_data(symbols, start_date=START_DATE) |
|
|
| |
| print(f"\nSaving {len(historical_data)} files to {RAW_DATA_DIR}...") |
| for ticker, df in historical_data.items(): |
| file_path = os.path.join(RAW_DATA_DIR, f"{ticker}.csv") |
| df.to_csv(file_path) |
| print("Done.") |
| else: |
| print("Failed to retrieve symbol list.") |
|
|
|
|
| def get_wiki_tickers(url, table_keywords): |
| """ |
| Helper to fetch ticker symbols from a Wikipedia URL table. |
| """ |
| tickers = [] |
| try: |
| response = requests.get(url, headers=HEADERS) |
| response.raise_for_status() |
|
|
| tables = pd.read_html(io.StringIO(response.text)) |
|
|
| target_table = None |
| for table in tables: |
| cols = [c.lower() for c in table.columns] |
| if any(k in cols for k in ['symbol', 'ticker']): |
| target_table = table |
| break |
|
|
| if target_table is not None: |
| |
| col_name = next(c for c in target_table.columns if c.lower() in ['symbol', 'ticker']) |
| tickers = target_table[col_name].tolist() |
| except Exception as e: |
| print(f" -> Error scraping {url}: {e}") |
|
|
| return tickers |
|
|
| def get_combined_market_tickers(): |
| """ |
| Aggregates S&P 500, NASDAQ 100, and S&P 400 (MidCap). |
| """ |
| print("--- Generaring Symbol List ---") |
| all_tickers = set() |
|
|
| |
| sp500 = get_wiki_tickers("https://en.wikipedia.org/wiki/List_of_S%26P_500_companies", ['symbol']) |
| all_tickers.update(sp500) |
| print(f"S&P 500 count: {len(sp500)}") |
|
|
| |
| ndx100 = get_wiki_tickers("https://en.wikipedia.org/wiki/Nasdaq-100", ['ticker', 'symbol']) |
| all_tickers.update(ndx100) |
| print(f"NASDAQ 100 count: {len(ndx100)}") |
|
|
| |
| sp400 = get_wiki_tickers("https://en.wikipedia.org/wiki/List_of_S%26P_400_companies", ['ticker', 'symbol']) |
| all_tickers.update(sp400) |
| print(f"S&P 400 count: {len(sp400)}") |
|
|
| |
| clean_list = [str(t).replace('.', '-') for t in all_tickers if isinstance(t, str)] |
| unique_list = sorted(list(set(clean_list))) |
|
|
| print(f"\nTotal unique symbols found: {len(unique_list)}") |
| return unique_list |
|
|
| def get_stock_data(tickers, start_date=None, interval="1d", end_date=None): |
| """ |
| Downloads data. If start_date is provided, it uses that. |
| Otherwise, it uses period="max". |
| """ |
| if not tickers: |
| print("No tickers provided.") |
| return {} |
|
|
| if end_date is None: |
| end_date = datetime.now().strftime('%Y-%m-%d') |
|
|
| print(f"Starting download for {len(tickers)} symbols...") |
| if start_date: |
| print(f"Data Range: {start_date} to {end_date}") |
| else: |
| print(f"Data Range: MAX available history to {end_date}") |
|
|
| all_data = {} |
| BATCH_SIZE = 50 |
|
|
| for i in range(0, len(tickers), BATCH_SIZE): |
| batch = tickers[i:i + BATCH_SIZE] |
| print(f"Processing batch {i} - {i+len(batch)}...") |
|
|
| try: |
| |
| |
| download_args = { |
| "tickers": ' '.join(batch), |
| "interval": interval, |
| "end": end_date, |
| "group_by": 'ticker', |
| "auto_adjust": True, |
| "progress": False, |
| "threads": True |
| } |
|
|
| |
| if start_date: |
| download_args["start"] = start_date |
| else: |
| download_args["period"] = "max" |
|
|
| data = yf.download(**download_args) |
|
|
| if not data.empty: |
| if len(batch) == 1: |
| all_data[batch[0]] = data.dropna(how='all') |
| else: |
| for ticker in batch: |
| try: |
| t_data = data.get(ticker) |
| if t_data is not None and not t_data.empty: |
| |
| t_data = t_data.dropna(how='all') |
| if len(t_data) > 0: |
| all_data[ticker] = t_data |
| except Exception: |
| continue |
| except Exception as e: |
| print(f"Batch error: {e}") |
|
|
| return all_data |
|
|
|
|
| TRAIN_END = pd.Timestamp("2021-12-31") |
| VAL_END = pd.Timestamp("2023-12-31") |
|
|
|
|
| |
| |
| |
|
|
| def simple_moving_average( |
| df: pd.DataFrame, |
| period: int, |
| price_col: str = "Close", |
| return_list: bool = False |
| ): |
| """Simple Moving Average (SMA) over a right-aligned rolling window.""" |
| if price_col not in df.columns or not isinstance(period, int) or period <= 0: |
| raise ValueError("Invalid inputs.") |
| sma = df[price_col].rolling(window=period, min_periods=period).mean() |
| return sma.tolist() if return_list else sma |
|
|
|
|
| def ema( |
| df: pd.DataFrame, |
| period: int, |
| price_col: str = "Close", |
| seed: str = "price", |
| min_periods: Optional[int] = None, |
| return_list: bool = False |
| ): |
| """Exponential Moving Average (EMA).""" |
| if price_col not in df.columns or not isinstance(period, int) or period <= 0: |
| raise ValueError("Invalid inputs.") |
| x = df[price_col].astype(float) |
|
|
| |
| ser = x.ewm(span=period, adjust=False, min_periods=min_periods).mean() |
| return ser.tolist() if return_list else ser |
|
|
|
|
| def rsi_wilder( |
| df: pd.DataFrame, |
| period: int = 14, |
| price_col: str = "Close", |
| return_list: bool = False |
| ): |
| """Wilder's Relative Strength Index (RSI).""" |
| if price_col not in df.columns or not isinstance(period, int) or period <= 1: |
| raise ValueError("Invalid inputs.") |
|
|
| price = df[price_col].astype(float) |
| delta = price.diff() |
|
|
| gains = delta.clip(lower=0) |
| losses = (-delta).clip(lower=0) |
|
|
| avg_gain = gains.ewm(alpha=1/period, adjust=False, min_periods=period).mean() |
| avg_loss = losses.ewm(alpha=1/period, adjust=False, min_periods=period).mean() |
|
|
| rs = avg_gain / avg_loss |
| rsi = 100.0 - (100.0 / (1.0 + rs)) |
|
|
| rsi = rsi.where(~((avg_loss == 0) & (avg_gain > 0)), 100.0) |
| rsi = rsi.where(~((avg_gain == 0) & (avg_loss > 0)), 0.0) |
| rsi = rsi.where(~((avg_gain == 0) & (avg_loss == 0)), 50.0) |
|
|
| return rsi.tolist() if return_list else rsi |
|
|
|
|
| def bollinger_bands( |
| df: pd.DataFrame, |
| period: int = 20, |
| num_std_dev: float = 2.0, |
| price_col: str = "Close", |
| ddof: int = 0, |
| return_extras: bool = False |
| ) -> pd.DataFrame: |
| """Bollinger Bands over a right-aligned rolling window.""" |
| if price_col not in df.columns or not isinstance(period, int) or period <= 1: |
| raise ValueError("Invalid inputs.") |
|
|
| s = df[price_col].astype(float) |
| middle = s.rolling(window=period, min_periods=period).mean() |
| sigma = s.rolling(window=period, min_periods=period).std(ddof=ddof) |
|
|
| upper = middle + num_std_dev * sigma |
| lower = middle - num_std_dev * sigma |
|
|
| out = pd.DataFrame({ |
| "BB_Middle": middle, |
| "BB_Upper": upper, |
| "BB_Lower": lower |
| }, index=df.index) |
|
|
| if return_extras: |
| width = upper - lower |
| percent_b = (s - lower) / width |
| percent_b = percent_b.where(width > 0) |
| bandwidth = width / middle |
| out["BB_PercentB"] = percent_b |
| out["BB_Bandwidth"] = bandwidth |
|
|
| return out |
|
|
|
|
| def macd( |
| df: pd.DataFrame, |
| price_col: str = "Close", |
| fast_period: int = 12, |
| slow_period: int = 26, |
| signal_period: int = 9, |
| min_periods: Optional[int] = None |
| ) -> pd.DataFrame: |
| """MACD (Moving Average Convergence Divergence).""" |
| if price_col not in df.columns or not (fast_period < slow_period and slow_period > 0): |
| raise ValueError("Invalid periods/columns.") |
|
|
| x = pd.to_numeric(df[price_col], errors="coerce").astype(float) |
| ema_fast = x.ewm(span=fast_period, adjust=False).mean() |
| ema_slow = x.ewm(span=slow_period, adjust=False).mean() |
|
|
| macd_line = ema_fast - ema_slow |
| signal = macd_line.ewm(span=signal_period, adjust=False).mean() |
| histogram = macd_line - signal |
|
|
| return pd.DataFrame( |
| {"MACD": macd_line, "Signal_Line": signal, "MACD_Histogram": histogram}, |
| index=df.index |
| ) |
|
|
|
|
| def pivot_points_fibonacci_FIXED( |
| df: pd.DataFrame, |
| high_col: str = "High", |
| low_col: str = "Low", |
| close_col: str = "Close", |
| session: str = "D" |
| ) -> pd.DataFrame: |
| """ |
| Fibonacci Pivot Points, shifted one period forward to avoid leakage. |
| """ |
| df = df.sort_index() |
| ohlc = df[[high_col, low_col, close_col]].copy() |
|
|
| sess = ohlc.resample(session, label="left", closed="left").agg({ |
| high_col: "max", |
| low_col: "min", |
| close_col: "last" |
| }) |
|
|
| H, L, C = sess[high_col], sess[low_col], sess[close_col] |
| P = (H + L + C) / 3.0 |
| R = (H - L) |
|
|
| R1 = P + 0.382 * R |
| R2 = P + 0.618 * R |
| R3 = P + 1.000 * R |
| S1 = P - 0.382 * R |
| S2 = P - 0.618 * R |
| S3 = P - 1.000 * R |
|
|
| fib_sess = pd.DataFrame({ |
| "PP": P, |
| "R1": R1, |
| "R2": R2, |
| "R3": R3, |
| "S1": S1, |
| "S2": S2, |
| "S3": S3, |
| }, index=sess.index) |
|
|
| fib_sess_shifted = fib_sess.shift(1) |
| return fib_sess_shifted.reindex(df.index).ffill() |
|
|
|
|
| def add_technical_indicators(df: pd.DataFrame) -> pd.DataFrame: |
| out = df.copy() |
|
|
| |
| sma5 = simple_moving_average(out, 5) |
| sma10 = simple_moving_average(out, 10) |
| sma15 = simple_moving_average(out, 15) |
| sma20 = simple_moving_average(out, 20) |
| sma30 = simple_moving_average(out, 30) |
|
|
| |
| out["ratio_mva5"] = (out["Close"] / sma5) - 1 |
| out["ratio_mva10"] = (out["Close"] / sma10) - 1 |
| out["ratio_mva15"] = (out["Close"] / sma15) - 1 |
| out["ratio_mva20"] = (out["Close"] / sma20) - 1 |
| out["ratio_mva30"] = (out["Close"] / sma30) - 1 |
|
|
| |
| if "Volume" in out.columns: |
| vol_sma20 = out["Volume"].rolling(window=20, min_periods=20).mean() |
| out["Volume_ratio"] = out["Volume"] / (vol_sma20 + 1e-8) |
| else: |
| out["Volume_ratio"] = 0.0 |
|
|
| |
| out["rsi14"] = rsi_wilder(out, 14) |
|
|
| |
| bb = bollinger_bands(out, period=20, num_std_dev=2.0, price_col="Close", return_extras=True) |
| out = out.join(bb) |
| |
| out["BB_PercentB"] = out["BB_PercentB"].fillna(0.5) |
|
|
| |
| macd_df = macd(out, price_col="Close") |
| out["MACD_norm"] = macd_df["MACD"] / (out["Close"] + 1e-8) |
| out["Signal_norm"] = macd_df["Signal_Line"] / (out["Close"] + 1e-8) |
| out["Hist_norm"] = macd_df["MACD_Histogram"]/ (out["Close"] + 1e-8) |
|
|
| return out |
|
|
|
|
| |
| |
| |
|
|
| def build_windows_from_df(df: pd.DataFrame, T: int = 64, H: int = 16, columns: list = None): |
| """ |
| LCS Upgrade (Phase 3): Produces sliding windows with Local Context Scaling. |
| |
| For each window: |
| 1. Extract raw price history (T steps) + future (H steps). |
| 2. Compute local scale = mean(abs(history prices)). |
| 3. Scale the entire window by this local scale → scaled prices ≈ N(1, σ). |
| 4. Tokenize scaled_history as input_ids, scaled_future as labels. |
| 5. Return raw prices, covariates, raw labels, AND local scales. |
| |
| Windows where scale <= 0.001 (flat/zero data) are dropped. |
| """ |
| prices = df["price"].values.astype("float32") |
|
|
| |
| target_cols = columns if columns is not None else COV_COLS |
|
|
| missing = [c for c in target_cols if c not in df.columns] |
| if missing: |
| raise ValueError(f"Missing columns in dataframe: {missing}") |
|
|
| cov = df[target_cols].values.astype("float32") |
|
|
| N_total = len(df) |
| windows = N_total - T - H + 1 |
| if windows <= 0: |
| raise ValueError("Dataset too small for given T and H.") |
|
|
| price_windows = [] |
| cov_windows = [] |
| label_windows = [] |
| scale_windows = [] |
|
|
| for i in range(windows): |
| p_window = prices[i:i+T] |
| f_window = prices[i+T:i+T+H] |
|
|
| |
| if np.any(p_window <= 0) or np.any(f_window <= 0): |
| continue |
|
|
| |
| local_scale = float(np.mean(np.abs(p_window))) |
| if local_scale <= 0.001: |
| continue |
|
|
| price_windows.append(p_window) |
| cov_windows.append(cov[i:i+T]) |
| label_windows.append(f_window) |
| scale_windows.append(local_scale) |
|
|
| if not price_windows: |
| return np.array([]), np.array([]), np.array([]), np.array([]) |
|
|
| return ( |
| np.stack(price_windows), |
| np.stack(cov_windows), |
| np.stack(label_windows), |
| np.array(scale_windows, dtype="float32"), |
| ) |
|
|
| def load_multi_csvs_stream( |
| data_dir: str, |
| T: int = 64, |
| H: int = 16, |
| min_len: int = 120, |
| max_files: int = 200 |
| ): |
| """ |
| Stream CSVs in small batches to avoid RAM crashes. |
| |
| Returns concatenated arrays but processes at most `max_files` tickers. |
| """ |
| all_prices = [] |
| all_cov = [] |
| all_labels = [] |
|
|
| files = [f for f in os.listdir(data_dir) if f.endswith(".csv")] |
| files = files[:max_files] |
|
|
| print(f"Processing {len(files)} tickers instead of full dataset...") |
|
|
| for fname in files: |
| path = os.path.join(data_dir, fname) |
| try: |
| df = pd.read_csv(path, parse_dates=["Date"]) |
| except Exception: |
| continue |
|
|
| if "Date" not in df.columns: |
| continue |
|
|
| df = df.sort_values("Date") |
| df = df.drop_duplicates() |
| df = df.set_index("Date") |
|
|
| |
| if not {"Open","High","Low","Close"}.issubset(df.columns): |
| continue |
|
|
| |
| |
| df = df.resample("B").asfreq().ffill() |
| df = df.dropna() |
|
|
| df["price"] = df["Close"] |
|
|
| if len(df) < min_len: |
| continue |
|
|
| |
| df = add_technical_indicators(df) |
| df = df.dropna() |
|
|
| if len(df) < T + H: |
| continue |
|
|
| |
| result = build_windows_from_df(df, T=T, H=H) |
| p_i, c_i, y_i = result[0], result[1], result[2] |
|
|
| |
| if len(p_i) > 0: |
| all_prices.append(p_i) |
| all_cov.append(c_i) |
| all_labels.append(y_i) |
|
|
| if not all_prices: |
| raise ValueError("No data after streaming.") |
|
|
| prices = np.concatenate(all_prices, axis=0) |
| covs = np.concatenate(all_cov, axis=0) |
| labels = np.concatenate(all_labels, axis=0) |
|
|
| return prices, covs, labels |
|
|
|
|
|
|
| def compute_mase_scale_multi(data_dir: str) -> float: |
| """ |
| Compute naive random-walk MAE across all tickers in data_dir |
| for MASE scaling. |
| """ |
| required_cols = ["Open", "High", "Low", "Close"] |
| diffs = [] |
|
|
| for fname in os.listdir(data_dir): |
| if not fname.endswith(".csv"): |
| continue |
| path = os.path.join(data_dir, fname) |
| try: |
| df = pd.read_csv(path, parse_dates=["Date"]) |
| except Exception: |
| continue |
|
|
| if "Date" not in df.columns: |
| continue |
|
|
| df = df.sort_values("Date") |
| df = df.drop_duplicates() |
| df = df.set_index("Date") |
|
|
| if not set(required_cols).issubset(df.columns): |
| continue |
|
|
| df = df[required_cols].copy() |
| |
| df = df.resample("B").asfreq().ffill() |
| df = df.dropna() |
| df["price"] = df["Close"] |
| p = df["price"].values.astype("float32") |
|
|
| if len(p) < 2: |
| continue |
|
|
| diffs.append(np.abs(p[1:] - p[:-1])) |
|
|
| if not diffs: |
| return 1.0 |
|
|
| diffs_all = np.concatenate(diffs, axis=0) |
| mase_scale = float(np.mean(diffs_all)) |
| return max(mase_scale, 1e-8) |
|
|
|
|
| |
| |
| |
|
|
| class SimpleChronosTokenizer: |
| """ |
| LCS Upgrade: Local Context Scaling tokenizer. |
| Operates on scaled prices (price / local_scale), covering [0.0, 5.0]. |
| Replaces the old log-return tokenizer to eliminate compounding errors. |
| """ |
| def __init__(self, num_bins: int = 1024, pad_token_id: int = 0, |
| min_val: float = 0.0, max_val: float = 5.0): |
| self.num_bins = num_bins |
| self.pad_token_id = pad_token_id |
| self.min_val = min_val |
| self.max_val = max_val |
| self.range = self.max_val - self.min_val + 1e-8 |
|
|
| self.bos_token_id = 1 |
| self.eos_token_id = 2 |
| self.vocab_offset = 3 |
| self.vocab_size = num_bins + self.vocab_offset |
| self.fitted = True |
|
|
| def fit(self, prices: np.ndarray): |
| |
| print(f"✓ Tokenizer using fixed LCS range [{self.min_val}, {self.max_val}]") |
|
|
| def encode(self, arr: np.ndarray) -> np.ndarray: |
| arr = np.array(arr, dtype=float) |
| |
| clamp_count = np.sum(arr > self.max_val) |
| if clamp_count > len(arr) * 0.1: |
| warnings.warn(f"⚠️ {clamp_count}/{len(arr)} values clamped above {self.max_val}. " |
| "Consider re-checking local scale computation.") |
| arr = np.clip(arr, self.min_val, self.max_val) |
| scaled = (arr - self.min_val) / self.range |
| scaled = np.clip(scaled, 0, 1) |
| bins = (scaled * (self.num_bins - 1)).astype(np.int64) |
| tokens = bins + self.vocab_offset |
| tokens[~np.isfinite(arr)] = self.pad_token_id |
| return tokens |
|
|
| def decode(self, tokens: np.ndarray) -> np.ndarray: |
| tokens = np.array(tokens, dtype=np.int64) |
| mask_special = ( |
| (tokens == self.pad_token_id) | |
| (tokens == self.bos_token_id) | |
| (tokens == self.eos_token_id) |
| ) |
| bins = np.clip(tokens - self.vocab_offset, 0, self.num_bins - 1) |
| scaled = bins.astype(float) / (self.num_bins - 1) |
| values = scaled * self.range + self.min_val |
| values[mask_special] = np.nan |
| return values |
|
|
| def save_config(self, path: str): |
| config = { |
| "num_bins": self.num_bins, |
| "pad_token_id": self.pad_token_id, |
| "min_val": self.min_val, |
| "max_val": self.max_val, |
| "mode": "local_scale" |
| } |
| with open(path, "w") as f: |
| json.dump(config, f, indent=2) |
| print(f"✓ Tokenizer config saved to {path}") |
|
|
| @classmethod |
| def load_config(cls, path: str): |
| with open(path, "r") as f: |
| config = json.load(f) |
| return cls( |
| num_bins=config["num_bins"], |
| pad_token_id=config["pad_token_id"], |
| min_val=config["min_val"], |
| max_val=config["max_val"] |
| ) |
|
|
| class ZScoreNormalizer: |
| """ |
| Anti-leakage Z-score normalizer with persistence. |
| """ |
|
|
| def __init__(self, epsilon: float = 1e-8): |
| self.mean: Optional[torch.Tensor] = None |
| self.std: Optional[torch.Tensor] = None |
| self.num_features: Optional[int] = None |
| self.epsilon = epsilon |
| self.fitted = False |
|
|
| def fit(self, train_data: torch.Tensor): |
| if train_data.dim() != 3: |
| raise ValueError(f"Expected (N, T, F), got {train_data.shape}") |
| self.mean = train_data.mean(dim=(0, 1), keepdim=True) |
| self.std = train_data.std(dim=(0, 1), keepdim=True) + self.epsilon |
| self.num_features = train_data.shape[2] |
| self.fitted = True |
|
|
| low_var = (self.std.squeeze() < (self.epsilon * 10)).cpu() |
| if low_var.any(): |
| idx = torch.where(low_var)[0].tolist() |
| warnings.warn(f"⚠️ Low variance features at indices: {idx}") |
|
|
| print(f"✓ Normalizer fitted on training covariates (F={self.num_features})") |
|
|
| def transform(self, data: torch.Tensor) -> torch.Tensor: |
| if not self.fitted: |
| raise ValueError("Normalizer not fitted. Call fit() first.") |
| if data.shape[-1] != self.num_features: |
| raise ValueError(f"Feature mismatch: expected {self.num_features}, got {data.shape[-1]}") |
| return (data - self.mean.to(data.device)) / self.std.to(data.device) |
|
|
| def inverse_transform(self, data: torch.Tensor) -> torch.Tensor: |
| if not self.fitted: |
| raise ValueError("Normalizer not fitted.") |
| return data * self.std.to(data.device) + self.mean.to(data.device) |
|
|
| |
| def save(self, dir_path: str): |
| if not self.fitted: |
| raise ValueError("Cannot save unfitted normalizer") |
|
|
| |
| torch.save(self.mean, os.path.join(dir_path, "norm_mean.pt")) |
| torch.save(self.std, os.path.join(dir_path, "norm_std.pt")) |
|
|
| |
| meta = {"num_features": self.num_features, "epsilon": self.epsilon} |
| with open(os.path.join(dir_path, "norm_meta.json"), "w") as f: |
| json.dump(meta, f, indent=2) |
| print(f"✓ Normalizer stats saved to {dir_path}") |
|
|
| @classmethod |
| def load(cls, dir_path: str): |
| meta_path = os.path.join(dir_path, "norm_meta.json") |
| if not os.path.exists(meta_path): |
| raise FileNotFoundError(f"Missing normalizer metadata: {meta_path}") |
|
|
| with open(meta_path, "r") as f: |
| meta = json.load(f) |
|
|
| obj = cls(epsilon=meta["epsilon"]) |
| obj.num_features = meta["num_features"] |
|
|
| |
| obj.mean = torch.load(os.path.join(dir_path, "norm_mean.pt"), map_location="cpu") |
| obj.std = torch.load(os.path.join(dir_path, "norm_std.pt"), map_location="cpu") |
| obj.fitted = True |
|
|
| print(f"✓ Normalizer loaded (F={obj.num_features})") |
| return obj |
|
|
| |
| |
| |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| import math |
|
|
| class xLSTMCovariateEncoder(nn.Module): |
| """ |
| Strategy 1: Deep xLSTM Encoder — 3 stacked Pre-Norm residual mLSTM blocks. |
| |
| Layer 1 (Perception): raw indicators -> baseline market states |
| Layer 2 (Context): combine states across time |
| Layer 3 (Translation): map to T5 cross-attention space |
| |
| Each layer: residual highway + branch(LayerNorm -> mLSTMBlock) |
| """ |
| def __init__(self, input_dim=11, d_model=256, num_heads=8, |
| num_layers=3, expansion_factor=2, dropout=0.1): |
| super().__init__() |
| self.input_dim = input_dim |
| self.d_model = d_model |
| self.num_heads = num_heads |
| self.num_layers = num_layers |
|
|
| |
| self.projection = nn.Linear(input_dim, d_model) |
|
|
| |
| self.pos_encoding = SinusoidalPositionalEncoding(d_model) |
|
|
| |
| self.norms = nn.ModuleList([nn.LayerNorm(d_model) for _ in range(num_layers)]) |
| self.blocks = nn.ModuleList([ |
| MultiHeadmLSTMBlock(d_model=d_model, num_heads=num_heads, |
| expansion_factor=expansion_factor) |
| for _ in range(num_layers) |
| ]) |
|
|
| self.dropout = nn.Dropout(dropout) |
|
|
| def forward(self, covariates): |
| """ |
| Args: covariates: (B, T, input_dim) |
| Returns: output: (B, T, d_model) |
| """ |
| |
| x = self.projection(covariates) |
| x = self.pos_encoding(x) |
|
|
| |
| for norm, block in zip(self.norms, self.blocks): |
| |
| branch = self.dropout(block(norm(x))) |
| |
| x = x + branch |
|
|
| return x |
|
|
| class MultiHeadmLSTMBlock(nn.Module): |
| """ |
| Multi-head mLSTM block with Up-Projection + SiLU gate (Strategy 1). |
| Operates in expanded space (d_model * expansion_factor) internally, |
| then projects back down to d_model. |
| """ |
| def __init__(self, d_model=256, num_heads=8, expansion_factor=2): |
| super().__init__() |
| assert d_model % num_heads == 0 |
|
|
| self.d_model = d_model |
| self.num_heads = num_heads |
| self.d_expanded = d_model * expansion_factor |
| assert self.d_expanded % num_heads == 0 |
| self.d_head = self.d_expanded // num_heads |
|
|
| |
| self.up_proj = nn.Linear(d_model, self.d_expanded) |
|
|
| |
| self.W_q = nn.Parameter(torch.randn(num_heads, self.d_head, self.d_head)) |
| self.W_k = nn.Parameter(torch.randn(num_heads, self.d_head, self.d_head)) |
| self.W_v = nn.Parameter(torch.randn(num_heads, self.d_head, self.d_head)) |
| self.W_alpha = nn.Parameter(torch.randn(num_heads, self.d_head, 1)) |
| self.b_alpha = nn.Parameter(torch.zeros(num_heads, 1)) |
| self.W_out = nn.Parameter(torch.randn(num_heads, self.d_head, self.d_head)) |
|
|
| |
| self.down_proj = nn.Linear(self.d_expanded, d_model) |
|
|
| |
| self.post_norm = nn.LayerNorm(self.d_head) |
|
|
| self._init_weights() |
|
|
| def _init_weights(self): |
| nn.init.xavier_uniform_(self.W_q) |
| nn.init.xavier_uniform_(self.W_k) |
| nn.init.xavier_uniform_(self.W_v) |
| nn.init.xavier_uniform_(self.W_alpha) |
| nn.init.xavier_uniform_(self.W_out) |
| nn.init.uniform_(self.b_alpha, 0.0, 0.5) |
| nn.init.xavier_uniform_(self.up_proj.weight) |
| nn.init.xavier_uniform_(self.down_proj.weight) |
|
|
| def forward(self, x): |
| """ |
| Args: x: (B, T, d_model) |
| Returns: (B, T, d_model) |
| """ |
| B, T, _ = x.shape |
|
|
| |
| x_up = F.silu(self.up_proj(x)) |
|
|
| |
| x_heads = x_up.view(B, T, self.num_heads, self.d_head).transpose(1, 2) |
|
|
| |
| Q = torch.einsum('bhtd,hde->bhte', x_heads, self.W_q) |
| K = torch.einsum('bhtd,hde->bhte', x_heads, self.W_k) |
| V = torch.einsum('bhtd,hde->bhte', x_heads, self.W_v) |
|
|
| |
| alpha_logits = torch.einsum('bhtd,hdf->bhtf', x_heads, self.W_alpha) |
| alpha_logits = alpha_logits + self.b_alpha.view(1, self.num_heads, 1, 1) |
| alpha = torch.clamp(torch.sigmoid(alpha_logits), min=1e-6, max=1.0 - 1e-6).unsqueeze(-1) |
|
|
| |
| updates = torch.matmul(K.unsqueeze(-1), V.unsqueeze(-2)) |
|
|
| |
| M = self._sequential_memory_update(alpha.transpose(1, 2), updates.transpose(1, 2)) |
| M = M.transpose(1, 2) |
|
|
| |
| h = torch.matmul(M, Q.unsqueeze(-1)).squeeze(-1) |
| h = self.post_norm(h) |
|
|
| |
| y = F.silu(torch.einsum('bhtd,hde->bhte', h, self.W_out)) |
|
|
| |
| y = y.transpose(1, 2).contiguous().view(B, T, self.d_expanded) |
|
|
| |
| return self.down_proj(y) |
|
|
| def _sequential_memory_update(self, alpha, updates): |
| B, T, H, d, _ = updates.shape |
| memory_states = [] |
| m_curr = alpha[:, 0] * updates[:, 0] |
| memory_states.append(m_curr) |
| for t in range(1, T): |
| m_prev = memory_states[-1] |
| m_curr = (1 - alpha[:, t]) * m_prev + alpha[:, t] * updates[:, t] |
| memory_states.append(m_curr) |
| return torch.stack(memory_states, dim=1) |
|
|
|
|
| class SinusoidalPositionalEncoding(nn.Module): |
| """Standard sinusoidal positional encoding.""" |
| def __init__(self, d_model, max_len=5000): |
| super().__init__() |
| pe = torch.zeros(max_len, d_model) |
| position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) |
| div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) |
| pe[:, 0::2] = torch.sin(position * div_term) |
| pe[:, 1::2] = torch.cos(position * div_term) |
| pe = pe.unsqueeze(0) |
| self.register_buffer('pe', pe) |
|
|
| def forward(self, x): |
| """ |
| Args: |
| x: (B, T, d_model) |
| Returns: |
| x + positional encoding: (B, T, d_model) |
| """ |
| return x + self.pe[:, :x.size(1), :] |
|
|
|
|
|
|
| class SinusoidalTimeEncoding(nn.Module): |
| """ |
| Robust Sinusoidal PE that handles odd/even dimensions correctly. |
| """ |
| def __init__(self, d_model: int, max_len: int = 500): |
| super().__init__() |
| pe = torch.zeros(max_len, d_model) |
| position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) |
| div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) |
|
|
| pe[:, 0::2] = torch.sin(position * div_term) |
| |
| n_cos = pe[:, 1::2].shape[1] |
| pe[:, 1::2] = torch.cos(position * div_term)[:, :n_cos] |
|
|
| self.register_buffer('pe', pe.unsqueeze(0)) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| T = x.size(1) |
| return x + self.pe[:, :T, :] |
|
|
|
|
| class CovariateEncoder(nn.Module): |
| def __init__(self, input_dim, d_model, nhead=4, num_layers=1, dropout=0.1): |
| """ |
| Encodes raw covariates into a dense, time-aware representation. |
| Input: (B, T, F) -> Output: (B, T, d_model) |
| """ |
| super().__init__() |
| |
| self.input_proj = nn.Linear(input_dim, d_model) |
|
|
| |
| self.pos_encoder = SinusoidalTimeEncoding(d_model) |
|
|
| |
| encoder_layer = nn.TransformerEncoderLayer( |
| d_model=d_model, |
| nhead=nhead, |
| dim_feedforward=d_model * 2, |
| dropout=dropout, |
| batch_first=True |
| ) |
| self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) |
|
|
| def forward(self, covariates): |
| |
| x = self.input_proj(covariates) |
| x = self.pos_encoder(x) |
| return self.transformer(x) |
|
|
|
|
| class CrossAttentionAdapter(nn.Module): |
| def __init__(self, d_model, nhead=4, dropout=0.1): |
| """ |
| Injects covariate context into encoder hidden states via Cross-Attention. |
| Query = Encoder Hidden States |
| Key/Value = Covariate Context |
| """ |
| super().__init__() |
| self.cross_attn = nn.MultiheadAttention(d_model, nhead, batch_first=True, dropout=dropout) |
| self.layernorm = nn.LayerNorm(d_model) |
| self.dropout = nn.Dropout(dropout) |
|
|
| |
| |
| nn.init.zeros_(self.cross_attn.out_proj.weight) |
| nn.init.zeros_(self.cross_attn.out_proj.bias) |
|
|
| def forward(self, encoder_hidden, cov_context): |
| |
| |
|
|
| residual = encoder_hidden |
|
|
| |
| attn_output, _ = self.cross_attn( |
| query=encoder_hidden, |
| key=cov_context, |
| value=cov_context, |
| need_weights=False |
| ) |
|
|
| |
| out = self.layernorm(residual + self.dropout(attn_output)) |
| return out |
|
|
|
|
| class DualPathAdapter(nn.Module): |
| def __init__(self, cov_dim, d_model, num_layers=3, expansion_factor=2): |
| super().__init__() |
|
|
| self.cov_encoder = xLSTMCovariateEncoder( |
| input_dim=cov_dim, |
| d_model=d_model, |
| num_heads=8, |
| num_layers=num_layers, |
| expansion_factor=expansion_factor, |
| dropout=0.1 |
| ) |
| |
| self.cross_adapter = CrossAttentionAdapter(d_model=d_model) |
|
|
| def forward(self, covariates, encoder_hidden): |
| cov_encoded = self.cov_encoder(covariates) |
| return self.cross_adapter(encoder_hidden, cov_encoded) |
|
|
|
|
| class ChronosDualPath(nn.Module): |
| def __init__( |
| self, |
| t5_model: AutoModelForSeq2SeqLM, |
| num_features: int, |
| freeze_encoder: bool = True, |
| num_layers: int = 3, |
| expansion_factor: int = 2 |
| ): |
| super().__init__() |
| self.t5_model = t5_model |
| self.d_model = t5_model.config.d_model |
|
|
| |
| self.adapter = DualPathAdapter( |
| cov_dim=num_features, |
| d_model=self.d_model, |
| num_layers=num_layers, |
| expansion_factor=expansion_factor |
| ) |
|
|
| |
| self.configure_t5_freezing(freeze_encoder) |
|
|
| def configure_t5_freezing(self, freeze_encoder: bool): |
| """ |
| Option A (freeze_encoder=True): Encoder Frozen, Decoder Unfrozen. |
| Option B (freeze_encoder=False): ALL Unfrozen (Full Fine-Tuning). |
| """ |
| |
| for p in self.t5_model.encoder.parameters(): |
| p.requires_grad = not freeze_encoder |
|
|
| |
| for p in self.t5_model.decoder.parameters(): |
| p.requires_grad = True |
|
|
| if freeze_encoder: |
| print("✓ T5 Freezing: Option A (Encoder FROZEN, Decoder UNFROZEN)") |
| else: |
| print("✓ T5 Freezing: Option B (Full Fine-Tuning, ALL UNFROZEN)") |
|
|
| |
| def forward( |
| self, |
| input_ids: torch.Tensor, |
| price: torch.Tensor, |
| cov: torch.Tensor, |
| labels: Optional[torch.Tensor] = None, |
| decoder_input_ids: Optional[torch.Tensor] = None, |
| attention_mask: Optional[torch.Tensor] = None, |
| decoder_attention_mask: Optional[torch.Tensor] = None, |
| ): |
| encoder_outputs = self.t5_model.encoder( |
| input_ids=input_ids, |
| attention_mask=attention_mask, |
| return_dict=True |
| ) |
|
|
| encoder_outputs.last_hidden_state = self.adapter( |
| covariates=cov, |
| encoder_hidden=encoder_outputs.last_hidden_state |
| ) |
|
|
| |
| outputs = self.t5_model( |
| encoder_outputs=encoder_outputs, |
| labels=labels, |
| decoder_input_ids=decoder_input_ids, |
| attention_mask=attention_mask, |
| decoder_attention_mask=decoder_attention_mask, |
| return_dict=True |
| ) |
|
|
| return outputs |
| |
|
|
| def _restrict_logits_to_numeric(self, logits): |
| start = self.t5_model.config.vocab_offset |
| end = start + self.t5_model.config.num_bins |
| mask = torch.full_like(logits, float("-inf")) |
| mask[..., start:end] = logits[..., start:end] |
| return mask |
|
|
| def _sample_numeric(self, logits, temperature=1.0, top_p=0.9): |
| logits = logits / max(temperature, 1e-8) |
| probs = torch.softmax(logits, dim=-1) |
| sorted_probs, sorted_idx = torch.sort(probs, descending=True) |
| cum = sorted_probs.cumsum(dim=-1) |
| cutoff = cum > top_p |
| cutoff[..., 1:] = cutoff[..., :-1].clone() |
| cutoff[..., 0] = False |
| sorted_probs = sorted_probs.masked_fill(cutoff, 0.0) |
| probs = torch.zeros_like(probs).scatter_(1, sorted_idx, sorted_probs) |
| probs = probs / probs.sum(dim=-1, keepdim=True).clamp(min=1e-8) |
| return torch.multinomial(probs, num_samples=1) |
|
|
| def generate( |
| self, |
| input_ids: torch.Tensor, |
| price: torch.Tensor, |
| cov: torch.Tensor, |
| attention_mask: Optional[torch.Tensor] = None, |
| max_length: int = 64, |
| **generate_kwargs |
| ): |
|
|
| |
| num_seq = generate_kwargs.get("num_return_sequences", 1) |
| assert num_seq == 1, "Current custom generate() only supports num_return_sequences=1" |
|
|
| |
| enc_outputs = self.t5_model.encoder( |
| input_ids=input_ids, |
| attention_mask=attention_mask, |
| return_dict=True |
| ) |
| |
| enc_outputs.last_hidden_state = self.adapter( |
| covariates=cov, |
| encoder_hidden=enc_outputs.last_hidden_state |
| ) |
|
|
| |
| device = input_ids.device |
| B = input_ids.shape[0] |
| dec_input = torch.full( |
| (B, 1), |
| self.t5_model.config.bos_token_id, |
| dtype=torch.long, |
| device=device |
| ) |
|
|
| outputs = [] |
| for _ in range(max_length): |
| out = self.t5_model( |
| encoder_outputs=enc_outputs, |
| decoder_input_ids=dec_input, |
| use_cache=True, |
| return_dict=True |
| ) |
| logits = out.logits[:, -1, :] |
| logits = self._restrict_logits_to_numeric(logits) |
| next_tok = self._sample_numeric( |
| logits=logits, |
| temperature=generate_kwargs.get("temperature", 1.0), |
| top_p=generate_kwargs.get("top_p", 0.9), |
| ) |
| |
| |
| |
| dec_input = torch.cat([dec_input, next_tok], dim=1) |
| outputs.append(next_tok) |
|
|
| return torch.cat(outputs, dim=1) |
|
|
| |
| |
| |
|
|
| class TimeSeriesDataset(Dataset): |
| """ |
| LCS Upgrade: Stores raw prices, scaled input_ids/labels, and local scale. |
| p_last is removed; reconstruction is now: decoded_token * scale. |
| """ |
| def __init__( |
| self, |
| prices: np.ndarray, |
| covariates: np.ndarray, |
| input_ids: np.ndarray, |
| labels: np.ndarray, |
| future_prices: np.ndarray, |
| scale: np.ndarray, |
| attention_mask: Optional[np.ndarray] = None, |
| pad_token_id: int = 0, |
| ): |
| self.prices = torch.FloatTensor(prices) |
| self.covariates = torch.FloatTensor(covariates) |
| self.input_ids = torch.LongTensor(input_ids) |
| self.labels = torch.LongTensor(labels) |
| self.future_prices = torch.FloatTensor(future_prices) |
| self.scale = torch.FloatTensor(scale) |
|
|
| if attention_mask is None: |
| self.attention_mask = (self.input_ids != pad_token_id).long() |
| else: |
| self.attention_mask = torch.LongTensor(attention_mask) |
|
|
| def __len__(self): |
| return self.prices.shape[0] |
|
|
| def __getitem__(self, idx): |
| return { |
| "price": self.prices[idx], |
| "cov": self.covariates[idx], |
| "input_ids": self.input_ids[idx], |
| "labels": self.labels[idx], |
| "future_prices": self.future_prices[idx], |
| "scale": self.scale[idx], |
| "attention_mask": self.attention_mask[idx], |
| } |
|
|
| |
| |
| |
|
|
| @dataclass |
| class TrainingConfig: |
| lr: float = 1e-4 |
| lr_end: float = 3e-5 |
| warmup_ratio: float = 0.05 |
| weight_decay: float = 0.01 |
| epochs: int = 12 |
| batch_size: int = 32 |
| gradient_clip: float = 1.0 |
| gradient_accumulation_steps: int = 1 |
| mixed_precision: bool = False |
| patience: int = 6 |
| device: str = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| lambda_price: float = 10000.0 |
|
|
| |
| |
| |
| freeze_encoder: bool = True |
|
|
| save_dir: str = "./checkpoints" |
| log_every_n_steps: int = 50 |
|
|
| def save(self, path: str): |
| with open(path, "w") as f: |
| json.dump(asdict(self), f, indent=2) |
|
|
| @classmethod |
| def load(cls, path: str): |
| with open(path, "r") as f: |
| return cls(**json.load(f)) |
|
|
|
|
| def get_custom_cosine_schedule( |
| optimizer, |
| num_warmup_steps: int, |
| num_training_steps: int, |
| lr_end_factor: float = 0.3, |
| ): |
| def lr_lambda(current_step: int): |
| if current_step < num_warmup_steps: |
| return float(current_step) / float(max(1, num_warmup_steps)) |
| progress = float(current_step - num_warmup_steps) / float( |
| max(1, num_training_steps - num_warmup_steps) |
| ) |
| cosine = 0.5 * (1.0 + math.cos(math.pi * progress)) |
| return max(lr_end_factor, cosine) |
|
|
| return torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) |
|
|
|
|
| |
| |
| |
|
|
| class CGCXTrainer: |
| def __init__( |
| self, |
| model, |
| config: TrainingConfig, |
| normalizer: ZScoreNormalizer, |
| train_loader: DataLoader, |
| val_loader: Optional[DataLoader], |
| tokenizer: SimpleChronosTokenizer, |
| mase_scale: float = 1.0, |
| cov_cols: Optional[List[str]] = None, |
| ): |
| self.model = model.to(config.device) |
| self.config = config |
| self.normalizer = normalizer |
| self.train_loader = train_loader |
| self.val_loader = val_loader |
| self.device = config.device |
| self.tokenizer = tokenizer |
|
|
| assert cov_cols is not None, "cov_cols must be provided" |
| self.cov_cols = cov_cols |
|
|
| |
| |
| trainable_params = [] |
| for name, p in model.named_parameters(): |
| if p.requires_grad: |
| trainable_params.append(p) |
|
|
| self.optimizer = torch.optim.AdamW( |
| trainable_params, |
| lr=config.lr, weight_decay=config.weight_decay, |
| ) |
|
|
| steps_per_epoch = math.ceil(len(train_loader) / config.gradient_accumulation_steps) |
| num_training_steps = steps_per_epoch * config.epochs |
|
|
| self.scheduler = get_custom_cosine_schedule( |
| self.optimizer, |
| int(num_training_steps * config.warmup_ratio), |
| num_training_steps, |
| lr_end_factor=config.lr_end / config.lr |
| ) |
| self.scaler = GradScaler('cuda') if config.mixed_precision else None |
| self.mase_scale = float(max(mase_scale, 1e-8)) |
|
|
| |
| num_bins = tokenizer.num_bins |
| vocab_size = model.t5_model.config.vocab_size |
| offset = model.t5_model.config.vocab_offset |
| bin_idx = torch.arange(num_bins, dtype=torch.float32) |
| scaled = bin_idx / (num_bins - 1) |
| bin_values = scaled * tokenizer.range + tokenizer.min_val |
|
|
| full_bin_values = torch.zeros(vocab_size, dtype=torch.float32) |
| full_bin_values[offset:offset + num_bins] = bin_values |
| self.bin_values = full_bin_values.view(1, 1, -1).to(self.device) |
| self.bin_values.requires_grad_(False) |
|
|
| print("✓ Trainer initialized (LCS Mode: scaled prices in [0.0, 5.0])") |
|
|
| def _expected_scaled_price_from_logits(self, logits: torch.Tensor) -> torch.Tensor: |
| """Returns expected scaled-price value from logits (LCS mode).""" |
| masked_logits = self.model._restrict_logits_to_numeric(logits) |
| probs = torch.softmax(masked_logits, dim=-1) |
| exp_scaled_price = (probs * self.bin_values).sum(dim=-1) |
| return exp_scaled_price |
|
|
| def _scaled_prices_from_labels(self, labels: torch.Tensor) -> torch.Tensor: |
| """ |
| Maps token IDs (labels) to their numeric scaled-price values. |
| """ |
| |
| vocab_vals = self.bin_values.view(-1) |
|
|
| |
| return vocab_vals.gather(0, labels.view(-1)).view_as(labels) |
|
|
| |
| def train_epoch(self, epoch: int) -> Dict[str, float]: |
| self.model.train() |
| total_loss = 0.0 |
| total_ret_mse = 0.0 |
| total_count = 0 |
|
|
| pad_id = self.tokenizer.pad_token_id |
| bos_id = self.model.t5_model.config.bos_token_id |
| self.optimizer.zero_grad() |
|
|
| for step, batch in enumerate(self.train_loader): |
| input_ids = batch["input_ids"].to(self.device) |
| price = batch["price"].to(self.device) |
| cov = batch["cov"].to(self.device) |
| labels = batch["labels"].to(self.device) |
| mask = batch["attention_mask"].to(self.device) |
|
|
| cov_norm = self.normalizer.transform(cov) |
|
|
| |
| |
| B, H = labels.shape |
| decoder_input_ids = torch.cat( |
| [torch.full((B, 1), bos_id, device=self.device, dtype=torch.long), labels[:, :-1]], |
| dim=1 |
| ) |
|
|
| |
| decoder_attention_mask = (decoder_input_ids != pad_id).long() |
|
|
| with autocast('cuda', enabled=self.config.mixed_precision): |
| |
| outputs = self.model( |
| input_ids=input_ids, price=price, cov=cov_norm, |
| labels=None, |
| decoder_input_ids=decoder_input_ids, |
| attention_mask=mask, |
| decoder_attention_mask=decoder_attention_mask |
| ) |
| logits = outputs.logits |
|
|
| |
| masked_logits = self.model._restrict_logits_to_numeric(logits) |
|
|
| ce_loss = F.cross_entropy( |
| masked_logits.reshape(-1, masked_logits.size(-1)), |
| labels.reshape(-1), |
| ignore_index=pad_id |
| ) |
|
|
| |
| |
| pred_scaled = self._expected_scaled_price_from_logits(logits) |
| true_scaled = self._scaled_prices_from_labels(labels) |
|
|
| |
| label_mask = (labels != pad_id).float() |
| mse_elem = (pred_scaled - true_scaled) ** 2 |
| ret_mse_loss = (mse_elem * label_mask).sum() / (label_mask.sum() + 1e-8) |
|
|
| |
| hybrid_loss = ce_loss + self.config.lambda_price * ret_mse_loss |
| loss = hybrid_loss / self.config.gradient_accumulation_steps |
|
|
| if self.scaler: |
| self.scaler.scale(loss).backward() |
| else: |
| loss.backward() |
|
|
| if (step + 1) % self.config.gradient_accumulation_steps == 0 or (step + 1) == len(self.train_loader): |
| if self.scaler: |
| self.scaler.unscale_(self.optimizer) |
| torch.nn.utils.clip_grad_norm_([p for p in self.model.parameters() if p.requires_grad], self.config.gradient_clip) |
| self.scaler.step(self.optimizer) |
| self.scaler.update() |
| else: |
| |
| torch.nn.utils.clip_grad_norm_([p for p in self.model.parameters() if p.requires_grad], self.config.gradient_clip) |
| |
| has_nan_grad = any( |
| p.grad is not None and not torch.isfinite(p.grad).all() |
| for p in self.model.parameters() if p.requires_grad |
| ) |
| if has_nan_grad: |
| print(f" ⚠️ NaN gradient detected at step {step}. Skipping optimizer step.") |
| self.optimizer.zero_grad() |
| else: |
| self.optimizer.step() |
|
|
| self.scheduler.step() |
| self.optimizer.zero_grad() |
|
|
| |
| total_loss += hybrid_loss.item() * self.config.gradient_accumulation_steps |
| total_ret_mse += ret_mse_loss.item() * self.config.gradient_accumulation_steps |
| total_count += 1 |
|
|
| if step % self.config.log_every_n_steps == 0: |
| print(f" [Epoch {epoch+1} Step {step}] Loss: {hybrid_loss.item() * self.config.gradient_accumulation_steps:.4f} (CE:{ce_loss.item():.3f} ScaledMSE:{ret_mse_loss.item():.5f})") |
|
|
| return { |
| "train_loss": total_loss / max(1, total_count), |
| "train_scaled_rmse": math.sqrt(total_ret_mse / max(1, total_count)) |
| } |
|
|
| @torch.no_grad() |
| def validate(self): |
| self.model.eval() |
| total_price_sse = 0.0 |
| total_tokens = 0 |
| pad_id = self.tokenizer.pad_token_id |
| bos_id = self.model.t5_model.config.bos_token_id |
|
|
| for batch in self.val_loader: |
| input_ids = batch["input_ids"].to(self.device) |
| cov = batch["cov"].to(self.device) |
| future_prices = batch["future_prices"].to(self.device) |
| scale = batch["scale"].to(self.device) |
| mask = batch["attention_mask"].to(self.device) |
| labels = batch["labels"].to(self.device) |
|
|
| |
| B, H = labels.shape |
| decoder_input_ids = torch.cat( |
| [torch.full((B, 1), bos_id, device=self.device, dtype=torch.long), labels[:, :-1]], |
| dim=1 |
| ) |
| decoder_attention_mask = (decoder_input_ids != pad_id).long() |
|
|
| outputs = self.model( |
| input_ids=input_ids, price=batch["price"].to(self.device), |
| cov=self.normalizer.transform(cov), |
| labels=None, |
| decoder_input_ids=decoder_input_ids, |
| attention_mask=mask, |
| decoder_attention_mask=decoder_attention_mask |
| ) |
|
|
| |
| pred_scaled = self._expected_scaled_price_from_logits(outputs.logits) |
| pred_prices = pred_scaled * scale.unsqueeze(1) |
|
|
| valid_mask = (labels != pad_id) |
| error_sq = (pred_prices - future_prices) ** 2 |
|
|
| total_price_sse += (error_sq * valid_mask).sum().item() |
| total_tokens += valid_mask.sum().item() |
|
|
| avg_mse = total_price_sse / total_tokens if total_tokens > 0 else 0.0 |
| return {"val_rmse": math.sqrt(avg_mse)} |
|
|
| def train(self): |
| print(f"🚀 Training Started (Max Epochs: {self.config.epochs}, Patience: {self.config.patience})") |
|
|
| best_rmse = float("inf") |
| patience_counter = 0 |
|
|
| for epoch in range(self.config.epochs): |
| metrics = self.train_epoch(epoch) |
| val_metrics = self.validate() |
| |
| |
| print(f"Epoch {epoch+1}: Train ScaledRMSE {metrics['train_scaled_rmse']:.4f} | Val PriceRMSE {val_metrics['val_rmse']:.4f}") |
|
|
| |
| current_val_rmse = val_metrics["val_rmse"] |
|
|
| if val_metrics["val_rmse"] < best_rmse: |
| best_rmse = val_metrics["val_rmse"] |
| patience_counter = 0 |
| print(f" 🔥 New Best Model! Saving... (RMSE: {best_rmse:.4f})") |
|
|
| Path(self.config.save_dir).mkdir(parents=True, exist_ok=True) |
| torch.save(self.model.adapter.state_dict(), os.path.join(self.config.save_dir, "best_adapter.pt")) |
| torch.save(self.model.t5_model.state_dict(), os.path.join(self.config.save_dir, "best_t5.pt")) |
| self.tokenizer.save_config(os.path.join(self.config.save_dir, "tokenizer_config.json")) |
| self.normalizer.save(self.config.save_dir) |
|
|
| with open(os.path.join(self.config.save_dir, "model_config.json"), "w") as f: |
| |
| json.dump({ |
| "t5_base_model": "amazon/chronos-t5-base", |
| "vocab_size": self.model.t5_model.config.vocab_size, |
| "pad_token_id": self.model.t5_model.config.pad_token_id, |
| "eos_token_id": self.model.t5_model.config.eos_token_id, |
| "bos_token_id": self.model.t5_model.config.bos_token_id, |
| "vocab_offset": self.model.t5_model.config.vocab_offset, |
| "num_bins": self.model.t5_model.config.num_bins, |
| "num_features": self.model.adapter.cov_encoder.projection.in_features, |
| "d_model": self.model.d_model, |
| "embeddings_resized": True, |
| "freeze_encoder": self.config.freeze_encoder, |
| "cov_cols": self.cov_cols, |
| "target_representation": "local_scale", |
| |
| "xlstm_num_layers": self.model.adapter.cov_encoder.num_layers, |
| "xlstm_expansion_factor": self.model.adapter.cov_encoder.blocks[0].up_proj.out_features // self.model.d_model, |
| }, f, indent=2) |
| else: |
| |
| patience_counter += 1 |
| print(f" ⚠️ No improvement. Patience: {patience_counter}/{self.config.patience}") |
|
|
| if patience_counter >= self.config.patience: |
| print(f"\n⏹️ Early Stopping Triggered at Epoch {epoch+1}!") |
| print(f" Best Validation RMSE was: {best_rmse:.4f}") |
| break |
|
|
| return {"best_val_rmse": best_rmse} |
|
|
| |
| |
| |
|
|
| class CGCXInference: |
| def __init__( |
| self, |
| model, |
| normalizer, |
| tokenizer, |
| device: Optional[str] = None, |
| ): |
| if device is None: |
| self.device = "cuda" if torch.cuda.is_available() else "cpu" |
| else: |
| self.device = device |
|
|
| self.model = model.to(self.device) |
| self.model.eval() |
| self.normalizer = normalizer |
| self.tokenizer = tokenizer |
|
|
|
|
| |
| |
|
|
| @torch.no_grad() |
| def predict( |
| self, |
| input_ids: torch.Tensor, |
| price: torch.Tensor, |
| covariates: torch.Tensor, |
| attention_mask: Optional[torch.Tensor] = None, |
| num_samples: int = 20, |
| max_length: int = 1, |
| temperature: float = 1.0, |
| top_p: float = 0.9, |
| ) -> np.ndarray: |
|
|
| cov_norm = self.normalizer.transform(covariates.to(self.device)) |
| price_np = price.cpu().numpy() |
| local_scale = np.mean(np.abs(price_np), axis=1, keepdims=True) |
| local_scale = np.clip(local_scale, 1e-8, None) |
|
|
| B = input_ids.shape[0] |
| all_pred_scaled = [] |
|
|
| |
| |
| for i in range(B): |
|
|
| |
| ids_single = input_ids[i:i+1].to(self.device) |
| cov_single = cov_norm[i:i+1].to(self.device) |
| mask_single = attention_mask[i:i+1].to(self.device) if attention_mask is not None else None |
|
|
| window_samples = [] |
|
|
| |
| for _ in range(num_samples): |
| gen_tokens = self.model.generate( |
| input_ids=ids_single, |
| price=None, |
| cov=cov_single, |
| attention_mask=mask_single, |
| max_length=max_length, |
| num_return_sequences=1, |
| temperature=temperature, |
| top_p=top_p |
| ) |
|
|
| |
| gen_tokens_np = gen_tokens.cpu().numpy() |
| pred_scaled_exp = self.tokenizer.decode(gen_tokens_np) |
|
|
| if np.isnan(pred_scaled_exp).any(): |
| pred_scaled_exp = np.nan_to_num(pred_scaled_exp, nan=1.0) |
|
|
| window_samples.append(pred_scaled_exp) |
|
|
| |
| |
| stacked_samples = np.stack(window_samples, axis=0) |
|
|
| |
| median_path = np.median(stacked_samples, axis=0) |
| all_pred_scaled.append(median_path) |
|
|
| |
| |
| |
| del ids_single, cov_single, mask_single, gen_tokens |
| torch.cuda.empty_cache() |
|
|
| |
| final_pred_scaled = np.concatenate(all_pred_scaled, axis=0) |
|
|
| |
| pred_prices = final_pred_scaled * local_scale |
| return pred_prices |
|
|
|
|
| |
| |
| |
|
|
| def load_multi_asset_time_splits(data_dir: str, T: int, H: int, |
| max_files: int = 200, |
| min_len: int = 120): |
|
|
| train_prices_list = [] |
| train_cov_list = [] |
| train_labels_list = [] |
|
|
| val_prices_list = [] |
| val_cov_list = [] |
| val_labels_list = [] |
|
|
| train_scales_list = [] |
| val_scales_list = [] |
| test_scales_list = [] |
|
|
| test_prices_list = [] |
| test_cov_list = [] |
| test_labels_list = [] |
|
|
| files = [f for f in os.listdir(data_dir) if f.endswith(".csv")] |
| files = files[:max_files] |
|
|
| print(f"Processing {len(files)} tickers...") |
|
|
| for fname in files: |
| path = os.path.join(data_dir, fname) |
|
|
| |
| try: |
| df = pd.read_csv(path) |
| except Exception as e: |
| print(f"⚠️ Skipping {fname}: cannot load ({e})") |
| continue |
|
|
| if "Date" not in df.columns: |
| print(f"⚠️ Skipping {fname}: no Date column") |
| continue |
| if not {"Open","High","Low","Close"}.issubset(df.columns): |
| print(f"⚠️ Skipping {fname}: missing OHLC") |
| continue |
|
|
| df["Date"] = pd.to_datetime(df["Date"], errors="coerce") |
| df = df.dropna(subset=["Date"]) |
| df = df.sort_values("Date").drop_duplicates(subset=["Date"]) |
| df = df.set_index("Date") |
|
|
| |
| |
| df = df.resample("B").asfreq().ffill() |
| df = df.dropna() |
| df["price"] = df["Close"] |
|
|
| if len(df) < min_len: |
| continue |
|
|
| |
| df = add_technical_indicators(df) |
| df = df.dropna() |
|
|
| if len(df) < T + H: |
| continue |
|
|
| |
| df_train = df.loc[:TRAIN_END] |
| df_val = df.loc[TRAIN_END + pd.Timedelta(days=1): VAL_END] |
| df_test = df.loc[VAL_END + pd.Timedelta(days=1):] |
|
|
| |
| def process(split_df, P_list, C_list, L_list, S_list): |
| if len(split_df) >= T + H: |
| result = build_windows_from_df(split_df, T=T, H=H) |
| p, c, y, s = result[0], result[1], result[2], result[3] |
| |
| if len(p) > 0: |
| P_list.append(p); C_list.append(c) |
| L_list.append(y); S_list.append(s) |
|
|
| process(df_train, train_prices_list, train_cov_list, train_labels_list, train_scales_list) |
| process(df_val, val_prices_list, val_cov_list, val_labels_list, val_scales_list) |
| process(df_test, test_prices_list, test_cov_list, test_labels_list, test_scales_list) |
|
|
| |
| def safe_concat(arr_list, name): |
| if not arr_list: |
| raise ValueError(f"No data generated for {name} split. Check Date range/Data quality.") |
| return np.concatenate(arr_list, axis=0) |
|
|
| |
| |
| train_prices = safe_concat(train_prices_list, "Train") |
| train_cov = safe_concat(train_cov_list, "Train") |
| train_labels = safe_concat(train_labels_list, "Train") |
| train_scales = safe_concat(train_scales_list, "Train") |
|
|
| val_prices = safe_concat(val_prices_list, "Validation") |
| val_cov = safe_concat(val_cov_list, "Validation") |
| val_labels = safe_concat(val_labels_list, "Validation") |
| val_scales = safe_concat(val_scales_list, "Validation") |
|
|
| test_prices = safe_concat(test_prices_list, "Test") |
| test_cov = safe_concat(test_cov_list, "Test") |
| test_labels = safe_concat(test_labels_list, "Test") |
| test_scales = safe_concat(test_scales_list, "Test") |
|
|
| return ( |
| train_prices, train_cov, train_labels, train_scales, |
| val_prices, val_cov, val_labels, val_scales, |
| test_prices, test_cov, test_labels, test_scales, |
| ) |
|
|
| |
| |
| |
|
|
| def calculate_metrics(y_true, y_pred): |
| """Calculates comprehensive regression metrics.""" |
| |
| mask = np.isfinite(y_true) & np.isfinite(y_pred) |
| y_true = y_true[mask] |
| y_pred = y_pred[mask] |
|
|
| if len(y_true) == 0: |
| return {} |
|
|
| mse = mean_squared_error(y_true, y_pred) |
| mae = mean_absolute_error(y_true, y_pred) |
| rmse = np.sqrt(mse) |
|
|
| |
| mape = np.mean(np.abs((y_true - y_pred) / (np.abs(y_true) + 1e-8))) * 100 |
|
|
| |
| r2 = r2_score(y_true, y_pred) |
|
|
| return { |
| "MSE": mse, |
| "RMSE": rmse, |
| "MAE": mae, |
| "MAPE": f"{mape:.2f}%", |
| "R2": r2 |
| } |
|
|
| |
| def load_checkpoint_with_config( |
| checkpoint_dir: str, |
| device: str = "cuda", |
| strict_schema: bool = True, |
| override_freeze_encoder: Optional[bool] = None |
| ): |
| print(f"\n🔧 Loading checkpoint from {checkpoint_dir}...") |
|
|
| tok_path = os.path.join(checkpoint_dir, "tokenizer_config.json") |
| mod_path = os.path.join(checkpoint_dir, "model_config.json") |
| adp_path = os.path.join(checkpoint_dir, "best_adapter.pt") |
|
|
| if not all(os.path.exists(p) for p in [tok_path, mod_path, adp_path]): |
| raise FileNotFoundError("Missing checkpoint files.") |
|
|
| with open(tok_path, "r") as f: tconfig = json.load(f) |
| with open(mod_path, "r") as f: mconfig = json.load(f) |
|
|
| saved_cov_cols = mconfig.get("cov_cols", None) |
| assert saved_cov_cols is not None, "Checkpoint missing 'cov_cols'" |
|
|
| if strict_schema: |
| saved_mode = mconfig.get("target_representation", "price") |
| tok_mode = tconfig.get("mode", "price") |
| assert saved_mode == "local_scale", f"Model mode '{saved_mode}' != 'local_scale'" |
| assert tok_mode == "local_scale", f"Tokenizer mode '{tok_mode}' != 'local_scale'" |
| assert saved_cov_cols == COV_COLS, "Global COV_COLS mismatch" |
|
|
| tokenizer = SimpleChronosTokenizer.load_config(tok_path) |
| normalizer = ZScoreNormalizer.load(checkpoint_dir) |
|
|
| t5_model = AutoModelForSeq2SeqLM.from_pretrained(mconfig['t5_base_model']) |
| if mconfig.get('embeddings_resized', False): |
| t5_model.resize_token_embeddings(mconfig['vocab_size']) |
|
|
| for k in ["vocab_size", "pad_token_id", "eos_token_id", "bos_token_id", "vocab_offset", "num_bins"]: |
| setattr(t5_model.config, k, mconfig[k]) |
|
|
| |
| if override_freeze_encoder is not None: |
| |
| freeze_setting = override_freeze_encoder |
| print(f"⚠️ Overriding saved freeze state. Forcing freeze_encoder={freeze_setting}") |
| else: |
| |
| freeze_setting = mconfig.get("freeze_encoder", mconfig.get("freeze_backbone", True)) |
|
|
| num_layers = mconfig.get("xlstm_num_layers", 3) |
| expansion_factor = mconfig.get("xlstm_expansion_factor", 2) |
|
|
| model = ChronosDualPath( |
| t5_model=t5_model, |
| num_features=mconfig['num_features'], |
| freeze_encoder=freeze_setting, |
| num_layers=num_layers, |
| expansion_factor=expansion_factor |
| ) |
|
|
| |
| raw_state_dict = torch.load(adp_path, map_location=torch.device(device)) |
|
|
| |
| |
| clean_state_dict = {} |
| for key, value in raw_state_dict.items(): |
| clean_key = key.replace("_orig_mod.", "") |
| clean_state_dict[clean_key] = value |
|
|
| model.adapter.load_state_dict(clean_state_dict) |
|
|
| t5_path = os.path.join(checkpoint_dir, "best_t5.pt") |
| if os.path.exists(t5_path): |
| model.t5_model.load_state_dict(torch.load(t5_path, map_location=torch.device(device))) |
| print("✓ T5 weights restored from checkpoint") |
| else: |
| print("⚠️ No best_t5.pt found — using pretrained T5 weights (old checkpoint)") |
|
|
| model.to(device) |
| model.eval() |
|
|
| print(f"✓ Model loaded successfully (Mode: local_scale)") |
| return model, tokenizer, normalizer, saved_cov_cols |
|
|
|
|
| def run_test_pipeline( |
| data_dir: str, |
| model, |
| normalizer, |
| tokenizer, |
| cov_cols: list, |
| T: int = 64, |
| H: int = 16, |
| num_symbols: int = 5 |
| ): |
| print("\n" + "="*60) |
| print("🚀 STARTING AUTOMATED TEST PIPELINE (LCS: Local Context Scaling)") |
| print("="*60) |
|
|
| all_files = [f for f in os.listdir(data_dir) if f.endswith(".csv")] |
| if not all_files: return |
|
|
| selected_files = random.sample(all_files, min(num_symbols, len(all_files))) |
| infer = CGCXInference(model, normalizer, tokenizer) |
|
|
| for fname in selected_files: |
| symbol = fname.replace('.csv', '') |
| print(f"\n🔍 Analyzing Symbol: {symbol}") |
| path = os.path.join(data_dir, fname) |
|
|
| try: |
| df = pd.read_csv(path, parse_dates=["Date"]) |
| df = df.sort_values("Date").drop_duplicates(subset=["Date"]).set_index("Date") |
| |
| df = df.resample("B").asfreq().ffill() |
| df = df.dropna() |
| df["price"] = df["Close"] |
| df = add_technical_indicators(df).dropna() |
|
|
| df_test = df.loc[VAL_END + pd.Timedelta(days=1):] |
| if len(df_test) < T + H: continue |
|
|
| prices_test, cov_test, labels_test, scales_test = build_windows_from_df( |
| df_test, T=T, H=H, columns=cov_cols |
| ) |
| if len(prices_test) == 0: continue |
|
|
| if np.any(prices_test <= 0): |
| print(f"Skipping {fname}: non-positive prices") |
| continue |
|
|
| |
| local_scales = np.mean(np.abs(prices_test), axis=1, keepdims=True) |
| local_scales = np.clip(local_scales, 1e-8, None) |
| scaled_input = prices_test / local_scales |
|
|
| input_ids_test = np.stack([tokenizer.encode(row) for row in scaled_input]) |
| input_ids_t = torch.LongTensor(input_ids_test) |
| price_t = torch.FloatTensor(prices_test) |
| cov_t = torch.FloatTensor(cov_test) |
|
|
| |
| input_mask = (input_ids_t != tokenizer.pad_token_id).long() |
|
|
| |
| pad_id = tokenizer.pad_token_id |
| unique_tokens = [len(np.unique(row[row != pad_id])) for row in input_ids_test] |
| print(f" LCS Token Diversity: {np.mean(unique_tokens):.1f}/{T}") |
|
|
| print(f" 🤖 Predicting {len(prices_test)} sequences...") |
| preds = infer.predict( |
| input_ids=input_ids_t, |
| price=price_t, |
| covariates=cov_t, |
| attention_mask=input_mask, |
| num_samples=20, |
| temperature=1.0, |
| max_length=H |
| ) |
|
|
| metrics = calculate_metrics(labels_test.flatten(), preds.flatten()) |
| rmse = metrics.get("RMSE") |
| print(f" 📊 All-Window RMSE: {rmse:.4f}" if rmse else " ⚠️ RMSE: N/A") |
|
|
| except Exception as e: |
| print(f"Error: {e}") |
| import traceback |
| traceback.print_exc() |
|
|
|
|
| |
|
|
| def run_evaluations(cursor, current_date, actuals_df, model_version="v1.0"): |
| print("\n--- Running Daily Evaluations ---") |
| import pandas as pd |
| import numpy as np |
| |
| |
| cursor.execute(""" |
| SELECT date, entity_id, predicted_value |
| FROM predictions |
| WHERE model_version = %s AND date < %s |
| ORDER BY date DESC LIMIT 400 |
| """, (model_version, current_date)) |
| |
| |
| past_preds = cursor.fetchall() |
| if not past_preds: |
| print("No previous predictions to evaluate yet.") |
| return |
|
|
| |
| actuals_df.index = pd.to_datetime(actuals_df.index).tz_localize(None).date |
| valid_dates = sorted([d for d in actuals_df.index if d < current_date]) |
| if len(valid_dates) < 2: |
| print("Not enough market data to evaluate.") |
| return |
| target_date = valid_dates[-1] |
| prev_date = valid_dates[-2] |
|
|
|
|
| evals_to_insert = [] |
| |
| |
| abs_errors_pct = [] |
| abs_errors_dollar = [] |
| direction_hits = 0 |
| pred_returns = [] |
| act_returns = [] |
| |
| for row in past_preds: |
| pred_date, symbol, predicted_price = row |
| |
| |
| if symbol in actuals_df.columns: |
| |
| try: |
| |
| actual_price = float(actuals_df.loc[target_date, symbol]) |
| prev_close = float(actuals_df.loc[prev_date, symbol]) |
| |
| |
| error_dollar = predicted_price - actual_price |
| error_pct = error_dollar / actual_price |
| |
| pred_ret = (predicted_price - prev_close) / prev_close |
| act_ret = (actual_price - prev_close) / prev_close |
| |
| |
| direction_hit = (pred_ret > 0 and act_ret > 0) or (pred_ret < 0 and act_ret < 0) |
| |
| evals_to_insert.append(( |
| target_date, symbol, predicted_price, actual_price, |
| error_dollar, error_pct, pred_ret, act_ret, direction_hit, model_version |
| )) |
| |
| |
| abs_errors_dollar.append(abs(error_dollar)) |
| abs_errors_pct.append(abs(error_pct)) |
| pred_returns.append(pred_ret) |
| act_returns.append(act_ret) |
| if direction_hit: |
| direction_hits += 1 |
| |
| except Exception as e: |
| continue |
| |
| |
| |
| actuals_to_insert = [] |
| for symbol in actuals_df.columns: |
| try: |
| today_actual_price = float(actuals_df.loc[target_date, symbol]) |
| actuals_to_insert.append((target_date, symbol, today_actual_price)) |
| except: |
| continue |
| |
| if actuals_to_insert: |
| cursor.executemany(""" |
| INSERT INTO actuals (date, symbol, actual_value) |
| VALUES (%s, %s, %s) |
| ON CONFLICT DO NOTHING; |
| """, actuals_to_insert) |
|
|
| |
| if evals_to_insert: |
| cursor.executemany(""" |
| INSERT INTO evaluations (date, symbol, predicted_value, actual_value, error_dollar, error_pct, predicted_return, actual_return, direction_hit, model_version) |
| VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) |
| ON CONFLICT DO NOTHING; |
| """, evals_to_insert) |
| |
| |
| |
| summary_df = pd.DataFrame({"pred": pred_returns, "act": act_returns}) |
| |
| |
| try: |
| spearman = float(summary_df.corr(method='spearman').iloc[0, 1]) |
| if np.isnan(spearman): spearman = 0.0 |
| except: |
| spearman = 0.0 |
| |
| try: |
| mae_dollar = float(np.nanmean(abs_errors_dollar)) |
| if np.isnan(mae_dollar): mae_dollar = 0.0 |
| except: |
| mae_dollar = 0.0 |
| |
| try: |
| mae_pct = float(np.nanmean(abs_errors_pct)) |
| if np.isnan(mae_pct): mae_pct = 0.0 |
| except: |
| mae_pct = 0.0 |
| |
| dir_acc = float(direction_hits / len(evals_to_insert)) if evals_to_insert else 0.0 |
| |
| cursor.execute(""" |
| INSERT INTO daily_summary (date, model_version, symbols_count, mae_dollar, mae_pct, directional_accuracy, spearman_corr) |
| VALUES (%s, %s, %s, %s, %s, %s, %s) |
| ON CONFLICT DO NOTHING; |
| """, (target_date, model_version, len(evals_to_insert), mae_dollar, mae_pct, dir_acc, spearman)) |
| |
| print(f"✅ Evaluated {len(evals_to_insert)} symbols. MAE: {mae_pct:.2%}, Accuracy: {dir_acc:.2%}") |
|
|
|
|
| |
| |
| |
| def main(): |
|
|
| torch.set_float32_matmul_precision('high') |
| torch.backends.cuda.matmul.allow_tf32 = True |
|
|
| print("Start LCS (Local Context Scaling)") |
| DATA_DIR = "raw_data" |
| T = 64 |
| H = 1 |
| epochs= 12 |
| batch_size=16 |
| lambda_price=10000 |
|
|
| |
| |
| freeze_encoder_setting = True |
| lr = 1e-4 if freeze_encoder_setting else 1e-5 |
| |
|
|
|
|
| print("Load multi-asset windows") |
| (train_prices, train_cov, train_labels, train_scales, |
| val_prices, val_cov, val_labels, val_scales, |
| test_prices, test_cov, test_labels, test_scales) = load_multi_asset_time_splits(DATA_DIR, T=T, H=H) |
|
|
| print("Tokenizer (LCS: scaled prices in [0.0, 5.0])") |
| tokenizer = SimpleChronosTokenizer(num_bins=1024, min_val=0.0, max_val=5.0) |
|
|
| |
| def prepare_dataset_tensors(prices, future_prices, scales, tokenizer_obj): |
| """ |
| LCS Upgrade: Encodes windows as scaled prices, not log-returns. |
| scale = mean(abs(history)) per window. |
| input_ids = tokenize(history / scale) |
| label_ids = tokenize(future / scale) |
| """ |
| |
| valid_mask = (np.min(prices, axis=1) > 0) & (np.min(future_prices, axis=1) > 0) |
|
|
| if not np.all(valid_mask): |
| drop_count = np.sum(~valid_mask) |
| print(f"⚠️ Dropping {drop_count}/{len(prices)} windows with non-positive prices.") |
| prices = prices[valid_mask] |
| future_prices = future_prices[valid_mask] |
| scales = scales[valid_mask] |
|
|
| if len(prices) == 0: |
| raise ValueError("All data dropped due to non-positive prices.") |
|
|
| |
| scaled_input = prices / scales[:, None] |
| scaled_future = future_prices / scales[:, None] |
|
|
| |
| input_ids = np.stack([tokenizer_obj.encode(row) for row in scaled_input]) |
| label_ids = np.stack([tokenizer_obj.encode(row) for row in scaled_future]) |
|
|
| |
| attention_mask = (input_ids != tokenizer_obj.pad_token_id).astype(np.int64) |
|
|
| return input_ids, label_ids, attention_mask, scales, prices, future_prices, valid_mask |
|
|
| tr_in, tr_lb, tr_mk, tr_sc, tr_p_safe, tr_l_safe, tr_valid = prepare_dataset_tensors(train_prices, train_labels, train_scales, tokenizer) |
| va_in, va_lb, va_mk, va_sc, va_p_safe, va_l_safe, va_valid = prepare_dataset_tensors(val_prices, val_labels, val_scales, tokenizer) |
|
|
| |
| train_cov = train_cov[tr_valid] |
| val_cov = val_cov[va_valid] |
|
|
| |
| vocab_start = tokenizer.vocab_offset |
| vocab_end = vocab_start + tokenizer.num_bins |
|
|
| assert tr_lb.min() >= vocab_start and tr_lb.max() < vocab_end, "Train labels contain invalid tokens" |
| assert va_lb.min() >= vocab_start and va_lb.max() < vocab_end, "Val labels contain invalid tokens" |
|
|
| for special in [tokenizer.pad_token_id, tokenizer.bos_token_id, tokenizer.eos_token_id]: |
| assert not np.any(tr_lb == special), f"Train labels contain special token {special}" |
| assert not np.any(va_lb == special), f"Val labels contain special token {special}" |
|
|
| print("Normalizer") |
| normalizer = ZScoreNormalizer() |
| normalizer.fit(torch.FloatTensor(train_cov)) |
|
|
| config = TrainingConfig( |
| lr=lr, epochs=epochs, batch_size=batch_size, |
| save_dir="./checkpoints_multi", lambda_price=lambda_price, |
| freeze_encoder=freeze_encoder_setting, |
| mixed_precision= False |
| ) |
|
|
| |
| train_dataset = TimeSeriesDataset( |
| prices=tr_p_safe, covariates=train_cov, input_ids=tr_in, |
| labels=tr_lb, future_prices=tr_l_safe, scale=tr_sc, |
| attention_mask=tr_mk, pad_token_id=tokenizer.pad_token_id, |
| ) |
| val_dataset = TimeSeriesDataset( |
| prices=va_p_safe, covariates=val_cov, input_ids=va_in, |
| labels=va_lb, future_prices=va_l_safe, scale=va_sc, |
| attention_mask=va_mk, pad_token_id=tokenizer.pad_token_id, |
| ) |
|
|
| train_loader = DataLoader(train_dataset, batch_size=config.batch_size, shuffle=True) |
| val_loader = DataLoader(val_dataset, batch_size=config.batch_size) |
|
|
| print("Load chronos-T5") |
| model_name = "amazon/chronos-t5-base" |
| t5_model = AutoModelForSeq2SeqLM.from_pretrained(model_name) |
| t5_model.resize_token_embeddings(tokenizer.vocab_size) |
|
|
| |
| t5_model.config.vocab_size = tokenizer.vocab_size |
| t5_model.config.pad_token_id = tokenizer.pad_token_id |
| t5_model.config.eos_token_id = tokenizer.eos_token_id |
| t5_model.config.bos_token_id = tokenizer.bos_token_id |
| t5_model.config.vocab_offset = tokenizer.vocab_offset |
| t5_model.config.num_bins = tokenizer.num_bins |
|
|
| model = ChronosDualPath( |
| t5_model=t5_model, |
| num_features=train_cov.shape[2], |
| freeze_encoder=config.freeze_encoder |
| ) |
|
|
| model.adapter = torch.compile(model.adapter, dynamic=False) |
|
|
| trainer = CGCXTrainer( |
| model=model, config=config, normalizer=normalizer, |
| train_loader=train_loader, val_loader=val_loader, |
| tokenizer=tokenizer, mase_scale=1.0, |
| cov_cols=COV_COLS |
| ) |
|
|
| trainer.train() |
|
|
| print("\n🔄 RELOADING Best Model...") |
| reloaded_model, reloaded_tokenizer, reloaded_normalizer, reloaded_cov_cols = load_checkpoint_with_config( |
| config.save_dir, device=config.device |
| ) |
|
|
| run_test_pipeline( |
| data_dir=DATA_DIR, |
| model=reloaded_model, |
| normalizer=reloaded_normalizer, |
| tokenizer=reloaded_tokenizer, |
| cov_cols=reloaded_cov_cols, |
| T=T, H=H, num_symbols=5 |
| ) |
|
|
|
|
| |
| def evaluate_and_plot_symbol(symbol: str, checkpoint_dir: str, data_dir: str, T=64, H=1): |
| """ |
| Loads the BEST model checkpoint and visualizes predictions for a specific symbol. |
| """ |
| print(f"\n🎨 VISUALIZATION: Analyzing {symbol}...") |
|
|
| |
| |
| try: |
| model, tokenizer, normalizer, cov_cols = load_checkpoint_with_config( |
| checkpoint_dir, device="cuda" if torch.cuda.is_available() else "cpu" |
| ) |
| except FileNotFoundError: |
| print("❌ Checkpoint not found. Skipping visualization.") |
| return |
|
|
| infer = CGCXInference(model, normalizer, tokenizer) |
|
|
| |
| file_path = os.path.join(data_dir, f"{symbol}.csv") |
| if not os.path.exists(file_path): |
| print(f"❌ Data file for {symbol} not found.") |
| return |
|
|
| |
| df = pd.read_csv(file_path, parse_dates=["Date"]).sort_values("Date").set_index("Date") |
| df = df.resample("B").asfreq().ffill().dropna() |
| df["price"] = df["Close"] |
| df = add_technical_indicators(df).dropna() |
|
|
| |
| VAL_END_DATE = pd.Timestamp("2023-12-31") |
| df_test = df.loc[VAL_END_DATE + pd.Timedelta(days=1):] |
|
|
| if len(df_test) < T + H: |
| print("❌ Not enough data for testing this symbol.") |
| return |
|
|
| |
| result = build_windows_from_df(df_test, T, H, columns=cov_cols) |
| prices, cov, labels, scales = result[0], result[1], result[2], result[3] |
| if len(prices) == 0: return |
|
|
| |
| local_scales = np.mean(np.abs(prices), axis=1, keepdims=True) |
| local_scales = np.clip(local_scales, 1e-8, None) |
| scaled_input = prices / local_scales |
|
|
| input_ids = np.stack([tokenizer.encode(row) for row in scaled_input]) |
|
|
| |
| input_ids_t = torch.LongTensor(input_ids) |
| price_t = torch.FloatTensor(prices) |
| cov_t = torch.FloatTensor(cov) |
| mask_t = (input_ids_t != tokenizer.pad_token_id).long() |
|
|
| |
| print(f" Generating predictions for {len(prices)} windows (Median of 20 samples)...") |
|
|
| |
| with torch.no_grad(): |
| preds = infer.predict( |
| input_ids=input_ids_t, |
| price=price_t, |
| covariates=cov_t, |
| attention_mask=mask_t, |
| num_samples=20, |
| temperature=1.0, |
| max_length=H |
| ) |
|
|
| |
| metrics = calculate_metrics(labels.flatten(), preds.flatten()) |
| print(f" 📊 RMSE: {metrics.get('RMSE', 'N/A'):.4f}") |
| print(f" 📊 R2: {metrics.get('R2', 'N/A'):.4f}") |
|
|
| |
| |
| true_curve = labels.flatten() |
| pred_curve = preds.flatten() |
|
|
| plt.figure(figsize=(14, 7)) |
|
|
| x_axis = np.arange(len(true_curve)) |
|
|
| |
| plt.plot(x_axis, true_curve, label='True Price (Entire Test Set)', color='black', linewidth=2) |
|
|
| |
| plt.plot(x_axis, pred_curve, label='Model 1-Day Predictions', color='red', linestyle='--', linewidth=1.5, alpha=0.8) |
|
|
| plt.title(f"Full Test Set Evaluation: {symbol} ({len(true_curve)} Trading Days)") |
| plt.xlabel("Days in Test Set") |
| plt.ylabel("Absolute Price ($)") |
| plt.legend() |
| plt.grid(True, alpha=0.3) |
|
|
| |
| save_path = f"prediction_{symbol}_FULL_YEAR.png" |
| plt.savefig(save_path) |
| print(f" 📸 Full timeline plot saved to {save_path}") |
| plt.close() |
| |
|
|
|
|
| |
| |
| |
| def fetch_latest_data(symbol: str, save_dir: str = "daily_test"): |
| if not os.path.exists(save_dir): |
| os.makedirs(save_dir) |
| ticker = yf.Ticker(symbol) |
| df = ticker.history(period="150d") |
| if df.empty: |
| raise ValueError(f"Could not find data for symbol: {symbol}") |
| df = df.reset_index() |
| file_path = os.path.join(save_dir, f"{symbol}_latest.csv") |
| df.to_csv(file_path, index=False) |
| return file_path |
|
|
| def predict_tomorrow(csv_path: str, infer, tokenizer, cov_cols, T: int = 64): |
| device = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| |
|
|
| df = pd.read_csv(csv_path) |
| date_col = "Date" if "Date" in df.columns else df.columns[0] |
| df[date_col] = pd.to_datetime(df[date_col], utc=True).dt.tz_localize(None).dt.normalize() |
| df = df.sort_values(date_col).set_index(date_col) |
| df = df.resample("B").asfreq().ffill().dropna() |
|
|
| if "Close" in df.columns: df["price"] = df["Close"] |
| elif "close" in df.columns: df["price"] = df["close"] |
| else: return None |
|
|
| df = add_technical_indicators(df).dropna() |
| df_window = df.iloc[-T:] |
|
|
| prices_np = df_window["price"].values.astype("float32") |
| cov_np = df_window[cov_cols].values.astype("float32") |
| local_scale = np.mean(np.abs(prices_np)) |
| scaled_prices = prices_np / local_scale |
|
|
| input_ids = tokenizer.encode(scaled_prices) |
| input_ids_t = torch.LongTensor(input_ids).unsqueeze(0).to(device) |
| price_t = torch.FloatTensor(prices_np).unsqueeze(0).to(device) |
| cov_t = torch.FloatTensor(cov_np).unsqueeze(0).to(device) |
| mask_t = (input_ids_t != tokenizer.pad_token_id).long().to(device) |
|
|
| with torch.no_grad(): |
| pred_prices = infer.predict( |
| input_ids=input_ids_t, price=price_t, covariates=cov_t, |
| attention_mask=mask_t, num_samples=20, max_length=1, temperature=1.0 |
| ) |
|
|
| return float(pred_prices[0, 0]) |
|
|
|
|
| |
| |
| image = modal.Image.debian_slim().pip_install( |
| "torch", |
| "pandas", |
| "numpy", |
| "yfinance", |
| "requests", |
| "lxml", |
| "psycopg2-binary", |
| "transformers", |
| "scikit-learn", |
| "matplotlib" |
| ) |
|
|
| app = modal.App("daily-ml-job") |
| volume = modal.Volume.from_name("model-volume") |
|
|
| @app.function( |
| image=image, |
| volumes={"/data": volume}, |
| schedule=modal.Cron("0 11 * * 1-5"), |
| gpu="T4", |
| secrets=[modal.Secret.from_name("neon-db-secret")], |
| timeout=1800 |
| ) |
| def run_daily_inference(): |
| import yfinance as yf |
| import pandas as pd |
| import numpy as np |
| import torch |
| import requests |
| import io |
| import psycopg2 |
| from datetime import datetime, timezone |
| print(f"⏰ SERVER WAKE-UP TIME (UTC): {datetime.now(timezone.utc)}") |
| |
| |
| |
| |
| |
|
|
| |
| HEADERS = {"User-Agent": "Mozilla/5.0"} |
|
|
| def get_wiki_tickers(url, table_keywords): |
| tickers = [] |
| try: |
| response = requests.get(url, headers=HEADERS) |
| response.raise_for_status() |
| tables = pd.read_html(io.StringIO(response.text)) |
| target_table = next((t for t in tables if any(k in [c.lower() for c in t.columns] for k in table_keywords)), None) |
| if target_table is not None: |
| col_name = next(c for c in target_table.columns if c.lower() in table_keywords) |
| tickers = target_table[col_name].tolist() |
| except Exception as e: |
| print(f" -> Error scraping {url}: {e}") |
| return [str(t).replace('.', '-') for t in tickers if isinstance(t, str)] |
|
|
| def get_all_nasdaq_tickers(): |
| try: |
| url = "ftp://ftp.nasdaqtrader.com/symboldirectory/nasdaqtraded.txt" |
| df = pd.read_csv(url, sep='|') |
| df = df[(df['Test Issue'] == 'N') & (df['NASDAQ Symbol'].notnull())] |
| return [str(t).replace('.', '-') for t in df['Symbol'].dropna().tolist()] |
| except Exception: |
| return get_wiki_tickers("https://en.wikipedia.org/wiki/Nasdaq-100", ['ticker', 'symbol']) |
|
|
| def fetch_market_caps(tickers): |
| caps = {} |
| for i, ticker in enumerate(tickers): |
| try: |
| mc = yf.Ticker(ticker).fast_info.get('market_cap') |
| if mc: caps[ticker] = mc |
| except: continue |
| return caps |
|
|
| def fetch_latest_data(symbol: str, save_dir: str = "/tmp/daily_test"): |
| |
| if not os.path.exists(save_dir): |
| os.makedirs(save_dir) |
| ticker = yf.Ticker(symbol) |
|
|
| df = ticker.history(period="150d") |
| if df.empty: |
| raise ValueError(f"Could not find data for symbol: {symbol}") |
| df = df.reset_index() |
|
|
| |
| df["Date"] = pd.to_datetime(df["Date"]).dt.tz_localize(None).dt.date |
| server_today = datetime.utcnow().date() |
| df = df[df["Date"] < server_today] |
| if df.empty: |
| raise ValueError(f"No historical data for {symbol} after firewall cut.") |
|
|
| file_path = os.path.join(save_dir, f"{symbol}_latest.csv") |
| df.to_csv(file_path, index=False) |
| return file_path |
|
|
| |
| |
| |
| |
| |
| print("Connecting to Neon Database...") |
| conn = psycopg2.connect(os.environ["DATABASE_URL"]) |
| cursor = conn.cursor() |
|
|
| |
| def get_target_symbols_from_db(): |
| print("--- 1. Reading Golden Symbol List from Volume ---") |
| |
| target_symbols_path = "/data/my_model_dir/symbols.txt" |
| |
| with open(target_symbols_path, "r") as f: |
| pooled_400 = set(line.strip().upper() for line in f if line.strip()) |
|
|
| print(f"Loaded {len(pooled_400)} target symbols from file.") |
|
|
| print("--- 2. Checking Neon Database for existing predictions ---") |
| |
| cursor.execute("SELECT DISTINCT entity_id FROM predictions") |
| downloaded_symbols = {row[0] for row in cursor.fetchall()} |
|
|
| |
| existing_list = list(downloaded_symbols.intersection(pooled_400)) |
| remaining_pool = pooled_400 - downloaded_symbols |
| new_list = list(remaining_pool) |
|
|
| return existing_list, new_list |
|
|
| |
| |
| |
| run_id = str(uuid.uuid4()) |
| today = datetime.now().date() |
| model_version = "v1.0" |
| |
| existing_symbols, new_symbols = get_target_symbols_from_db() |
| print(f"\nTargeting {len(existing_symbols)} existing symbols and {len(new_symbols)} new symbols.") |
|
|
| |
| cursor.close() |
| conn.close() |
| print("Closed initial DB connection. Starting ML heavy lifting...") |
|
|
| print("\n⚙️ Loading neural network model into memory...") |
|
|
| print("\n⚙️ Loading neural network model into memory...") |
| device = "cuda" if torch.cuda.is_available() else "cpu" |
| |
| checkpoint_dir = "/data/my_model_dir" |
| |
| model, tokenizer, normalizer, cov_cols = load_checkpoint_with_config(checkpoint_dir, device=device) |
| infer = CGCXInference(model, normalizer, tokenizer, device=device) |
| print("✅ Model loaded successfully!") |
|
|
| all_predictions = [] |
|
|
| |
| for sym in existing_symbols + new_symbols: |
| try: |
| path = fetch_latest_data(sym, save_dir="/tmp/daily_test") |
| price_pred = predict_tomorrow(path, infer, tokenizer, cov_cols) |
| if price_pred: |
| |
| all_predictions.append((run_id, today, sym, price_pred, model_version)) |
| print(f"{sym}: ${price_pred:.2f}") |
| except Exception as e: |
| print(f"Skipping {sym} - Error: {e}") |
|
|
| |
| |
| print(f"\n💾 Saving {len(all_predictions)} predictions to Neon Database...") |
| if all_predictions: |
| print("Waking up Neon Database for saving...") |
| conn = psycopg2.connect(os.environ["DATABASE_URL"]) |
| cursor = conn.cursor() |
|
|
| insert_query = """ |
| INSERT INTO predictions (run_id, date, entity_id, predicted_value, model_version) |
| VALUES (%s, %s, %s, %s, %s) |
| ON CONFLICT (date, entity_id, model_version) DO NOTHING; |
| """ |
| cursor.executemany(insert_query, all_predictions) |
|
|
| |
| print("Fetching actual closing prices for evaluation...") |
| all_syms = existing_symbols + new_symbols |
| try: |
| |
| raw_actuals = yf.download(all_syms, period="10d")["Close"] |
| actuals_df = pd.DataFrame(raw_actuals) |
| actuals_df.index = pd.to_datetime(actuals_df.index).tz_localize(None).date |
| server_today = datetime.utcnow().date() |
| actuals_df = actuals_df[actuals_df.index < server_today] |
| actuals_df = actuals_df.dropna(how='all') |
| |
| |
| run_evaluations(cursor, current_date=today, actuals_df=actuals_df) |
| except Exception as e: |
| print(f"⚠️ Warning: Evaluations failed, but predictions are safe. Error: {e}") |
| |
| |
| conn.commit() |
| print("✅ Database commit successful. Pipeline complete.") |
| |
| cursor.close() |
| conn.close() |
| print("✅ Database save complete!") |
| else: |
| print("⚠️ No predictions generated today.") |
|
|
| @app.local_entrypoint() |
| def main(): |
| run_daily_inference.remote() |