Thang6822 commited on
Commit
0fc4a41
Β·
1 Parent(s): 707c123

feat: replace Kronos with Google TimesFM 2.5 (200M) forecaster

Browse files
Files changed (3) hide show
  1. _ai_refactor.py +166 -0
  2. backend/main.py +168 -237
  3. requirements.txt +1 -1
_ai_refactor.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ path = r'd:\Python\Kronos_Platform_V1\frontend\index.html'
3
+ with open(path, 'r', encoding='utf-8') as f:
4
+ content = f.read()
5
+
6
+ # 1. Add fetchPaneAI function (or modify fetchAIAnalysis)
7
+ # Actually, let's just append fetchPaneAI function before fetchAIAnalysis
8
+ # and update the UI binding.
9
+
10
+ new_fetch_code = """
11
+ // --- MULTI-PANE AI FETCH ---
12
+ async function fetchPaneAI(pane, options = {}) {
13
+ const symbol = pane.symbol;
14
+ const interval = pane.interval;
15
+ const horizon = pane.horizon || 24;
16
+ const requestKey = `${symbol}|${interval}|${horizon}`;
17
+
18
+ if (!options.force && pane.analysisRequestPromise && pane.analysisRequestKey === requestKey) {
19
+ return pane.analysisRequestPromise;
20
+ }
21
+
22
+ if (pane.analysisRetryTimer) {
23
+ clearTimeout(pane.analysisRetryTimer);
24
+ pane.analysisRetryTimer = null;
25
+ }
26
+ if (pane.analysisFetchController) {
27
+ pane.analysisFetchController.abort();
28
+ }
29
+
30
+ const controller = new AbortController();
31
+ pane.analysisFetchController = controller;
32
+ pane.analysisRequestKey = requestKey;
33
+
34
+ if (pane.paneId === Workspace.activePaneId && !pane.lastAnalysis?.payload) {
35
+ const panel = document.getElementById('analysisPanel');
36
+ panel.innerHTML = `<div class="dash-loading"><div class="loader-ring"></div><p>AI dang tinh toan...</p></div>`;
37
+ }
38
+
39
+ const requestPromise = (async () => {
40
+ try {
41
+ const fData = await DataCoordinator.fetchForecast(symbol, interval, horizon, controller.signal);
42
+ if (pane.symbol !== symbol || pane.interval !== interval) return null;
43
+
44
+ pane.lastAnalysis = { payload: fData, symbol, interval };
45
+ const hasForecast = Array.isArray(fData.forecast) && fData.forecast.length > 0;
46
+
47
+ if (hasForecast && pane.lastCandleData) {
48
+ const forecastPoints = fData.forecast;
49
+ const anchorPoint = { time: pane.lastCandleData.time, value: pane.lastCandleData.close };
50
+ const futurePoints = forecastPoints.filter(d => d && d.time !== undefined && d.p50 !== undefined && d.time !== pane.lastCandleData.time).map(d => ({ time: d.time, value: d.p50 }));
51
+ const p50 = [anchorPoint, ...futurePoints];
52
+ const p10 = [anchorPoint, ...forecastPoints.filter(d => d && d.time !== undefined && d.p10 !== undefined && d.time !== pane.lastCandleData.time).map(d => ({ time: d.time, value: d.p10 }))];
53
+ const p90 = [anchorPoint, ...forecastPoints.filter(d => d && d.time !== undefined && d.p90 !== undefined && d.time !== pane.lastCandleData.time).map(d => ({ time: d.time, value: d.p90 }))];
54
+
55
+ if (pane.forecastSeries) {
56
+ pane.forecastSeries.p50.setData([]);
57
+ pane.forecastSeries.p10.setData(p10);
58
+ pane.forecastSeries.p90.setData(p90);
59
+
60
+ // For segments we need a helper since it's complex, or just ignore segments per-pane to keep it fast
61
+ // Actually we can reuse buildForecastSegmentSeries but pass the pane
62
+ buildPaneForecastSegments(pane, p50);
63
+ }
64
+ }
65
+
66
+ if (pane.paneId === Workspace.activePaneId) {
67
+ renderAnalysisPanel(symbol, interval, fData);
68
+ renderCompactGauges(symbol, interval, fData);
69
+ updateDashboardScale();
70
+ } else {
71
+ // For non-active panes, render gauges into their mini container
72
+ renderPaneGauges(pane, fData);
73
+ }
74
+
75
+ return fData;
76
+ } catch (e) {
77
+ if (e.name === 'AbortError') return null;
78
+ console.error(`[Pane ${pane.paneId}] AI Error:`, e);
79
+ return null;
80
+ } finally {
81
+ if (pane.analysisFetchController === controller) pane.analysisFetchController = null;
82
+ }
83
+ })();
84
+
85
+ pane.analysisRequestPromise = requestPromise;
86
+ try {
87
+ return await requestPromise;
88
+ } finally {
89
+ if (pane.analysisRequestPromise === requestPromise) {
90
+ pane.analysisRequestPromise = null;
91
+ pane.analysisRequestKey = null;
92
+ }
93
+ }
94
+ }
95
+
96
+ function buildPaneForecastSegments(pane, p50) {
97
+ if (!pane.forecastSeries || !pane.forecastSeries.segments) return;
98
+ const sGroup = pane.forecastSeries.segments;
99
+ sGroup.forEach(s => s.setData([]));
100
+ if (p50.length < 2) return;
101
+ for (let i = 0; i < p50.length - 1; i++) {
102
+ if (i >= sGroup.length) {
103
+ const ns = pane.chartInstance.addLineSeries({
104
+ color: 'rgba(34,211,238,0.8)', lineWidth: 2, lineStyle: 0,
105
+ crosshairMarkerVisible: false, lastValueVisible: false, priceLineVisible: false
106
+ });
107
+ sGroup.push(ns);
108
+ }
109
+ const pA = p50[i], pB = p50[i+1];
110
+ const clr = pB.value >= pA.value ? 'rgba(34,211,238,0.8)' : 'rgba(251,113,133,0.8)';
111
+ sGroup[i].applyOptions({ color: clr });
112
+ sGroup[i].setData([pA, pB]);
113
+ }
114
+ }
115
+
116
+ function renderPaneGauges(pane, payload) {
117
+ if (!pane.gaugesEl) return;
118
+ if (!payload || !payload.analysis) {
119
+ pane.gaugesEl.innerHTML = '';
120
+ return;
121
+ }
122
+ // Build mini gauges
123
+ const a = payload.analysis;
124
+ const tScore = typeof a.trend_score === 'number' ? a.trend_score : 50;
125
+ const sScore = typeof a.strength_score === 'number' ? a.strength_score : 50;
126
+ const vScore = typeof a.volatility_score === 'number' ? a.volatility_score : 50;
127
+
128
+ const cT = tScore > 60 ? '#22d3ee' : (tScore < 40 ? '#fb7185' : '#94a3b8');
129
+ const cS = sScore > 60 ? '#818cf8' : (sScore < 40 ? '#fb7185' : '#94a3b8');
130
+ const cV = vScore > 60 ? '#fb923c' : (vScore < 40 ? '#2dd4bf' : '#94a3b8');
131
+
132
+ pane.gaugesEl.innerHTML = `
133
+ <div style="width:20px;height:20px;border-radius:50%;border:2px solid ${cT};display:flex;align-items:center;justify-content:center;background:var(--bg-depth);">
134
+ <span style="font-size:8px;font-weight:bold;color:${cT}">${tScore > 50 ? '↑' : '↓'}</span>
135
+ </div>
136
+ `;
137
+ }
138
+
139
+ // Replace the active pane listener
140
+ Workspace._onActivePaneChange = (newPaneId) => {
141
+ const pane = Workspace.getPane(newPaneId);
142
+ if (!pane) return;
143
+ // Sync toolbar
144
+ if (currentSymbol !== pane.symbol || timeframeSelect.value !== pane.interval) {
145
+ currentSymbol = pane.symbol;
146
+ searchInput.value = pane.symbol;
147
+ timeframeSelect.value = pane.interval;
148
+ if (window.initSymbolDetails) initSymbolDetails();
149
+ }
150
+ // Update AI Panel
151
+ if (pane.lastAnalysis?.payload) {
152
+ renderAnalysisPanel(pane.symbol, pane.interval, pane.lastAnalysis.payload);
153
+ renderCompactGauges(pane.symbol, pane.interval, pane.lastAnalysis.payload);
154
+ updateDashboardScale();
155
+ } else {
156
+ document.getElementById('analysisPanel').innerHTML = '';
157
+ document.getElementById('chartGauges').innerHTML = '';
158
+ fetchPaneAI(pane);
159
+ }
160
+ };
161
+
162
+ // Hook into loadPaneData
163
+ """
164
+
165
+ with open('_ai_refactor.txt', 'w') as f:
166
+ f.write(new_fetch_code)
backend/main.py CHANGED
@@ -107,7 +107,6 @@ from backend.startup_utils import (
107
  build_source_selftest_urls,
108
  clear_stale_ip_limits,
109
  run_source_selftest,
110
- warmup_kronos,
111
  )
