""" Kronos Foundation Model Adapter for Predix. Wraps the Kronos-mini OHLCV foundation model (4.1M params, AAAI 2026, MIT) for use as: - Factor (Option A): predicted next-day return signal - Model alongside LightGBM (Option B): IC/Sharpe evaluation Kronos repo: https://github.com/shiyu-coder/Kronos HuggingFace: NeoQuasar/Kronos-mini | NeoQuasar/Kronos-Tokenizer-2k """ from __future__ import annotations import sys from pathlib import Path from typing import Optional import numpy as np import pandas as pd import logging logger = logging.getLogger(__name__) def _cuda_available() -> bool: try: import torch return torch.cuda.is_available() except ImportError: return False KRONOS_REPO = Path.home() / "Kronos" _KRONOS_AVAILABLE: Optional[bool] = None def _ensure_kronos() -> bool: global _KRONOS_AVAILABLE if _KRONOS_AVAILABLE is not None: return _KRONOS_AVAILABLE if not KRONOS_REPO.exists(): logger.warning(f"Kronos repo not found at {KRONOS_REPO}. Clone with: git clone https://github.com/shiyu-coder/Kronos ~/Kronos") _KRONOS_AVAILABLE = False return False repo_str = str(KRONOS_REPO) if repo_str not in sys.path: sys.path.insert(0, repo_str) try: import model as _ # noqa: F401 _KRONOS_AVAILABLE = True except ImportError as e: logger.warning(f"Failed to import Kronos model: {e}") _KRONOS_AVAILABLE = False return _KRONOS_AVAILABLE def _ohlcv_from_predix(df: pd.DataFrame) -> pd.DataFrame: """Convert Predix HDF5 format ($open/$close/...) to Kronos format (open/close/...).""" col_map = {"$open": "open", "$high": "high", "$low": "low", "$close": "close", "$volume": "volume"} renamed = df.rename(columns=col_map) cols = [c for c in ["open", "high", "low", "close", "volume"] if c in renamed.columns] return renamed[cols].astype(float) def _build_window_inputs( ohlcv_df: pd.DataFrame, pred_bars: int, freq: str, ) -> tuple[pd.DataFrame, pd.Series, pd.Series]: """Prepare (ctx_df, x_timestamp, y_timestamp) for one Kronos window.""" last_ts = ohlcv_df.index[-1] future_idx = pd.date_range(start=last_ts, periods=pred_bars + 1, freq=freq)[1:] x_timestamp = pd.Series(ohlcv_df.index.values) y_timestamp = pd.Series(future_idx) ctx = ohlcv_df.copy().reset_index(drop=True) return ctx, x_timestamp, y_timestamp class KronosAdapter: """ Loads Kronos-mini once and provides rolling-window OHLCV inference. Usage: adapter = KronosAdapter(device="cuda") adapter.load() pred_return = adapter.predict_return(ohlcv_df, context_bars=512, pred_bars=96) """ MODEL_ID = "NeoQuasar/Kronos-mini" TOKENIZER_ID = "NeoQuasar/Kronos-Tokenizer-2k" def __init__(self, device: Optional[str] = None, max_context: int = 512): self.device = device or ("cuda" if _cuda_available() else "cpu") self.max_context = max_context self._predictor = None def load(self) -> "KronosAdapter": if self._predictor is not None: return self if not _ensure_kronos(): raise RuntimeError("Kronos not available — see warning above.") from model import Kronos, KronosTokenizer, KronosPredictor # type: ignore logger.info(f"Loading Kronos-mini from HuggingFace ({self.MODEL_ID})...") tokenizer = KronosTokenizer.from_pretrained(self.TOKENIZER_ID) model = Kronos.from_pretrained(self.MODEL_ID) self._predictor = KronosPredictor(model, tokenizer, device=self.device, max_context=self.max_context) logger.info("Kronos-mini loaded.") return self def predict_next_bars( self, ohlcv_df: pd.DataFrame, context_bars: int, pred_bars: int, temperature: float = 1.0, top_p: float = 0.9, ) -> pd.DataFrame: """ Run Kronos on `context_bars` of OHLCV data, returning `pred_bars` predicted bars. Args: ohlcv_df: DataFrame with columns open/high/low/close[/volume], DatetimeIndex. context_bars: Number of history bars to feed as context. pred_bars: Number of future bars to predict. Returns: DataFrame with predicted open/high/low/close/volume, indexed by future timestamps. """ if self._predictor is None: raise RuntimeError("Call .load() first.") if len(ohlcv_df) < context_bars: raise ValueError(f"Need at least {context_bars} bars, got {len(ohlcv_df)}") freq = ohlcv_df.index.freq or pd.infer_freq(ohlcv_df.index[:100]) or "1min" ctx, x_timestamp, y_timestamp = _build_window_inputs(ohlcv_df.iloc[-context_bars:], pred_bars, freq) future_idx = pd.DatetimeIndex(y_timestamp) pred_df = self._predictor.predict( df=ctx, x_timestamp=x_timestamp, y_timestamp=y_timestamp, pred_len=pred_bars, T=temperature, top_p=top_p, sample_count=1, verbose=False, ) pred_df.index = future_idx return pred_df def predict_next_bars_batch( self, ohlcv_windows: list, pred_bars: int, temperature: float = 1.0, top_p: float = 0.9, ) -> list: """ Batch inference: run Kronos on multiple context windows simultaneously. All windows must have the same number of bars. Processing them together saturates the GPU and is typically 5-20x faster than sequential calls. Args: ohlcv_windows: List of OHLCV DataFrames, each with a DatetimeIndex. pred_bars: Number of future bars to predict per window. Returns: List of prediction DataFrames (one per input window), same order. """ if self._predictor is None: raise RuntimeError("Call .load() first.") if not ohlcv_windows: return [] freq = ohlcv_windows[0].index.freq or pd.infer_freq(ohlcv_windows[0].index[:100]) or "1min" df_list, x_ts_list, y_ts_list, future_idxs = [], [], [], [] for win in ohlcv_windows: ctx, x_ts, y_ts = _build_window_inputs(win, pred_bars, freq) df_list.append(ctx) x_ts_list.append(x_ts) y_ts_list.append(y_ts) future_idxs.append(pd.DatetimeIndex(y_ts)) pred_dfs = self._predictor.predict_batch( df_list=df_list, x_timestamp_list=x_ts_list, y_timestamp_list=y_ts_list, pred_len=pred_bars, T=temperature, top_p=top_p, sample_count=1, verbose=False, ) for pred_df, future_idx in zip(pred_dfs, future_idxs): pred_df.index = future_idx return pred_dfs def predict_return( self, ohlcv_df: pd.DataFrame, context_bars: int = 512, pred_bars: int = 1, ) -> float: """ Predict the average return over the next `pred_bars` using the last `context_bars`. Returns the predicted log-return (predicted_close / last_close - 1). """ pred = self.predict_next_bars(ohlcv_df, context_bars=context_bars, pred_bars=pred_bars) last_close = float(ohlcv_df["close"].iloc[-1]) pred_close = float(pred["close"].iloc[-1]) return pred_close / last_close - 1.0 def build_kronos_factor( hdf5_path, context_bars: int = 512, pred_bars: int = 96, stride_bars: int = 96, device: Optional[str] = None, batch_size: int = 32, ) -> pd.DataFrame: """ Generate the Kronos predicted-return factor for all EUR/USD 1-min bars. Strategy: Every `stride_bars` bars, run Kronos on the previous `context_bars` and predict the next `pred_bars`. Windows are processed in GPU batches of `batch_size` for full GPU utilization. The predicted log-return is forward-filled across the predicted window. Returns: MultiIndex (datetime, instrument) DataFrame with column "KronosPredReturn". """ device = device or ("cuda" if _cuda_available() else "cpu") logger.info(f"Loading data from {hdf5_path}...") raw = pd.read_hdf(hdf5_path, key="data") instrument = raw.index.get_level_values("instrument").unique()[0] df = raw.xs(instrument, level="instrument") ohlcv = _ohlcv_from_predix(df) adapter = KronosAdapter(device=device, max_context=min(context_bars, 512)) adapter.load() bar_indices = list(range(context_bars, len(ohlcv), stride_bars)) n_windows = len(bar_indices) logger.info( f"Running Kronos batch inference: {n_windows} windows " f"(batch={batch_size}, stride={stride_bars}, ctx={context_bars}, pred={pred_bars}, device={device})" ) factor_values: dict = {} for batch_start in range(0, n_windows, batch_size): batch_idx = bar_indices[batch_start : batch_start + batch_size] windows = [ohlcv.iloc[i - context_bars : i] for i in batch_idx] last_closes = [float(ohlcv["close"].iloc[i - 1]) for i in batch_idx] try: pred_dfs = adapter.predict_next_bars_batch(windows, pred_bars=pred_bars) for pred_df, last_close in zip(pred_dfs, last_closes): for ts, row in pred_df.iterrows(): factor_values[ts] = float(row["close"]) / last_close - 1.0 except Exception as e: logger.warning(f"Batch {batch_start // batch_size + 1} failed ({e}), retrying individually...") for bar_idx, win, last_close in zip(batch_idx, windows, last_closes): try: pred = adapter.predict_next_bars(win, context_bars=context_bars, pred_bars=pred_bars) for ts, row in pred.iterrows(): factor_values[ts] = float(row["close"]) / last_close - 1.0 except Exception as e2: logger.warning(f" Single inference failed at bar {bar_idx}: {e2}") done = min(batch_start + batch_size, n_windows) if done % max(batch_size, 100) < batch_size or done == n_windows: logger.info(f" {done}/{n_windows} windows done") if not factor_values: raise RuntimeError("No Kronos predictions were generated.") factor_series = pd.Series(factor_values, name="KronosPredReturn") factor_series = factor_series.reindex(ohlcv.index, method="ffill") result = factor_series.to_frame() result.index = pd.MultiIndex.from_arrays( [ohlcv.index, [instrument] * len(ohlcv)], names=["datetime", "instrument"], ) logger.info(f"Kronos factor built: {len(result)} bars, {result['KronosPredReturn'].notna().sum()} non-NaN") return result def evaluate_kronos_model( hdf5_path, context_bars: int = 512, pred_bars: int = 30, stride_bars: int = 30, device: Optional[str] = None, batch_size: int = 32, ) -> dict: """ Evaluate Kronos as a standalone model (Option B, alongside LightGBM). Computes IC (Information Coefficient) between Kronos predicted returns and actual realized returns on the test set. Returns: dict with keys: IC_mean, IC_std, IC_IR (IC / std), hit_rate, n_predictions """ device = device or ("cuda" if _cuda_available() else "cpu") raw = pd.read_hdf(hdf5_path, key="data") instrument = raw.index.get_level_values("instrument").unique()[0] df = raw.xs(instrument, level="instrument") ohlcv = _ohlcv_from_predix(df) adapter = KronosAdapter(device=device, max_context=min(context_bars, 512)) adapter.load() n = len(ohlcv) bar_indices = list(range(context_bars, n - pred_bars, stride_bars)) logger.info( f"Evaluating Kronos: {len(bar_indices)} windows " f"(batch={batch_size}, ctx={context_bars}, pred={pred_bars}, device={device})" ) predicted_returns = [] actual_returns = [] for batch_start in range(0, len(bar_indices), batch_size): batch_idx = bar_indices[batch_start : batch_start + batch_size] windows = [ohlcv.iloc[i - context_bars : i] for i in batch_idx] last_closes = [float(ohlcv["close"].iloc[i - 1]) for i in batch_idx] actuals = [ float(ohlcv["close"].iloc[i + pred_bars - 1]) / float(ohlcv["close"].iloc[i - 1]) - 1.0 for i in batch_idx ] try: pred_dfs = adapter.predict_next_bars_batch(windows, pred_bars=pred_bars) for pred_df, last_close, actual_ret in zip(pred_dfs, last_closes, actuals): pred_ret = float(pred_df["close"].iloc[-1]) / last_close - 1.0 predicted_returns.append(pred_ret) actual_returns.append(actual_ret) except Exception as e: logger.warning(f"Batch {batch_start // batch_size + 1} failed ({e}), retrying individually...") for bar_idx, win, last_close, actual_ret in zip(batch_idx, windows, last_closes, actuals): try: pred = adapter.predict_next_bars(win, context_bars=context_bars, pred_bars=pred_bars) pred_ret = float(pred["close"].iloc[-1]) / last_close - 1.0 predicted_returns.append(pred_ret) actual_returns.append(actual_ret) except Exception: pass pred_arr = np.array(predicted_returns) actual_arr = np.array(actual_returns) ic = np.corrcoef(pred_arr, actual_arr)[0, 1] if len(pred_arr) > 1 else float("nan") ic_std = float( np.std([ np.corrcoef(pred_arr[i : i + 50], actual_arr[i : i + 50])[0, 1] for i in range(0, len(pred_arr) - 50, 10) ]) ) if len(pred_arr) > 60 else float("nan") hit_rate = float(np.mean(np.sign(pred_arr) == np.sign(actual_arr))) return { "IC_mean": float(ic), "IC_std": ic_std, "IC_IR": float(ic / ic_std) if ic_std and ic_std > 0 else float("nan"), "hit_rate": hit_rate, "n_predictions": len(pred_arr), } # BATCH_INFERENCE_v2