112
  from backend.symbol_utils import (
113
  assemble_market_peer_payload,
@@ -417,35 +416,33 @@ if IS_FROZEN:
417
  else:
418
  logger.info("Running in DEV mode. PROJECT_ROOT: %s", PROJECT_ROOT)
419
 
420
- KRONOS_PATH = os.path.join(CURRENT_DIR, "kronos_core")
421
-
422
- if KRONOS_PATH not in sys.path:
423
- sys.path.append(KRONOS_PATH)
424
 
425
  if TORCH_IMPORT_ERROR:
426
- KRONOS_AVAILABLE = False
427
- logger.error("Torch import error: %s", TORCH_IMPORT_ERROR)
428
- logger.warning("Kronos forecasting disabled because torch is unavailable")
429
  else:
430
  try:
431
- from model.kronos import Kronos, KronosTokenizer, KronosPredictor, calc_time_stamps
432
- KRONOS_AVAILABLE = True
433
- logger.info("Kronos loaded from: %s", KRONOS_PATH)
434
- except Exception as ex:
435
- KRONOS_AVAILABLE = False
436
- logger.error("Kronos load error: %s", ex)
437
- logger.warning("Kronos not found or failed at: %s β€” forecasting disabled", KRONOS_PATH)
438
-
439
- PRELOAD_KRONOS = os.getenv("KRONOS_PRELOAD", "1").strip().lower() not in {"0", "false", "no"}
440
  STARTUP_STATE: Dict[str, Any] = {
441
- "kronos": {
442
- "available": KRONOS_AVAILABLE,
443
- "preload_enabled": PRELOAD_KRONOS,
444
  "warming": False,
445
  "loaded": False,
446
  "device": "not_loaded",
447
- "last_error": TORCH_IMPORT_ERROR if TORCH_IMPORT_ERROR else None,
448
- "path": KRONOS_PATH,
449
  },
450
  "sources": {},
451
  }
@@ -4443,10 +4440,10 @@ async def lifespan(app: FastAPI):
4443
  # Quick source reachability check (non-blocking)
4444
  _start_background_task(_source_selftest(), "source-selftest")
4445
 
4446
- if PRELOAD_KRONOS and KRONOS_AVAILABLE:
4447
- _start_background_task(_warmup_kronos(), "kronos-warmup")
4448
- elif not KRONOS_AVAILABLE:
4449
- STARTUP_STATE["kronos"]["last_error"] = TORCH_IMPORT_ERROR or "Kronos source import failed"
4450
 
4451
  try:
4452
  yield
@@ -4491,12 +4488,28 @@ def market_status_now() -> List[Dict[str, Any]]:
4491
  # ──────────────────────────────────────────────────────────────────────────────
4492
  # Kronos Forecaster (identical to v3, with CLIP_DEFAULT fix retained)
4493
  # ──────────────────────────────────────────────────────────────────────────────
4494
- class KronosForecaster:
4495
- MAX_CONTEXT = 512
4496
- MODEL_NAME = "NeoQuasar/Kronos-base"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4497
 
4498
  def __init__(self) -> None:
4499
- self._predictor: Optional[Any] = None
4500
  self._loaded = False
4501
  self._lock: Optional[asyncio.Lock] = None
4502
  self._predict_lock: Optional[asyncio.Lock] = None
@@ -4517,52 +4530,13 @@ class KronosForecaster:
4517
 
4518
  @property
4519
  def device(self) -> str:
4520
- return str(self._predictor.device) if self._predictor else "not_loaded"
4521
-
4522
- @property
4523
- def _clip(self) -> float:
4524
- if self._predictor is None:
4525
- return CLIP_DEFAULT
4526
- return getattr(self._predictor, "clip", CLIP_DEFAULT)
4527
-
4528
- @staticmethod
4529
- def _collapse_tokenizer_to_single_ohlc4_channel(tokenizer: Any) -> Any:
4530
- """
4531
- Convert the 6-channel public Kronos tokenizer into a true 1-channel
4532
- tokenizer for OHLC4 inference.
4533
-
4534
- The encoder-side projection preserves the previous replicated-OHLC4
4535
- behaviour exactly by summing the O/H/L/C input weights, because the old
4536
- wrapper fed the same OHLC4 value into all four price channels.
4537
-
4538
- The decoder-side projection emits a single OHLC4 channel by averaging
4539
- the original O/H/L/C output heads.
4540
- """
4541
- d_in = int(getattr(tokenizer, "d_in", 0) or 0)
4542
- if d_in == 1:
4543
- return tokenizer
4544
- if d_in != 6:
4545
- raise ValueError(f"Unsupported Kronos tokenizer d_in={d_in}; expected 6 for adapter collapse")
4546
-
4547
- device = tokenizer.embed.weight.device
4548
- dtype = tokenizer.embed.weight.dtype
4549
-
4550
- collapsed_embed = torch.nn.Linear(1, tokenizer.d_model, bias=tokenizer.embed.bias is not None).to(device=device, dtype=dtype)
4551
- collapsed_head = torch.nn.Linear(tokenizer.d_model, 1, bias=tokenizer.head.bias is not None).to(device=device, dtype=dtype)
4552
-
4553
- with torch.no_grad():
4554
- collapsed_embed.weight.copy_(tokenizer.embed.weight[:, :4].sum(dim=1, keepdim=True))
4555
- if tokenizer.embed.bias is not None and collapsed_embed.bias is not None:
4556
- collapsed_embed.bias.copy_(tokenizer.embed.bias)
4557
-
4558
- collapsed_head.weight.copy_(tokenizer.head.weight[:4].mean(dim=0, keepdim=True))
4559
- if tokenizer.head.bias is not None and collapsed_head.bias is not None:
4560
- collapsed_head.bias.copy_(tokenizer.head.bias[:4].mean().reshape(1))
4561
-
4562
- tokenizer.embed = collapsed_embed
4563
- tokenizer.head = collapsed_head
4564
- tokenizer.d_in = 1
4565
- return tokenizer
4566
 
4567
  async def _lazy_load(self) -> None:
4568
  if self._loaded:
@@ -4571,116 +4545,110 @@ class KronosForecaster:
4571
  async with lock:
4572
  if self._loaded:
4573
  return
4574
- if not KRONOS_AVAILABLE:
4575
- raise HTTPException(status_code=500, detail="Kronos not available")
 
 
 
4576
  try:
4577
- device = ("cuda:0" if torch.cuda.is_available()
4578
- else "mps" if (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
4579
- else "cpu")
4580
- logger.info("[Kronos] Loading on %s …", device)
4581
- tokenizer = await asyncio.to_thread(KronosTokenizer.from_pretrained, "NeoQuasar/Kronos-Tokenizer-base")
4582
- tokenizer = self._collapse_tokenizer_to_single_ohlc4_channel(tokenizer)
4583
- model = await asyncio.to_thread(Kronos.from_pretrained, self.MODEL_NAME)
4584
- self._predictor = KronosPredictor(model, tokenizer, device=device, max_context=self.MAX_CONTEXT)
4585
- self._loaded = True
4586
- logger.info("[Kronos] Ready on %s", device)
4587
- except Exception as ex:
4588
- logger.error("[Kronos] Init failed: %s", ex)
4589
- raise HTTPException(status_code=500, detail=f"Kronos init failed: {ex}")
4590
 
4591
- @staticmethod
4592
- def _prepare_feature_frame(df: pd.DataFrame) -> pd.DataFrame:
4593
- """
4594
- Prepare a true 1-channel OHLC4 frame for Kronos inference.
4595
- """
4596
- required_price_cols = ["open", "high", "low", "close"]
4597
- ohlc4 = df[required_price_cols].mean(axis=1).astype(np.float32)
4598
- return pd.DataFrame({"ohlc4": ohlc4}, index=df.index).astype(np.float32)
 
 
 
 
 
 
 
4599
 
4600
- @staticmethod
4601
- def _normalize_feature_matrix(
4602
- x: np.ndarray,
4603
- clip: float,
4604
- ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
4605
- """
4606
- Use the same normalization contract as KronosPredictor.predict():
4607
- std is stabilized by +1e-5 rather than replacing zero-std columns with 1.0.
4608
- """
4609
- x_mean = np.mean(x, axis=0).astype(np.float32)
4610
- x_scale = (np.std(x, axis=0) + 1e-5).astype(np.float32)
4611
- x_norm = np.clip((x - x_mean) / x_scale, -clip, clip).astype(np.float32)
4612
- return x_norm, x_mean, x_scale
4613
-
4614
- async def forecast(self, df: pd.DataFrame, x_timestamp: pd.Series,
4615
- y_timestamp: pd.Series, horizon: int,
4616
- sample_count: int = 10) -> Dict[str, Any]:
 
 
4617
  await self._lazy_load()
4618
- assert self._predictor is not None
4619
  try:
4620
- if not isinstance(x_timestamp, pd.Series):
4621
- x_timestamp = pd.Series(x_timestamp.values if hasattr(x_timestamp, "values") else x_timestamp)
4622
- if not isinstance(y_timestamp, pd.Series):
4623
- y_timestamp = pd.Series(y_timestamp.values if hasattr(y_timestamp, "values") else y_timestamp)
4624
-
4625
- prepared_df = self._prepare_feature_frame(df)
4626
- x = prepared_df[["ohlc4"]].values.astype(np.float32)
4627
-
4628
- x_stamp = calc_time_stamps(x_timestamp).values.astype(np.float32)
4629
- y_stamp = calc_time_stamps(y_timestamp).values.astype(np.float32)
4630
-
4631
- x_norm, x_mean, x_scale = self._normalize_feature_matrix(x, self._clip)
4632
 
4633
- x_norm = x_norm[np.newaxis, :]
4634
- x_stamp = x_stamp[np.newaxis, :]
4635
- y_stamp = y_stamp[np.newaxis, :]
4636
 
4637
  t0 = time.time()
4638
  predict_lock = await self._get_predict_lock()
4639
  async with predict_lock:
4640
- samples = await asyncio.to_thread(
4641
- self._predictor.generate,
4642
- x=x_norm, x_stamp=x_stamp, y_stamp=y_stamp,
4643
- pred_len=horizon, T=1.0, top_k=0, top_p=0.9,
4644
- sample_count=sample_count, verbose=False, return_samples=True,
4645
  )
4646
- logger.info("[Kronos] %.2fs | horizon=%d samples=%d ctx=%d",
4647
- time.time() - t0, horizon, sample_count, len(df))
 
 
 
4648
 
4649
- if "cuda" in self.device:
4650
  torch.cuda.empty_cache()
4651
 
4652
- ohlc4_samples = np.asarray(samples[0, :, :, 0], dtype=float)
4653
- # Some Kronos checkpoints return the full decoded sequence rather than
4654
- # only the requested pred_len. Keep the most recent horizon window so
4655
- # downstream logic always receives a forecast-length vector.
4656
- if ohlc4_samples.shape[1] > horizon:
4657
- ohlc4_samples = ohlc4_samples[:, -horizon:]
4658
- elif ohlc4_samples.shape[1] < horizon:
4659
- pad_width = horizon - ohlc4_samples.shape[1]
4660
- ohlc4_samples = np.pad(ohlc4_samples, ((0, 0), (0, pad_width)), mode="edge")
4661
-
4662
- ohlc4_samples = ohlc4_samples * float(x_scale[0]) + float(x_mean[0])
4663
- p10 = np.percentile(ohlc4_samples, 10, axis=0)
4664
- p50 = np.percentile(ohlc4_samples, 50, axis=0)
4665
- p90 = np.percentile(ohlc4_samples, 90, axis=0)
4666
 
4667
  return {
4668
  "p10": p10,
4669
  "p50": p50,
4670
  "p90": p90,
4671
- "model_name": self.MODEL_NAME,
4672
- "context_length": len(df),
4673
- "output_horizon": int(ohlc4_samples.shape[1]),
4674
  "input_semantics": {
4675
  "feature_channels": ["ohlc4"],
4676
  "active_forecast_channels": ["ohlc4"],
4677
- "ignored_channels": [],
4678
  "price_mode": "ohlc4_single_channel",
4679
  "base_signal": "ohlc4",
4680
  "volume_mode": "omitted",
4681
  "amount_mode": "omitted",
4682
- "adapter_mode": "tokenizer_6ch_to_1ch_ohlc4",
4683
- "normalization": "std_plus_epsilon_1e-5",
4684
  },
4685
  "output_semantics": {
4686
  "forecast_channel": "ohlc4",
@@ -4690,20 +4658,11 @@ class KronosForecaster:
4690
  },
4691
  }
4692
  except Exception as ex:
4693
- logger.error("[Kronos] Forecast failed: %s", ex, exc_info=True)
4694
- raise HTTPException(status_code=500, detail=f"Kronos prediction failed: {ex}")
4695
-
4696
-
4697
- forecaster = KronosForecaster()
4698
 
4699
 
4700
- def _is_kronos_shape_mismatch_error(exc: Exception) -> bool:
4701
- detail = getattr(exc, "detail", exc)
4702
- text = str(detail)
4703
- return (
4704
- "size of tensor" in text
4705
- and "must match" in text
4706
- )
4707
 
4708
 
4709
 
@@ -4914,13 +4873,16 @@ async def _source_selftest():
4914
  )
4915
 
4916
 
4917
- async def _warmup_kronos() -> None:
4918
- """Load Kronos in the background so the first forecast is fast and health is explicit."""
4919
- await warmup_kronos(
4920
- forecaster=forecaster,
4921
- startup_kronos_state=STARTUP_STATE["kronos"],
4922
- logger=logger,
4923
- )
 
 
 
4924
 
4925
 
4926
  # ── Symbol / Interval listing ─────────────────────────────────────────────────
@@ -5242,7 +5204,7 @@ def _forecast_payload_is_current(cached: Optional[Dict[str, Any]]) -> bool:
5242
 
5243
  display = cached.get("display") or {}
5244
  if (
5245
- display.get("mode") != "raw_kronos_ohlc4_line"
5246
  or display.get("output_mode") != "single_future_ohlc4_line"
5247
  or display.get("channels") != ["ohlc4"]
5248
  ):
@@ -5258,7 +5220,7 @@ def _forecast_payload_is_current(cached: Optional[Dict[str, Any]]) -> bool:
5258
  and semantics.get("feature_channels") == ["ohlc4"]
5259
  and semantics.get("price_mode") == "ohlc4_single_channel"
5260
  and semantics.get("base_signal") == "ohlc4"
5261
- and semantics.get("adapter_mode") == "tokenizer_6ch_to_1ch_ohlc4"
5262
  and output_semantics.get("forecast_channel") == "ohlc4"
5263
  and output_semantics.get("forecast_mode") == "single_future_ohlc4_line"
5264
  and output_semantics.get("candle_projection") == "omitted"
@@ -5297,7 +5259,7 @@ async def _prepare_forecast_response_payload(
5297
  refresh=refresh,
5298
  min_context=FORECAST_CONTEXT,
5299
  )
5300
- if not KRONOS_AVAILABLE:
5301
  last_ohlc4 = (
5302
  float(
5303
  np.mean(
@@ -5316,8 +5278,7 @@ async def _prepare_forecast_response_payload(
5316
  "symbol": symbol,
5317
  "interval": interval,
5318
  "forecast_rows": [],
5319
- "error": "AI Forecaster is currently offline or not found in bundle.",
5320
- "path_checked": KRONOS_PATH,
5321
  "ai_runtime": {"mode": "local_only", "model": "offline"},
5322
  "_data_list": data_list,
5323
  "indicators_snapshot": indicators,
@@ -5334,12 +5295,12 @@ async def _prepare_forecast_response_payload(
5334
  "input_semantics": {
5335
  "feature_channels": ["ohlc4"],
5336
  "active_forecast_channels": ["ohlc4"],
5337
- "ignored_channels": [],
5338
  "price_mode": "ohlc4_single_channel",
5339
  "base_signal": "ohlc4",
5340
  "volume_mode": "omitted",
5341
  "amount_mode": "omitted",
5342
- "adapter_mode": "tokenizer_6ch_to_1ch_ohlc4",
5343
  },
5344
  "output_semantics": {
5345
  "forecast_channel": "ohlc4",
@@ -5363,49 +5324,19 @@ async def _prepare_forecast_response_payload(
5363
  else:
5364
  df_hist["amount"] = df_hist["amount"].fillna(0)
5365
 
5366
- context_len = min(len(df_hist), KronosForecaster.MAX_CONTEXT)
 
5367
  df_context = df_hist.tail(context_len).reset_index(drop=True)
5368
 
5369
  logger.info("[forecast] %s %s | ctx=%d/%d | horizon=%d", symbol, interval, context_len, len(df_hist), horizon)
5370
 
5371
  last_time = int(df_hist["time"].iloc[-1])
5372
  step = STEP_SECONDS[interval]
5373
- y_timestamps = pd.Series(pd.to_datetime(
5374
- [last_time + step * (i + 1) for i in range(horizon)], unit="s", utc=True
5375
- ))
5376
-
5377
- sample_count = 10 if forecaster.device in {"not_loaded", "cpu"} else 15
5378
-
5379
- async def _run_model(model_df: pd.DataFrame) -> Dict[str, Any]:
5380
- return await forecaster.forecast(
5381
- df=model_df[["open", "high", "low", "close", "volume", "amount"]],
5382
- x_timestamp=model_df["timestamps"],
5383
- y_timestamp=y_timestamps,
5384
- horizon=horizon,
5385
- sample_count=sample_count,
5386
- )
5387
 
5388
- try:
5389
- model_output = await _run_model(df_context)
5390
- except HTTPException as exc:
5391
- if not _is_kronos_shape_mismatch_error(exc) or context_len <= 504:
5392
- raise
5393
-
5394
- fallback_context_len = min(context_len - 8, 504)
5395
- fallback_context_len = max(fallback_context_len, min(256, context_len))
5396
- if fallback_context_len >= context_len:
5397
- raise
5398
-
5399
- logger.warning(
5400
- "[forecast] %s %s | Kronos shape mismatch at ctx=%d, retrying with ctx=%d",
5401
- symbol,
5402
- interval,
5403
- context_len,
5404
- fallback_context_len,
5405
- )
5406
- context_len = fallback_context_len
5407
- df_context = df_hist.tail(context_len).reset_index(drop=True)
5408
- model_output = await _run_model(df_context)
5409
 
5410
  last_ohlc4 = float(df_hist[["open", "high", "low", "close"]].mean(axis=1).iloc[-1])
5411
  last_close = last_ohlc4
@@ -5438,11 +5369,11 @@ async def _prepare_forecast_response_payload(
5438
  "forecast_rows": forecast_rows,
5439
  "from_persistent_cache": False,
5440
  "model": {
5441
- "name": model_output.get("model_name", "Kronos-base"),
5442
  "context_length": int(model_output.get("context_length", context_len)),
5443
  "quantiles": [0.1, 0.5, 0.9],
5444
  "cache_version": CACHE_VERSION,
5445
- "sample_count": sample_count,
5446
  "input_semantics": model_output.get("input_semantics", {}),
5447
  "output_semantics": model_output.get("output_semantics", {}),
5448
  },
@@ -5453,7 +5384,7 @@ async def _prepare_forecast_response_payload(
5453
  "_cache_origin": cache_origin,
5454
  "ai_runtime": {
5455
  "mode": "local_only",
5456
- "model": str(model_output.get("model_name", "Kronos-base")),
5457
  "device": forecaster.device,
5458
  },
5459
  }
@@ -5501,7 +5432,7 @@ def _build_raw_close_bundle(
5501
  "confidence": round(confidence, 2),
5502
  "model_bias_pct": 0.0,
5503
  "path_metrics": path_metrics,
5504
- "mode": "raw_kronos_ohlc4",
5505
  }
5506
 
5507
 
@@ -5512,12 +5443,12 @@ async def _finalize_forecast_response_payload(payload: Dict[str, Any]) -> Dict[s
5512
  "interval": payload["interval"],
5513
  "forecast": payload.get("forecast_rows", []),
5514
  "error": payload["error"],
5515
- "path_checked": payload.get("path_checked", KRONOS_PATH),
5516
  "display": {
5517
- "mode": "raw_kronos_ohlc4_line",
5518
  "channels": ["ohlc4"],
5519
  "output_mode": "single_future_ohlc4_line",
5520
- "uncertainty_source": "raw_kronos_ohlc4_quantiles",
5521
  "uses_anchor_blending": False,
5522
  },
5523
  "ai_runtime": payload.get("ai_runtime", {"mode": "local_only", "model": "offline"}),
@@ -5547,14 +5478,14 @@ async def _finalize_forecast_response_payload(payload: Dict[str, Any]) -> Dict[s
5547
  "from_persistent_cache": payload.get("from_persistent_cache", False),
5548
  "model": payload["model"],
5549
  "display": {
5550
- "mode": "raw_kronos_ohlc4_line",
5551
  "channels": ["ohlc4"],
5552
  "output_mode": "single_future_ohlc4_line",
5553
- "uncertainty_source": "raw_kronos_ohlc4_quantiles",
5554
  "uses_anchor_blending": False,
5555
  },
5556
  "ensemble": {
5557
- "mode": "raw_kronos_ohlc4",
5558
  "model_weight": analysis_bundle["model_weight"],
5559
  "anchor_weight": analysis_bundle["anchor_weight"],
5560
  "trend_agreement": analysis_bundle["agreement"],
@@ -5722,10 +5653,10 @@ async def cache_stats(request: Request) -> Dict[str, Any]:
5722
  # ── Health ────────────────────────────────────────────────────────────────────
5723
  @app.get("/api/health")
5724
  async def health_check() -> Dict[str, Any]:
5725
- STARTUP_STATE["kronos"]["loaded"] = forecaster.is_ready
5726
- STARTUP_STATE["kronos"]["device"] = forecaster.device
5727
  return {
5728
- "status": "online" if KRONOS_AVAILABLE else "degraded",
5729
  "version": APP_VERSION,
5730
  "model_ready": forecaster.is_ready,
5731
  "device": forecaster.device,
@@ -5736,7 +5667,7 @@ async def health_check() -> Dict[str, Any]:
5736
  "cors_allow_origins": CORS_ALLOW_ORIGINS,
5737
  "admin_auth_configured": ADMIN_TOKEN != DEFAULT_ADMIN_TOKEN,
5738
  "request_metrics": REQUEST_METRICS.snapshot(),
5739
- "kronos": STARTUP_STATE["kronos"],
5740
  "startup_checks": STARTUP_STATE["sources"],
5741
  }
5742
 
@@ -5779,7 +5710,7 @@ async def get_metrics(request: Request):
5779
  },
5780
  "circuit_breakers": cb_stats,
5781
  "sources": STARTUP_STATE["sources"],
5782
- "kronos_status": STARTUP_STATE["kronos"]["loaded"],
5783
  }
5784
 
5785
 
 
107
  build_source_selftest_urls,
108
  clear_stale_ip_limits,
109
  run_source_selftest,
 
110
  )
111
  from backend.symbol_utils import (
112
  assemble_market_peer_payload,
 
416
  else:
417
  logger.info("Running in DEV mode. PROJECT_ROOT: %s", PROJECT_ROOT)
418
 
419
+ # TimesFM 2.5 β€” replaces legacy local Kronos model
420
+ TIMESFM_AVAILABLE = False
421
+ TIMESFM_IMPORT_ERROR: Optional[str] = None
 
422
 
423
  if TORCH_IMPORT_ERROR:
424
+ TIMESFM_IMPORT_ERROR = TORCH_IMPORT_ERROR
425
+ logger.warning("TimesFM disabled because torch is unavailable: %s", TORCH_IMPORT_ERROR)
 
426
  else:
427
  try:
428
+ import timesfm # pip install timesfm[torch]
429
+ TIMESFM_AVAILABLE = True
430
+ logger.info("TimesFM library imported successfully")
431
+ except Exception as _tfm_ex:
432
+ TIMESFM_IMPORT_ERROR = str(_tfm_ex)
433
+ logger.error("TimesFM import error: %s", _tfm_ex)
434
+ logger.warning("TimesFM not installed β€” forecasting disabled. Run: pip install timesfm[torch]")
435
+
436
+ PRELOAD_TIMESFM = os.getenv("TIMESFM_PRELOAD", "1").strip().lower() not in {"0", "false", "no"}
437
  STARTUP_STATE: Dict[str, Any] = {
438
+ "timesfm": {
439
+ "available": TIMESFM_AVAILABLE,
440
+ "preload_enabled": PRELOAD_TIMESFM,
441
  "warming": False,
442
  "loaded": False,
443
  "device": "not_loaded",
444
+ "last_error": TIMESFM_IMPORT_ERROR,
445
+ "model": "google/timesfm-2.5-200m-pytorch",
446
  },
447
  "sources": {},
448
  }
 
4440
  # Quick source reachability check (non-blocking)
4441
  _start_background_task(_source_selftest(), "source-selftest")
4442
 
4443
+ if PRELOAD_TIMESFM and TIMESFM_AVAILABLE:
4444
+ _start_background_task(_warmup_timesfm(), "timesfm-warmup")
4445
+ elif not TIMESFM_AVAILABLE:
4446
+ STARTUP_STATE["timesfm"]["last_error"] = TIMESFM_IMPORT_ERROR or "TimesFM import failed"
4447
 
4448
  try:
4449
  yield
 
4488
  # ──────────────────────────────────────────────────────────────────────────────
4489
  # Kronos Forecaster (identical to v3, with CLIP_DEFAULT fix retained)
4490
  # ──────────────────────────────────────────────────────────────────────────────
4491
+ class TimesFMForecaster:
4492
+ """Async wrapper around Google TimesFM 2.5 (200M, PyTorch).
4493
+
4494
+ External contract identical to the old KronosForecaster / new TimesFMForecaster:
4495
+ await forecaster.forecast(df, horizon=N, ...) -> {
4496
+ p10, p50, p90, # np.ndarray of shape (horizon,)
4497
+ model_name, context_length,
4498
+ input_semantics, output_semantics
4499
+ }
4500
+ Input: OHLCV DataFrame; OHLC4 = (O+H+L+C)/4 computed internally.
4501
+ Output: p10/p50/p90 future OHLC4 values for `horizon` future bars.
4502
+ """
4503
+ MAX_CONTEXT = 15_360 # TimesFM 2.5 supports up to 16 384 ctx
4504
+ MODEL_HF_ID = "google/timesfm-2.5-200m-pytorch"
4505
+ # quantile_forecast tensor indices (batch, horizon, 10)
4506
+ # timesfm returns: [mean, q10, q20, q30, q40, q50, q60, q70, q80, q90]
4507
+ _Q10 = 1
4508
+ _Q50 = 5 # median
4509
+ _Q90 = 9
4510
 
4511
  def __init__(self) -> None:
4512
+ self._model: Optional[Any] = None
4513
  self._loaded = False
4514
  self._lock: Optional[asyncio.Lock] = None
4515
  self._predict_lock: Optional[asyncio.Lock] = None
 
4530
 
4531
  @property
4532
  def device(self) -> str:
4533
+ if self._model is None:
4534
+ return "not_loaded"
4535
+ try:
4536
+ params = list(self._model.parameters())
4537
+ return str(params[0].device) if params else "cpu"
4538
+ except Exception:
4539
+ return "cpu"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4540
 
4541
  async def _lazy_load(self) -> None:
4542
  if self._loaded:
 
4545
  async with lock:
4546
  if self._loaded:
4547
  return
4548
+ if not TIMESFM_AVAILABLE:
4549
+ raise HTTPException(
4550
+ status_code=503,
4551
+ detail="TimesFM not installed. Run: pip install timesfm[torch]",
4552
+ )
4553
  try:
4554
+ device = (
4555
+ "cuda" if torch.cuda.is_available()
4556
+ else "mps" if (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
4557
+ else "cpu"
4558
+ )
4559
+ logger.info("[TimesFM] Loading %s on %s ...", self.MODEL_HF_ID, device)
 
 
 
 
 
 
 
4560
 
4561
+ model = await asyncio.to_thread(
4562
+ timesfm.TimesFM_2p5_200M_torch.from_pretrained,
4563
+ self.MODEL_HF_ID,
4564
+ )
4565
+ # Apply inference config (must run after loading weights)
4566
+ model.compile(
4567
+ timesfm.ForecastConfig(
4568
+ max_context=self.MAX_CONTEXT,
4569
+ normalize_inputs=True,
4570
+ use_continuous_quantile_head=True,
4571
+ force_flip_invariance=True,
4572
+ infer_is_positive=True,
4573
+ fix_quantile_crossing=True,
4574
+ )
4575
+ )
4576
 
4577
+ self._model = model
4578
+ self._loaded = True
4579
+ STARTUP_STATE["timesfm"]["loaded"] = True
4580
+ STARTUP_STATE["timesfm"]["device"] = device
4581
+ logger.info("[TimesFM] Ready on %s", device)
4582
+ except Exception as ex:
4583
+ STARTUP_STATE["timesfm"]["last_error"] = str(ex)
4584
+ logger.error("[TimesFM] Init failed: %s", ex, exc_info=True)
4585
+ raise HTTPException(status_code=500, detail=f"TimesFM init failed: {ex}")
4586
+
4587
+ async def forecast(
4588
+ self,
4589
+ df: pd.DataFrame,
4590
+ horizon: int,
4591
+ # legacy kwargs from callers β€” absorbed and ignored
4592
+ x_timestamp=None,
4593
+ y_timestamp=None,
4594
+ sample_count: int = 10,
4595
+ ) -> Dict[str, Any]:
4596
  await self._lazy_load()
4597
+ assert self._model is not None
4598
  try:
4599
+ # Build single OHLC4 series: (O+H+L+C)/4
4600
+ ohlc4 = (
4601
+ df[["open", "high", "low", "close"]]
4602
+ .mean(axis=1)
4603
+ .astype(np.float32)
4604
+ .values
4605
+ )
 
 
 
 
 
4606
 
4607
+ # Clip to model context limit (take the most-recent N bars)
4608
+ context_len = min(len(ohlc4), self.MAX_CONTEXT)
4609
+ ohlc4_ctx = ohlc4[-context_len:]
4610
 
4611
  t0 = time.time()
4612
  predict_lock = await self._get_predict_lock()
4613
  async with predict_lock:
4614
+ # model.forecast() expects list[np.ndarray]
4615
+ point_forecast, quantile_forecast = await asyncio.to_thread(
4616
+ self._model.forecast,
4617
+ inputs=[ohlc4_ctx],
4618
+ horizon=horizon,
4619
  )
4620
+ elapsed = time.time() - t0
4621
+ logger.info(
4622
+ "[TimesFM] %.2fs | horizon=%d ctx=%d",
4623
+ elapsed, horizon, context_len,
4624
+ )
4625
 
4626
+ if torch is not None and torch.cuda.is_available():
4627
  torch.cuda.empty_cache()
4628
 
4629
+ # point_forecast: (1, horizon)
4630
+ # quantile_forecast: (1, horizon, 10)
4631
+ p10 = quantile_forecast[0, :, self._Q10].astype(float)
4632
+ p50 = quantile_forecast[0, :, self._Q50].astype(float)
4633
+ p90 = quantile_forecast[0, :, self._Q90].astype(float)
 
 
 
 
 
 
 
 
 
4634
 
4635
  return {
4636
  "p10": p10,
4637
  "p50": p50,
4638
  "p90": p90,
4639
+ "model_name": self.MODEL_HF_ID,
4640
+ "context_length": context_len,
4641
+ "output_horizon": horizon,
4642
  "input_semantics": {
4643
  "feature_channels": ["ohlc4"],
4644
  "active_forecast_channels": ["ohlc4"],
4645
+ "ignored_channels": ["volume"],
4646
  "price_mode": "ohlc4_single_channel",
4647
  "base_signal": "ohlc4",
4648
  "volume_mode": "omitted",
4649
  "amount_mode": "omitted",
4650
+ "adapter_mode": "timesfm_native",
4651
+ "normalization": "timesfm_internal",
4652
  },
4653
  "output_semantics": {
4654
  "forecast_channel": "ohlc4",
 
4658
  },
4659
  }
4660
  except Exception as ex:
4661
+ logger.error("[TimesFM] Forecast failed: %s", ex, exc_info=True)
4662
+ raise HTTPException(status_code=500, detail=f"TimesFM prediction failed: {ex}")
 
 
 
4663
 
4664
 
4665
+ forecaster = TimesFMForecaster()
 
 
 
 
 
 
4666
 
4667
 
4668
 
 
4873
  )
4874
 
4875
 
4876
+ async def _warmup_timesfm() -> None:
4877
+ """Load TimesFM in the background so the first forecast is fast."""
4878
+ try:
4879
+ STARTUP_STATE["timesfm"]["warming"] = True
4880
+ await forecaster._lazy_load()
4881
+ STARTUP_STATE["timesfm"]["warming"] = False
4882
+ logger.info("[TimesFM] Warmup complete β€” ready on %s", forecaster.device)
4883
+ except Exception as ex:
4884
+ STARTUP_STATE["timesfm"]["warming"] = False
4885
+ logger.error("[TimesFM] Warmup failed: %s", ex)
4886
 
4887
 
4888
  # ── Symbol / Interval listing ─────────────────────────────────────────────────
 
5204
 
5205
  display = cached.get("display") or {}
5206
  if (
5207
+ display.get("mode") != "raw_timesfm_ohlc4_line"
5208
  or display.get("output_mode") != "single_future_ohlc4_line"
5209
  or display.get("channels") != ["ohlc4"]
5210
  ):
 
5220
  and semantics.get("feature_channels") == ["ohlc4"]
5221
  and semantics.get("price_mode") == "ohlc4_single_channel"
5222
  and semantics.get("base_signal") == "ohlc4"
5223
+ and semantics.get("adapter_mode") == "timesfm_native"
5224
  and output_semantics.get("forecast_channel") == "ohlc4"
5225
  and output_semantics.get("forecast_mode") == "single_future_ohlc4_line"
5226
  and output_semantics.get("candle_projection") == "omitted"
 
5259
  refresh=refresh,
5260
  min_context=FORECAST_CONTEXT,
5261
  )
5262
+ if not TIMESFM_AVAILABLE:
5263
  last_ohlc4 = (
5264
  float(
5265
  np.mean(
 
5278
  "symbol": symbol,
5279
  "interval": interval,
5280
  "forecast_rows": [],
5281
+ "error": "TimesFM not installed. Run: pip install timesfm[torch]",
 
5282
  "ai_runtime": {"mode": "local_only", "model": "offline"},
5283
  "_data_list": data_list,
5284
  "indicators_snapshot": indicators,
 
5295
  "input_semantics": {
5296
  "feature_channels": ["ohlc4"],
5297
  "active_forecast_channels": ["ohlc4"],
5298
+ "ignored_channels": ["volume"],
5299
  "price_mode": "ohlc4_single_channel",
5300
  "base_signal": "ohlc4",
5301
  "volume_mode": "omitted",
5302
  "amount_mode": "omitted",
5303
+ "adapter_mode": "timesfm_native",
5304
  },
5305
  "output_semantics": {
5306
  "forecast_channel": "ohlc4",
 
5324
  else:
5325
  df_hist["amount"] = df_hist["amount"].fillna(0)
5326
 
5327
+ # TimesFM handles context internally; pass the full available history
5328
+ context_len = min(len(df_hist), TimesFMForecaster.MAX_CONTEXT)
5329
  df_context = df_hist.tail(context_len).reset_index(drop=True)
5330
 
5331
  logger.info("[forecast] %s %s | ctx=%d/%d | horizon=%d", symbol, interval, context_len, len(df_hist), horizon)
5332
 
5333
  last_time = int(df_hist["time"].iloc[-1])
5334
  step = STEP_SECONDS[interval]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5335
 
5336
+ model_output = await forecaster.forecast(
5337
+ df=df_context[["open", "high", "low", "close", "volume"]],
5338
+ horizon=horizon,
5339
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5340
 
5341
  last_ohlc4 = float(df_hist[["open", "high", "low", "close"]].mean(axis=1).iloc[-1])
5342
  last_close = last_ohlc4
 
5369
  "forecast_rows": forecast_rows,
5370
  "from_persistent_cache": False,
5371
  "model": {
5372
+ "name": model_output.get("model_name", TimesFMForecaster.MODEL_HF_ID),
5373
  "context_length": int(model_output.get("context_length", context_len)),
5374
  "quantiles": [0.1, 0.5, 0.9],
5375
  "cache_version": CACHE_VERSION,
5376
+ "sample_count": 0, # TimesFM is deterministic; no sampling needed
5377
  "input_semantics": model_output.get("input_semantics", {}),
5378
  "output_semantics": model_output.get("output_semantics", {}),
5379
  },
 
5384
  "_cache_origin": cache_origin,
5385
  "ai_runtime": {
5386
  "mode": "local_only",
5387
+ "model": str(model_output.get("model_name", TimesFMForecaster.MODEL_HF_ID)),
5388
  "device": forecaster.device,
5389
  },
5390
  }
 
5432
  "confidence": round(confidence, 2),
5433
  "model_bias_pct": 0.0,
5434
  "path_metrics": path_metrics,
5435
+ "mode": "raw_timesfm_ohlc4",
5436
  }
5437
 
5438
 
 
5443
  "interval": payload["interval"],
5444
  "forecast": payload.get("forecast_rows", []),
5445
  "error": payload["error"],
5446
+ "path_checked": None,
5447
  "display": {
5448
+ "mode": "raw_timesfm_ohlc4_line",
5449
  "channels": ["ohlc4"],
5450
  "output_mode": "single_future_ohlc4_line",
5451
+ "uncertainty_source": "timesfm_quantile_head",
5452
  "uses_anchor_blending": False,
5453
  },
5454
  "ai_runtime": payload.get("ai_runtime", {"mode": "local_only", "model": "offline"}),
 
5478
  "from_persistent_cache": payload.get("from_persistent_cache", False),
5479
  "model": payload["model"],
5480
  "display": {
5481
+ "mode": "raw_timesfm_ohlc4_line",
5482
  "channels": ["ohlc4"],
5483
  "output_mode": "single_future_ohlc4_line",
5484
+ "uncertainty_source": "timesfm_quantile_head",
5485
  "uses_anchor_blending": False,
5486
  },
5487
  "ensemble": {
5488
+ "mode": "raw_timesfm_ohlc4",
5489
  "model_weight": analysis_bundle["model_weight"],
5490
  "anchor_weight": analysis_bundle["anchor_weight"],
5491
  "trend_agreement": analysis_bundle["agreement"],
 
5653
  # ── Health ────────────────────────────────────────────────────────────────────
5654
  @app.get("/api/health")
5655
  async def health_check() -> Dict[str, Any]:
5656
+ STARTUP_STATE["timesfm"]["loaded"] = forecaster.is_ready
5657
+ STARTUP_STATE["timesfm"]["device"] = forecaster.device
5658
  return {
5659
+ "status": "online" if TIMESFM_AVAILABLE else "degraded",
5660
  "version": APP_VERSION,
5661
  "model_ready": forecaster.is_ready,
5662
  "device": forecaster.device,
 
5667
  "cors_allow_origins": CORS_ALLOW_ORIGINS,
5668
  "admin_auth_configured": ADMIN_TOKEN != DEFAULT_ADMIN_TOKEN,
5669
  "request_metrics": REQUEST_METRICS.snapshot(),
5670
+ "timesfm": STARTUP_STATE["timesfm"],
5671
  "startup_checks": STARTUP_STATE["sources"],
5672
  }
5673
 
 
5710
  },
5711
  "circuit_breakers": cb_stats,
5712
  "sources": STARTUP_STATE["sources"],
5713
+ "timesfm_status": STARTUP_STATE["timesfm"]["loaded"],
5714
  }
5715
 
5716
 
requirements.txt CHANGED
@@ -8,9 +8,9 @@ ccxt==4.5.49
8
  torch==2.11.0
9
  aiofiles==25.1.0
10
  pytz==2026.1.post1
11
- einops==0.8.1
12
  huggingface_hub==0.33.1
13
  matplotlib==3.9.3
14
  tqdm==4.67.1
15
  safetensors==0.6.2
16
  python-dotenv==1.1.0
 
 
8
  torch==2.11.0
9
  aiofiles==25.1.0
10
  pytz==2026.1.post1
 
11
  huggingface_hub==0.33.1
12
  matplotlib==3.9.3
13
  tqdm==4.67.1
14
  safetensors==0.6.2
15
  python-dotenv==1.1.0
16
+ timesfm[torch]>=2.0.0