Thang6822 commited on
Commit
9734b71
·
1 Parent(s): 5981eff

Update branding to SuperAI Forecast

Browse files
README.md CHANGED
@@ -1,59 +1,38 @@
1
  ---
2
- title: Kronos AI
3
- emoji: 🚀
4
  colorFrom: blue
5
- colorTo: red
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
- # Kronos AI Analysis
11
 
12
- Kronos AI Analysis is a FastAPI + Lightweight Charts application for:
13
 
14
- - loading market data from multiple providers with automatic fallback
15
- - calculating technical indicators
16
- - generating AI forecasts with Kronos
17
- - serving the web UI directly from the backend
18
 
19
- ## Runtime Requirements
20
-
21
- - Windows with Python 3.11 installed
22
- - Internet access for live market data
23
- - Internet access on first Kronos model load if the model is not already cached locally
24
-
25
- ## Project Layout
26
 
27
- ```text
28
- Kronos_AI_Analysis/
29
- |-- backend/
30
- | |-- main.py
31
- | `-- launcher.py
32
- |-- frontend/
33
- | `-- index.html
34
- |-- Kronos-master/
35
- | `-- model/
36
- |-- dist/
37
- | `-- SuperAI_Analysis.exe
38
- |-- requirements.txt
39
- `-- run_server.bat
40
- ```
41
 
42
- ## Recommended Start
 
 
43
 
44
- Run:
45
 
46
  ```bat
47
- run_server.bat
48
  ```
49
 
50
- The startup script now:
51
-
52
- 1. checks Python 3.11
53
- 2. repairs a broken virtual environment if needed
54
- 3. installs the full runtime dependency set
55
- 4. verifies Kronos can be imported
56
- 5. starts the FastAPI server on a random free local port and opens the browser automatically
57
 
58
  ## Manual Start
59
 
@@ -63,15 +42,30 @@ venv\Scripts\python.exe -m pip install -r requirements.txt
63
  venv\Scripts\python.exe -m backend.launcher
64
  ```
65
 
66
- ## Important Notes
67
 
68
- - This project uses Kronos, not TimesFM.
69
- - Hugging Face Docker Spaces are pinned to port `7860`, while the local Windows launcher uses a random free port.
70
- - Kronos source code is loaded from `Kronos-master`.
71
- - Kronos weights are loaded from Hugging Face via:
72
- - `NeoQuasar/Kronos-base`
73
- - `NeoQuasar/Kronos-Tokenizer-base`
74
- - The first AI forecast can take longer because the model may warm up or download into cache.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  ## Key Endpoints
77
 
@@ -84,18 +78,29 @@ venv\Scripts\python.exe -m backend.launcher
84
  - `GET /api/crypto/market`
85
  - `WS /ws/price/{symbol}`
86
 
 
 
 
 
 
 
 
 
 
87
  ## Health Expectations
88
 
89
  When the system is healthy:
90
 
91
  - `/api/health` returns `status: online`
 
 
92
  - `kronos.available` is `true`
93
- - `kronos.loaded` becomes `true` after warmup or after the first forecast
94
  - the frontend loads from the same backend origin
95
 
96
  ## Troubleshooting
97
 
98
- - If Python 3.11 is missing, install it first.
99
- - If a previous `venv` was copied from another machine, `run_server.bat` rebuilds it.
100
- - If forecasts fail, check `/api/health` and confirm Kronos reports as available and loaded.
101
- - If market data fails, check network reachability to Binance, Bybit, CoinGecko, Twelve Data, Finnhub, and Yahoo Finance.
 
1
  ---
2
+ title: SuperAI Forecast
3
+ emoji: "📈"
4
  colorFrom: blue
5
+ colorTo: cyan
6
  sdk: docker
7
  app_port: 7860
8
  pinned: false
9
  ---
10
+ # SuperAI Forecast
11
 
12
+ SuperAI Forecast is a FastAPI + Lightweight Charts application for live market analysis with a multi-model AI forecast stack: Kronos, Google TimesFM, and Amazon Chronos.
13
 
14
+ ## Core Features
 
 
 
15
 
16
+ - multi-source OHLCV data with automatic fallback
17
+ - technical indicator calculation
18
+ - independent Kronos / TimesFM / Chronos forecasting
19
+ - unified OHLC4 forecast contract across all AI models
20
+ - real-time WebSocket price streaming
21
+ - single-origin frontend served directly from the backend
 
22
 
23
+ ## Runtime Requirements
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ - Windows with Python 3.11
26
+ - Internet access for market data
27
+ - Internet access on the first TimesFM model load if the checkpoint is not cached locally
28
 
29
+ ## Quick Start
30
 
31
  ```bat
32
+ run.bat
33
  ```
34
 
35
+ The launcher validates the virtual environment, ensures the local AI model dependencies are ready, applies the TimesFM compatibility patch, starts the backend, and opens the dashboard automatically.
 
 
 
 
 
 
36
 
37
  ## Manual Start
38
 
 
42
  venv\Scripts\python.exe -m backend.launcher
43
  ```
44
 
45
+ ## Project Layout
46
 
47
+ ```text
48
+ SuperAI Forecast/
49
+ |-- backend/
50
+ | |-- main.py
51
+ | |-- frontend_assets.py
52
+ | |-- launcher.py
53
+ | |-- server_runtime.py
54
+ | |-- forecasting/
55
+ | `-- startup_utils.py
56
+ |-- frontend/
57
+ | |-- app.js
58
+ | |-- forecast-models.js
59
+ | |-- index.html
60
+ | |-- workspace.js
61
+ | `-- workspace.css
62
+ |-- libs/
63
+ | `-- chronos-forecasting/
64
+ |-- scripts/
65
+ | `-- patch_timesfm.py
66
+ |-- requirements.txt
67
+ `-- run.bat
68
+ ```
69
 
70
  ## Key Endpoints
71
 
 
78
  - `GET /api/crypto/market`
79
  - `WS /ws/price/{symbol}`
80
 
81
+ ## SuperAI Forecast Contract
82
+
83
+ - Input to every AI model is a single `OHLC4` series built as `(open + high + low + close) / 4`.
84
+ - The recommended context length is `512`, with model-specific fallback windows when the source history is shorter.
85
+ - The default horizon is `10`, so each enabled AI model predicts `T+1 ... T+10`.
86
+ - Output from each AI model is an independent future `OHLC4` line with quantiles `p10`, `p50`, `p90`.
87
+ - API responses expose both `last_close` (market close) and `last_ohlc4` (forecast baseline) so downstream analysis stays explicit and stable.
88
+ - Frontend rendering uses one forecast line per model, a shared horizon/timeframe contract, and a versioned asset registry for consistent model/UI alignment.
89
+
90
  ## Health Expectations
91
 
92
  When the system is healthy:
93
 
94
  - `/api/health` returns `status: online`
95
+ - `timesfm.available` is `true`
96
+ - `chronos.available` is `true`
97
  - `kronos.available` is `true`
98
+ - each model reports its own readiness state after warmup or after the first forecast
99
  - the frontend loads from the same backend origin
100
 
101
  ## Troubleshooting
102
 
103
+ - Install Python 3.11 if it is missing.
104
+ - Rebuild `venv` if it was copied from another machine.
105
+ - If forecasts fail, check `/api/health` and confirm the target model is available and loaded.
106
+ - If market data fails, verify connectivity to Binance, Bybit, CoinGecko, Twelve Data, Finnhub, and Yahoo Finance.
_ai_refactor.py DELETED
@@ -1,166 +0,0 @@
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,27 +1,9 @@
1
  from __future__ import annotations
2
 
3
- import os
4
- import socket
5
-
6
  import uvicorn
 
7
 
8
- def find_free_port(host: str = "127.0.0.1") -> int:
9
- """Ask the OS for a currently available local TCP port."""
10
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
11
- server_socket.bind((host, 0))
12
- server_socket.listen(1)
13
- return int(server_socket.getsockname()[1])
14
-
15
-
16
- def is_huggingface_space() -> bool:
17
- """Return True when running inside a Hugging Face Space runtime."""
18
- return bool(os.getenv("SPACE_ID") or os.getenv("SPACE_HOST"))
19
-
20
-
21
- def bootstrap_runtime_port() -> None:
22
- """Seed PORT early so backend imports cannot override the Hugging Face runtime port."""
23
- if is_huggingface_space() and not os.getenv("PORT", "").strip():
24
- os.environ["PORT"] = "7860"
25
 
26
 
27
  bootstrap_runtime_port()
@@ -29,19 +11,5 @@ bootstrap_runtime_port()
29
  from backend.main import app
30
 
31
 
32
- def resolve_server_port() -> int:
33
- """Use the runtime PORT when defined, keep Hugging Face on 7860, else pick a free local port."""
34
- raw_port = os.getenv("PORT", "").strip()
35
- if raw_port:
36
- return int(raw_port)
37
- if is_huggingface_space():
38
- os.environ["PORT"] = "7860"
39
- return 7860
40
-
41
- port = find_free_port()
42
- os.environ["PORT"] = str(port)
43
- return port
44
-
45
-
46
  if __name__ == "__main__":
47
- uvicorn.run(app, host="0.0.0.0", port=resolve_server_port())
 
1
  from __future__ import annotations
2
 
 
 
 
3
  import uvicorn
4
+ from backend.server_runtime import bootstrap_runtime_port, resolve_server_port
5
 
6
+ DEFAULT_HOST = "0.0.0.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
 
9
  bootstrap_runtime_port()
 
11
  from backend.main import app
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  if __name__ == "__main__":
15
+ uvicorn.run(app, host=DEFAULT_HOST, port=resolve_server_port(DEFAULT_HOST))
backend/forecasting/AI_MODEL_RULES.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Quy tắc vận hành cho AI Forecast Models
2
+
3
+ ## 1. Chuẩn đầu vào
4
+
5
+ - Dữ liệu đầu vào của mọi model AI là một chuỗi lịch sử `OHLC4` đúng `timeframe`.
6
+ - `OHLC4 = (open + high + low + close) / 4`.
7
+ - Chuỗi phải tăng dần theo thời gian, không đảo thứ tự, không chứa `NaN`, `inf`, giá trị rỗng hoặc timestamp lỗi.
8
+ - Chuỗi phải đồng nhất timeframe trong toàn bộ context.
9
+ - Mỗi lần inference phải được cắt context riêng cho từng model, không tái sử dụng hidden state, sample path, scaler hay tokenizer state của model khác.
10
+ - `context length` tối thiểu cho inference là `32`.
11
+ - `context length` khuyến nghị mặc định là `512` khi đủ dữ liệu.
12
+ - `horizon` và `timeframe` là hai tham số dùng chung giữa các model; mọi tham số nội bộ khác phải độc lập theo từng model.
13
+
14
+ ## 2. Chuẩn đầu ra
15
+
16
+ - Với `horizon = 10` mặc định, mỗi model AI phải dự báo đúng `10` giá trị `OHLC4` tương lai:
17
+ - `T+1`, `T+2`, `T+3`, `T+4`, `T+5`, `T+6`, `T+7`, `T+8`, `T+9`, `T+10`.
18
+ - Chuẩn output thống nhất cho mọi model:
19
+ - `p10`
20
+ - `p50`
21
+ - `p90`
22
+ - `p50` là đường dự báo chính để hiển thị "Dự báo AI".
23
+ - Backend phải tạo dãy timestamp tương lai khớp chính xác với `timeframe` đầu vào.
24
+ - Frontend phải nối tuần tự các điểm `p50` thành một line duy nhất cho từng model.
25
+ - Điểm neo đầu tiên luôn là giá trị `OHLC4` hiện tại tại `T0`.
26
+
27
+ ## 3. Tính độc lập giữa các model
28
+
29
+ - `Kronos`, `TimesFM`, `Chronos` phải chạy độc lập hoàn toàn.
30
+ - Một model lỗi không được làm hỏng kết quả của model khác.
31
+ - Cache phải phân biệt theo:
32
+ - `symbol`
33
+ - `timeframe`
34
+ - `horizon`
35
+ - `model selection`
36
+ - `rules version`
37
+ - Log, warmup state, import error và device state phải theo từng model riêng.
38
+
39
+ ## 4. Quy tắc backend chuyên nghiệp
40
+
41
+ - Mỗi model phải có adapter/provider riêng, không nhét logic model trực tiếp vào route.
42
+ - Mọi adapter phải cùng tuân thủ một contract forecast thống nhất.
43
+ - Validation chỉ bắt buộc ở biên hệ thống:
44
+ - dữ liệu lịch sử đầu vào
45
+ - output quantile
46
+ - timestamp tương lai
47
+ - Sampling model phải có seed ổn định để tái lập kết quả khi cùng input.
48
+ - Warmup model là lazy-load an toàn bằng lock async.
49
+ - Inference phải có lock riêng theo model để tránh race condition khi cùng tải local GPU/CPU.
50
+ - Response schema phải luôn giữ ổn định kể cả khi một model bị skip hoặc unavailable.
51
+ - Mỗi model phải trả về metadata tối thiểu:
52
+ - tên model
53
+ - context length đã dùng
54
+ - horizon đầu ra
55
+ - input semantics
56
+ - output semantics
57
+ - output validation
58
+ - ai runtime
59
+
60
+ ## 5. Quy tắc hiển thị
61
+
62
+ - Mỗi model có line forecast riêng.
63
+ - Độ dày mặc định của các đường dự báo AI là: `1`.
64
+ - Đường tổng hợp chỉ được tạo từ các model đang enabled và success.
65
+ - Màu line phải phản ánh hướng đi của chính line forecast đó, không dùng trạng thái của model khác.
66
+ - Khi người dùng rê chuột vào một đường forecast, giao diện phải hiển thị rõ đó là đường của model nào.
67
+ - Chronos dùng palette riêng:
68
+ - tăng: xanh lá đậm
69
+ - sideway: xám vàng
70
+ - giảm: hồng đậm
71
+
72
+ ## 6. Quy tắc bảo trì
73
+
74
+ - Thêm model mới phải chỉ cần:
75
+ - thêm provider
76
+ - đăng ký vào config
77
+ - nối vào frontend toggle/series
78
+ - Không sửa contract chung chỉ để phục vụ một model đơn lẻ.
79
+ - Mọi thay đổi lớn ở forecast subsystem phải có test regression cho payload và selection logic.
backend/forecasting/ARCHITECTURE.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Forecasting Backend Architecture
2
+
3
+ ## Mục tiêu
4
+
5
+ - Tách riêng forecasting subsystem khỏi route monolith.
6
+ - Chuẩn hóa contract dùng chung cho `Kronos`, `TimesFM`, `Chronos`.
7
+ - Bảo đảm mỗi model inference độc lập, có warmup/state/cache/log riêng.
8
+
9
+ ## Cấu trúc hiện tại
10
+
11
+ - `config.py`
12
+ - Danh sách model chuẩn.
13
+ - Default selection.
14
+ - Hàm chuẩn hóa `model_selection`.
15
+ - `rules.py`
16
+ - Load file `AI_MODEL_RULES.md`.
17
+ - Sinh `rules version` để dùng cho cache key và response metadata.
18
+ - `AI_MODEL_RULES.md`
19
+ - Nguồn quy tắc vận hành chính thức cho forecast subsystem.
20
+ - `providers/timesfm_provider.py`
21
+ - Provider TimesFM theo contract OHLC4 chuẩn.
22
+ - `providers/chronos_provider.py`
23
+ - Provider Chronos theo contract OHLC4 chuẩn.
24
+ - `backend/kronos_adapter.py`
25
+ - Provider Kronos hiện hữu, tiếp tục được dùng như provider độc lập.
26
+
27
+ ## Contract provider
28
+
29
+ Mỗi provider phải đảm bảo:
30
+
31
+ - Input là chuỗi lịch sử OHLC4 cùng timeframe.
32
+ - Output thống nhất:
33
+ - `p10`
34
+ - `p50`
35
+ - `p90`
36
+ - `model_name`
37
+ - `context_length`
38
+ - `output_horizon`
39
+ - `input_semantics`
40
+ - `input_validation`
41
+ - `output_semantics`
42
+ - `output_validation`
43
+ - Lazy-load an toàn bằng async lock.
44
+ - Predict lock riêng để tránh race condition trên CPU/GPU cục bộ.
45
+
46
+ ## Runtime state
47
+
48
+ Mỗi model có startup state độc lập:
49
+
50
+ - `available`
51
+ - `preload_enabled`
52
+ - `warming`
53
+ - `loaded`
54
+ - `device`
55
+ - `last_error`
56
+ - `model`
57
+
58
+ ## Cache rules
59
+
60
+ Forecast cache key phải phân biệt theo:
61
+
62
+ - `symbol`
63
+ - `interval`
64
+ - `horizon`
65
+ - `model selection`
66
+ - `rules version`
67
+
68
+ ## Response rules
69
+
70
+ `/api/forecast/{symbol}` luôn trả:
71
+
72
+ - `forecast`
73
+ - `forecast_models`
74
+ - `model_selection`
75
+ - `forecast_contract`
76
+ - `rules`
77
+
78
+ Nhờ vậy frontend và test regression có thể bám vào một schema ổn định khi thêm model mới.
79
+
80
+ ## Frontend integration
81
+
82
+ Frontend đồng bộ cùng contract này qua:
83
+
84
+ - `frontend/forecast-models.js`
85
+ - Nguồn cấu hình dùng chung cho thứ tự model, label, palette và `defaultLineWidth`.
86
+ - `frontend/workspace.js`
87
+ - Đồng bộ selection/signature/cache theo cùng model order.
88
+ - `frontend/app.js`
89
+ - Render line riêng theo từng model.
90
+ - Hover vào line phải hiện đúng tên model đang được trỏ tới.
backend/forecasting/__init__.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from backend.forecasting.config import (
2
+ DEFAULT_FORECAST_MODEL_SELECTION,
3
+ FORECAST_MODEL_LABELS,
4
+ FORECAST_MODEL_ORDER,
5
+ build_query_model_selection,
6
+ enabled_model_keys,
7
+ forecast_model_signature,
8
+ normalize_forecast_model_selection,
9
+ )
10
+ from backend.forecasting.rules import FORECAST_RULE_DOCUMENT, ForecastRuleDocument
11
+
12
+ __all__ = [
13
+ "DEFAULT_FORECAST_MODEL_SELECTION",
14
+ "FORECAST_MODEL_LABELS",
15
+ "FORECAST_MODEL_ORDER",
16
+ "FORECAST_RULE_DOCUMENT",
17
+ "ForecastRuleDocument",
18
+ "build_query_model_selection",
19
+ "enabled_model_keys",
20
+ "forecast_model_signature",
21
+ "normalize_forecast_model_selection",
22
+ ]
backend/forecasting/config.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Mapping, Tuple
4
+
5
+
6
+ FORECAST_MODEL_ORDER: Tuple[str, ...] = ("kronos", "timesfm", "chronos")
7
+
8
+ FORECAST_MODEL_LABELS: Dict[str, str] = {
9
+ "kronos": "Kronos",
10
+ "timesfm": "TimesFM",
11
+ "chronos": "Chronos",
12
+ }
13
+
14
+ DEFAULT_FORECAST_MODEL_SELECTION: Dict[str, bool] = {
15
+ model_key: True
16
+ for model_key in FORECAST_MODEL_ORDER
17
+ }
18
+
19
+
20
+ def normalize_forecast_model_selection(
21
+ selection: Mapping[str, bool] | None = None,
22
+ ) -> Dict[str, bool]:
23
+ normalized = {
24
+ model_key: bool(
25
+ DEFAULT_FORECAST_MODEL_SELECTION[model_key]
26
+ if selection is None
27
+ else selection.get(model_key, DEFAULT_FORECAST_MODEL_SELECTION[model_key])
28
+ )
29
+ for model_key in FORECAST_MODEL_ORDER
30
+ }
31
+ if not any(normalized.values()):
32
+ normalized[FORECAST_MODEL_ORDER[0]] = True
33
+ return normalized
34
+
35
+
36
+ def build_query_model_selection(
37
+ *,
38
+ use_kronos: bool,
39
+ use_timesfm: bool,
40
+ use_chronos: bool,
41
+ ) -> Dict[str, bool]:
42
+ return normalize_forecast_model_selection(
43
+ {
44
+ "kronos": use_kronos,
45
+ "timesfm": use_timesfm,
46
+ "chronos": use_chronos,
47
+ }
48
+ )
49
+
50
+
51
+ def forecast_model_signature(model_selection: Mapping[str, bool]) -> str:
52
+ normalized = normalize_forecast_model_selection(model_selection)
53
+ return "".join(
54
+ f"{model_key[0]}{int(normalized[model_key])}"
55
+ for model_key in FORECAST_MODEL_ORDER
56
+ )
57
+
58
+
59
+ def enabled_model_keys(model_selection: Mapping[str, bool]) -> list[str]:
60
+ normalized = normalize_forecast_model_selection(model_selection)
61
+ return [
62
+ model_key
63
+ for model_key in FORECAST_MODEL_ORDER
64
+ if normalized[model_key]
65
+ ]
backend/forecasting/providers/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from backend.forecasting.providers.chronos_provider import (
2
+ CHRONOS_AVAILABLE,
3
+ CHRONOS_IMPORT_ERROR,
4
+ ChronosForecaster,
5
+ )
6
+ from backend.forecasting.providers.timesfm_provider import (
7
+ TIMESFM_AVAILABLE,
8
+ TIMESFM_IMPORT_ERROR,
9
+ TimesFMForecaster,
10
+ )
11
+
12
+ __all__ = [
13
+ "CHRONOS_AVAILABLE",
14
+ "CHRONOS_IMPORT_ERROR",
15
+ "ChronosForecaster",
16
+ "TIMESFM_AVAILABLE",
17
+ "TIMESFM_IMPORT_ERROR",
18
+ "TimesFMForecaster",
19
+ ]
backend/forecasting/providers/chronos_provider.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import logging
6
+ import os
7
+ import random
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional, Tuple
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from fastapi import HTTPException
16
+
17
+ try:
18
+ import torch
19
+ TORCH_IMPORT_ERROR: Optional[str] = None
20
+ except Exception as exc: # pragma: no cover - environment dependent
21
+ torch = None # type: ignore[assignment]
22
+ TORCH_IMPORT_ERROR = str(exc)
23
+
24
+
25
+ PROJECT_ROOT = Path(__file__).resolve().parents[3]
26
+ CHRONOS_VENDOR_SRC = PROJECT_ROOT / "libs" / "chronos-forecasting" / "src"
27
+
28
+ CHRONOS_AVAILABLE = False
29
+ CHRONOS_IMPORT_ERROR: Optional[str] = None
30
+ ChronosPipeline = None
31
+
32
+ if TORCH_IMPORT_ERROR:
33
+ CHRONOS_IMPORT_ERROR = TORCH_IMPORT_ERROR
34
+ else:
35
+ try:
36
+ if CHRONOS_VENDOR_SRC.exists() and str(CHRONOS_VENDOR_SRC) not in sys.path:
37
+ sys.path.insert(0, str(CHRONOS_VENDOR_SRC))
38
+ from chronos import ChronosPipeline as _ChronosPipeline # type: ignore[import-not-found]
39
+
40
+ ChronosPipeline = _ChronosPipeline
41
+ CHRONOS_AVAILABLE = True
42
+ except Exception as exc: # pragma: no cover - import environment dependent
43
+ CHRONOS_IMPORT_ERROR = str(exc)
44
+
45
+
46
+ class ChronosForecaster:
47
+ key = "chronos"
48
+ label = "Chronos"
49
+
50
+ MODEL_HF_ID = os.getenv("CHRONOS_MODEL_HF_ID", "amazon/chronos-t5-tiny")
51
+ MIN_CONTEXT = 32
52
+ MAX_CONTEXT = 512
53
+ DEFAULT_SAMPLE_COUNT = 32
54
+ DEFAULT_TEMPERATURE = 1.0
55
+ DEFAULT_TOP_K = 50
56
+ DEFAULT_TOP_P = 1.0
57
+
58
+ def __init__(self, logger: logging.Logger | None = None) -> None:
59
+ self._logger = logger or logging.getLogger("ai-forecast.chronos")
60
+ self._pipeline: Optional[Any] = None
61
+ self._loaded = False
62
+ self._load_lock: Optional[asyncio.Lock] = None
63
+ self._predict_lock: Optional[asyncio.Lock] = None
64
+
65
+ @property
66
+ def available(self) -> bool:
67
+ return CHRONOS_AVAILABLE
68
+
69
+ @property
70
+ def import_error(self) -> Optional[str]:
71
+ return CHRONOS_IMPORT_ERROR
72
+
73
+ async def _get_load_lock(self) -> asyncio.Lock:
74
+ if self._load_lock is None:
75
+ self._load_lock = asyncio.Lock()
76
+ return self._load_lock
77
+
78
+ async def _get_predict_lock(self) -> asyncio.Lock:
79
+ if self._predict_lock is None:
80
+ self._predict_lock = asyncio.Lock()
81
+ return self._predict_lock
82
+
83
+ @property
84
+ def is_ready(self) -> bool:
85
+ return self._loaded
86
+
87
+ @property
88
+ def device(self) -> str:
89
+ if self._pipeline is None:
90
+ return "not_loaded"
91
+ try:
92
+ return str(self._pipeline.model.device)
93
+ except Exception:
94
+ return "cpu"
95
+
96
+ @staticmethod
97
+ def _extract_timestamps(df: pd.DataFrame) -> pd.Series:
98
+ if "timestamps" in df.columns:
99
+ timestamps = pd.to_datetime(df["timestamps"], utc=True)
100
+ elif "time" in df.columns:
101
+ timestamps = pd.to_datetime(df["time"], unit="s", utc=True)
102
+ else:
103
+ raise HTTPException(status_code=422, detail="Chronos requires timestamps or time column")
104
+ if timestamps.isna().any():
105
+ raise HTTPException(status_code=422, detail="Chronos input timestamps contain NaT")
106
+ return timestamps.reset_index(drop=True)
107
+
108
+ @classmethod
109
+ def _extract_ohlc4_series(cls, df: pd.DataFrame) -> np.ndarray:
110
+ required_columns = {"open", "high", "low", "close"}
111
+ missing_columns = sorted(required_columns - set(df.columns))
112
+ if missing_columns:
113
+ raise HTTPException(
114
+ status_code=422,
115
+ detail=f"Chronos input is missing OHLC columns: {', '.join(missing_columns)}",
116
+ )
117
+
118
+ ohlc4 = (
119
+ df[["open", "high", "low", "close"]]
120
+ .mean(axis=1)
121
+ .to_numpy(dtype=np.float32, copy=False)
122
+ )
123
+ if ohlc4.ndim != 1:
124
+ raise HTTPException(status_code=422, detail="Chronos input series must be 1-D")
125
+ if len(ohlc4) < cls.MIN_CONTEXT:
126
+ raise HTTPException(
127
+ status_code=422,
128
+ detail=f"Chronos requires at least {cls.MIN_CONTEXT} OHLC4 points",
129
+ )
130
+ if not np.isfinite(ohlc4).all():
131
+ raise HTTPException(status_code=422, detail="Chronos input contains non-finite OHLC4 values")
132
+ return ohlc4
133
+
134
+ @staticmethod
135
+ def _stable_seed(
136
+ symbol: str,
137
+ interval: str,
138
+ horizon: int,
139
+ context_length: int,
140
+ last_timestamp: pd.Timestamp,
141
+ ) -> int:
142
+ payload = "|".join(
143
+ [
144
+ symbol,
145
+ interval,
146
+ str(horizon),
147
+ str(context_length),
148
+ str(int(last_timestamp.timestamp())),
149
+ ]
150
+ )
151
+ return int(hashlib.sha256(payload.encode("utf-8")).hexdigest()[:8], 16)
152
+
153
+ @staticmethod
154
+ def _validate_output(
155
+ quantiles: np.ndarray,
156
+ mean_forecast: np.ndarray,
157
+ horizon: int,
158
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, Dict[str, Any]]:
159
+ quantile_array = np.asarray(quantiles, dtype=float)
160
+ mean_array = np.asarray(mean_forecast, dtype=float)
161
+
162
+ if quantile_array.ndim != 3 or quantile_array.shape[0] != 1 or quantile_array.shape[1] < horizon or quantile_array.shape[2] < 3:
163
+ raise HTTPException(
164
+ status_code=500,
165
+ detail=f"Chronos quantile forecast has invalid shape: {list(quantile_array.shape)}",
166
+ )
167
+ if mean_array.ndim != 2 or mean_array.shape[0] != 1 or mean_array.shape[1] < horizon:
168
+ raise HTTPException(
169
+ status_code=500,
170
+ detail=f"Chronos mean forecast has invalid shape: {list(mean_array.shape)}",
171
+ )
172
+
173
+ p10 = quantile_array[0, :horizon, 0].astype(float)
174
+ p50 = quantile_array[0, :horizon, 1].astype(float)
175
+ p90 = quantile_array[0, :horizon, 2].astype(float)
176
+ mean_values = mean_array[0, :horizon].astype(float)
177
+
178
+ if not (np.isfinite(p10).all() and np.isfinite(p50).all() and np.isfinite(p90).all()):
179
+ raise HTTPException(status_code=500, detail="Chronos output contains non-finite values")
180
+
181
+ quantiles_monotonic = bool(
182
+ np.all(p10 <= (p50 + 1e-6))
183
+ and np.all(p50 <= (p90 + 1e-6))
184
+ )
185
+ if not quantiles_monotonic:
186
+ raise HTTPException(status_code=500, detail="Chronos returned non-monotonic quantiles")
187
+
188
+ return p10, p50, p90, {
189
+ "quantile_shape": [int(dim) for dim in quantile_array.shape],
190
+ "mean_shape": [int(dim) for dim in mean_array.shape],
191
+ "quantiles_monotonic": quantiles_monotonic,
192
+ "median_close_to_mean": bool(np.allclose(p50, mean_values, atol=1e-3, rtol=1e-3)),
193
+ }
194
+
195
+ async def _lazy_load(self) -> None:
196
+ if self._loaded:
197
+ return
198
+ load_lock = await self._get_load_lock()
199
+ async with load_lock:
200
+ if self._loaded:
201
+ return
202
+ if not CHRONOS_AVAILABLE or ChronosPipeline is None:
203
+ raise HTTPException(
204
+ status_code=503,
205
+ detail=(
206
+ "Chronos is unavailable. Ensure libs/chronos-forecasting is present "
207
+ "and its dependencies are installed."
208
+ ),
209
+ )
210
+ try:
211
+ self._logger.info("[Chronos] Loading %s ...", self.MODEL_HF_ID)
212
+ device_map = "cuda" if torch is not None and torch.cuda.is_available() else "cpu"
213
+ model_dtype = torch.bfloat16 if torch is not None and torch.cuda.is_available() else torch.float32
214
+ pipeline = await asyncio.to_thread(
215
+ ChronosPipeline.from_pretrained,
216
+ self.MODEL_HF_ID,
217
+ device_map=device_map,
218
+ dtype=model_dtype,
219
+ )
220
+ self._pipeline = pipeline
221
+ self._loaded = True
222
+ self._logger.info("[Chronos] Ready on %s", self.device)
223
+ except Exception as exc:
224
+ self._logger.error("[Chronos] Init failed: %s", exc, exc_info=True)
225
+ raise HTTPException(status_code=500, detail=f"Chronos init failed: {exc}")
226
+
227
+ async def forecast(
228
+ self,
229
+ df: pd.DataFrame,
230
+ horizon: int,
231
+ interval: str,
232
+ step_seconds: int,
233
+ symbol: str = "",
234
+ ) -> Dict[str, Any]:
235
+ del step_seconds
236
+ await self._lazy_load()
237
+ if self._pipeline is None:
238
+ raise HTTPException(status_code=500, detail="Chronos pipeline is not initialized")
239
+
240
+ timestamps = self._extract_timestamps(df)
241
+ ohlc4_series = self._extract_ohlc4_series(df)
242
+ context_length = min(len(ohlc4_series), self.MAX_CONTEXT, int(self._pipeline.model_context_length))
243
+ context = ohlc4_series[-context_length:]
244
+ seed = self._stable_seed(
245
+ symbol=symbol or "UNKNOWN",
246
+ interval=interval,
247
+ horizon=horizon,
248
+ context_length=context_length,
249
+ last_timestamp=pd.Timestamp(timestamps.iloc[-1]).tz_convert("UTC"),
250
+ )
251
+
252
+ predict_lock = await self._get_predict_lock()
253
+ async with predict_lock:
254
+ try:
255
+ random.seed(seed)
256
+ np.random.seed(seed % (2**32 - 1))
257
+ if torch is not None:
258
+ torch.manual_seed(seed)
259
+ if torch.cuda.is_available():
260
+ torch.cuda.manual_seed_all(seed)
261
+
262
+ started_at = time.time()
263
+ context_tensor = torch.tensor(context, dtype=torch.float32)
264
+ quantiles, mean_forecast = await asyncio.to_thread(
265
+ self._pipeline.predict_quantiles,
266
+ context_tensor,
267
+ prediction_length=horizon,
268
+ quantile_levels=[0.1, 0.5, 0.9],
269
+ num_samples=self.DEFAULT_SAMPLE_COUNT,
270
+ temperature=self.DEFAULT_TEMPERATURE,
271
+ top_k=self.DEFAULT_TOP_K,
272
+ top_p=self.DEFAULT_TOP_P,
273
+ limit_prediction_length=False,
274
+ )
275
+ elapsed = time.time() - started_at
276
+ self._logger.info(
277
+ "[Chronos] %.2fs | horizon=%d ctx=%d samples=%d device=%s",
278
+ elapsed,
279
+ horizon,
280
+ context_length,
281
+ self.DEFAULT_SAMPLE_COUNT,
282
+ self.device,
283
+ )
284
+ except Exception as exc:
285
+ self._logger.error("[Chronos] Forecast failed: %s", exc, exc_info=True)
286
+ raise HTTPException(status_code=500, detail=f"Chronos prediction failed: {exc}")
287
+
288
+ p10, p50, p90, output_validation = self._validate_output(
289
+ quantiles=quantiles.numpy() if hasattr(quantiles, "numpy") else np.asarray(quantiles),
290
+ mean_forecast=mean_forecast.numpy() if hasattr(mean_forecast, "numpy") else np.asarray(mean_forecast),
291
+ horizon=horizon,
292
+ )
293
+
294
+ return {
295
+ "p10": p10,
296
+ "p50": p50,
297
+ "p90": p90,
298
+ "model_name": self.MODEL_HF_ID,
299
+ "context_length": context_length,
300
+ "output_horizon": horizon,
301
+ "sample_count": self.DEFAULT_SAMPLE_COUNT,
302
+ "seed": seed,
303
+ "input_semantics": {
304
+ "feature_channels": ["ohlc4"],
305
+ "active_forecast_channels": ["ohlc4"],
306
+ "ignored_channels": ["volume", "amount"],
307
+ "price_mode": "ohlc4_single_channel",
308
+ "base_signal": "ohlc4",
309
+ "volume_mode": "omitted",
310
+ "amount_mode": "omitted",
311
+ "adapter_mode": "chronos_univariate",
312
+ },
313
+ "input_validation": {
314
+ "series_field": "ohlc4",
315
+ "dtype": "float32",
316
+ "context_length": context_length,
317
+ "finite": True,
318
+ },
319
+ "output_semantics": {
320
+ "forecast_channel": "ohlc4",
321
+ "forecast_mode": "single_future_ohlc4_line",
322
+ "quantile_fields": ["p10", "p50", "p90"],
323
+ "quantile_source": "chronos_sampling",
324
+ "candle_projection": "omitted",
325
+ "reference_baseline": "last_ohlc4",
326
+ },
327
+ "output_validation": output_validation,
328
+ }
backend/forecasting/providers/timesfm_provider.py ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import importlib
5
+ import logging
6
+ import os
7
+ import time
8
+ from typing import Any, Dict, Optional, Tuple
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+ from fastapi import HTTPException
13
+
14
+ try:
15
+ import torch
16
+ TORCH_IMPORT_ERROR: Optional[str] = None
17
+ except Exception as exc: # pragma: no cover - environment dependent
18
+ torch = None # type: ignore[assignment]
19
+ TORCH_IMPORT_ERROR = str(exc)
20
+
21
+
22
+ TIMESFM_AVAILABLE = False
23
+ TIMESFM_IMPORT_ERROR: Optional[str] = None
24
+ timesfm = None
25
+
26
+ if TORCH_IMPORT_ERROR:
27
+ TIMESFM_IMPORT_ERROR = TORCH_IMPORT_ERROR
28
+ else:
29
+ try:
30
+ import timesfm as _timesfm # type: ignore[import-not-found]
31
+
32
+ timesfm = _timesfm
33
+ TIMESFM_AVAILABLE = True
34
+ except Exception as exc: # pragma: no cover - import environment dependent
35
+ TIMESFM_IMPORT_ERROR = str(exc)
36
+
37
+
38
+ _TIMESFM_RUNTIME_PATCHED = False
39
+
40
+
41
+ def _patch_timesfm_runtime_compatibility(logger: logging.Logger) -> None:
42
+ global _TIMESFM_RUNTIME_PATCHED
43
+ if _TIMESFM_RUNTIME_PATCHED or timesfm is None:
44
+ return
45
+
46
+ try:
47
+ internal_module = importlib.import_module("timesfm.timesfm_2p5.timesfm_2p5_torch")
48
+ model_module_cls = getattr(internal_module, "TimesFM_2p5_200M_torch_module", None)
49
+ if model_module_cls is None or getattr(model_module_cls, "_aiforecast_meta_patch", False):
50
+ _TIMESFM_RUNTIME_PATCHED = True
51
+ return
52
+
53
+ def _patched_load_checkpoint(self: Any, path: str, **kwargs: Any) -> None:
54
+ tensors = internal_module.load_file(path)
55
+ has_meta_parameters = any(
56
+ getattr(parameter, "is_meta", False)
57
+ for parameter in self.parameters()
58
+ )
59
+ try:
60
+ if has_meta_parameters:
61
+ self.load_state_dict(tensors, strict=True, assign=True)
62
+ else:
63
+ self.load_state_dict(tensors, strict=True)
64
+ except TypeError:
65
+ if has_meta_parameters:
66
+ self.to_empty(device=self.device)
67
+ self.load_state_dict(tensors, strict=True)
68
+
69
+ self.to(self.device)
70
+ torch_compile = kwargs.get("torch_compile", True)
71
+ if torch_compile:
72
+ internal_module.logging.info("Compiling model...")
73
+ self = torch.compile(self)
74
+ self.eval()
75
+
76
+ model_module_cls.load_checkpoint = _patched_load_checkpoint
77
+ model_module_cls._aiforecast_meta_patch = True
78
+ _TIMESFM_RUNTIME_PATCHED = True
79
+ except Exception as exc: # pragma: no cover - defensive guard
80
+ logger.warning("[TimesFM] Runtime compatibility patch skipped: %s", exc)
81
+
82
+
83
+ class TimesFMForecaster:
84
+ key = "timesfm"
85
+ label = "TimesFM"
86
+
87
+ MODEL_HF_ID = os.getenv("TIMESFM_MODEL_HF_ID", "google/timesfm-2.5-200m-pytorch")
88
+ MAX_CONTEXT = 8_192
89
+ MAX_HORIZON = 300
90
+ _Q10 = 1
91
+ _Q50 = 5
92
+ _Q90 = 9
93
+
94
+ def __init__(self, logger: logging.Logger | None = None) -> None:
95
+ self._logger = logger or logging.getLogger("ai-forecast.timesfm")
96
+ self._model: Optional[Any] = None
97
+ self._loaded = False
98
+ self._lock: Optional[asyncio.Lock] = None
99
+ self._predict_lock: Optional[asyncio.Lock] = None
100
+ self._compiled_horizon: Optional[int] = None
101
+
102
+ @property
103
+ def available(self) -> bool:
104
+ return TIMESFM_AVAILABLE
105
+
106
+ @property
107
+ def import_error(self) -> Optional[str]:
108
+ return TIMESFM_IMPORT_ERROR
109
+
110
+ @staticmethod
111
+ def _prepare_feature_frame(df: pd.DataFrame) -> pd.DataFrame:
112
+ ohlc4 = (
113
+ df[["open", "high", "low", "close"]]
114
+ .mean(axis=1)
115
+ .astype(np.float32)
116
+ )
117
+ return pd.DataFrame({"ohlc4": ohlc4})
118
+
119
+ @classmethod
120
+ def _extract_ohlc4_series(cls, df: pd.DataFrame) -> np.ndarray:
121
+ feature_frame = cls._prepare_feature_frame(df)
122
+ ohlc4_series = feature_frame["ohlc4"].to_numpy(dtype=np.float32, copy=False)
123
+ cls._validate_input_series(ohlc4_series)
124
+ return ohlc4_series
125
+
126
+ @staticmethod
127
+ def _validate_input_series(series: np.ndarray) -> None:
128
+ if series.ndim != 1:
129
+ raise HTTPException(status_code=422, detail="TimesFM input series must be 1-D")
130
+ if len(series) < 32:
131
+ raise HTTPException(status_code=422, detail="TimesFM requires at least 32 OHLC4 points")
132
+ if not np.isfinite(series).all():
133
+ raise HTTPException(status_code=422, detail="TimesFM input contains non-finite OHLC4 values")
134
+
135
+ @classmethod
136
+ def _validate_output_tensors(
137
+ cls,
138
+ point_forecast: np.ndarray,
139
+ quantile_forecast: np.ndarray,
140
+ horizon: int,
141
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, Dict[str, Any]]:
142
+ point_arr = np.asarray(point_forecast)
143
+ quant_arr = np.asarray(quantile_forecast)
144
+
145
+ if point_arr.ndim != 2 or point_arr.shape[0] != 1 or point_arr.shape[1] < horizon:
146
+ raise HTTPException(
147
+ status_code=500,
148
+ detail=f"TimesFM point forecast has invalid shape: {list(point_arr.shape)}",
149
+ )
150
+ if (
151
+ quant_arr.ndim != 3
152
+ or quant_arr.shape[0] != 1
153
+ or quant_arr.shape[1] < horizon
154
+ or quant_arr.shape[2] <= cls._Q90
155
+ ):
156
+ raise HTTPException(
157
+ status_code=500,
158
+ detail=f"TimesFM quantile forecast has invalid shape: {list(quant_arr.shape)}",
159
+ )
160
+
161
+ p10 = quant_arr[0, :horizon, cls._Q10].astype(float)
162
+ p50 = point_arr[0, :horizon].astype(float)
163
+ p50_from_quantiles = quant_arr[0, :horizon, cls._Q50].astype(float)
164
+ p90 = quant_arr[0, :horizon, cls._Q90].astype(float)
165
+
166
+ if not (np.isfinite(p10).all() and np.isfinite(p50).all() and np.isfinite(p90).all()):
167
+ raise HTTPException(status_code=500, detail="TimesFM output contains non-finite values")
168
+
169
+ quantiles_monotonic = bool(
170
+ np.all(p10 <= (p50 + 1e-6))
171
+ and np.all(p50 <= (p90 + 1e-6))
172
+ )
173
+ if not quantiles_monotonic:
174
+ raise HTTPException(status_code=500, detail="TimesFM returned non-monotonic quantiles")
175
+
176
+ median_matches_point_forecast = bool(
177
+ np.allclose(p50, p50_from_quantiles, atol=1e-4, rtol=1e-4)
178
+ )
179
+ if not median_matches_point_forecast:
180
+ logging.getLogger("ai-forecast").warning(
181
+ "[TimesFM] point_forecast differs from q50 quantile output"
182
+ )
183
+
184
+ output_validation = {
185
+ "point_shape": [int(dim) for dim in point_arr.shape],
186
+ "quantile_shape": [int(dim) for dim in quant_arr.shape],
187
+ "median_matches_point_forecast": median_matches_point_forecast,
188
+ "quantiles_monotonic": quantiles_monotonic,
189
+ }
190
+ return p10, p50, p90, output_validation
191
+
192
+ async def _get_lock(self) -> asyncio.Lock:
193
+ if self._lock is None:
194
+ self._lock = asyncio.Lock()
195
+ return self._lock
196
+
197
+ async def _get_predict_lock(self) -> asyncio.Lock:
198
+ if self._predict_lock is None:
199
+ self._predict_lock = asyncio.Lock()
200
+ return self._predict_lock
201
+
202
+ @property
203
+ def is_ready(self) -> bool:
204
+ return self._loaded
205
+
206
+ @property
207
+ def device(self) -> str:
208
+ if self._model is None:
209
+ return "not_loaded"
210
+ try:
211
+ return str(self._model.model.device)
212
+ except Exception:
213
+ return "cpu"
214
+
215
+ def _compile(self, horizon: int) -> None:
216
+ output_patch = 128
217
+ max_horizon = int(np.ceil(horizon / output_patch) * output_patch)
218
+ max_horizon = max(max_horizon, output_patch)
219
+ max_horizon = min(max_horizon, self.MAX_HORIZON)
220
+ context_len = self.MAX_CONTEXT
221
+ assert timesfm is not None
222
+ self._model.compile(
223
+ timesfm.ForecastConfig(
224
+ max_context=context_len,
225
+ max_horizon=max_horizon,
226
+ normalize_inputs=True,
227
+ use_continuous_quantile_head=True,
228
+ force_flip_invariance=True,
229
+ infer_is_positive=True,
230
+ fix_quantile_crossing=True,
231
+ )
232
+ )
233
+ compiled_config = getattr(self._model, "forecast_config", None)
234
+ actual_ctx = int(getattr(compiled_config, "max_context", context_len))
235
+ actual_max_horizon = int(getattr(compiled_config, "max_horizon", max_horizon))
236
+ self._compiled_horizon = actual_max_horizon
237
+ self._logger.info("[TimesFM] Compiled: ctx=%d max_horizon=%d", actual_ctx, actual_max_horizon)
238
+
239
+ async def _lazy_load(self) -> None:
240
+ if self._loaded:
241
+ return
242
+ lock = await self._get_lock()
243
+ async with lock:
244
+ if self._loaded:
245
+ return
246
+ if not TIMESFM_AVAILABLE or timesfm is None:
247
+ raise HTTPException(
248
+ status_code=503,
249
+ detail="TimesFM not installed. Run: pip install timesfm[torch]",
250
+ )
251
+ try:
252
+ _patch_timesfm_runtime_compatibility(self._logger)
253
+ self._logger.info("[TimesFM] Loading %s ...", self.MODEL_HF_ID)
254
+ model = await asyncio.to_thread(
255
+ timesfm.TimesFM_2p5_200M_torch.from_pretrained,
256
+ self.MODEL_HF_ID,
257
+ torch_compile=False,
258
+ )
259
+ self._model = model
260
+ self._compile(self.MAX_HORIZON)
261
+ self._loaded = True
262
+ self._logger.info("[TimesFM] Ready on %s", self.device)
263
+ except Exception as exc:
264
+ self._logger.error("[TimesFM] Init failed: %s", exc, exc_info=True)
265
+ raise HTTPException(status_code=500, detail=f"TimesFM init failed: {exc}")
266
+
267
+ async def forecast(
268
+ self,
269
+ df: pd.DataFrame,
270
+ horizon: int,
271
+ interval: str = "",
272
+ step_seconds: int = 0,
273
+ symbol: str = "",
274
+ ) -> Dict[str, Any]:
275
+ del interval, step_seconds, symbol
276
+ await self._lazy_load()
277
+ if self._model is None:
278
+ raise HTTPException(status_code=500, detail="TimesFM model is not initialized")
279
+
280
+ try:
281
+ ohlc4_series = self._extract_ohlc4_series(df)
282
+ context_len = min(len(ohlc4_series), self.MAX_CONTEXT)
283
+ ohlc4_context = ohlc4_series[-context_len:]
284
+
285
+ if self._compiled_horizon is None or horizon > self._compiled_horizon:
286
+ self._compile(horizon)
287
+
288
+ started_at = time.time()
289
+ predict_lock = await self._get_predict_lock()
290
+ async with predict_lock:
291
+ point_forecast, quantile_forecast = await asyncio.to_thread(
292
+ self._model.forecast,
293
+ horizon,
294
+ [ohlc4_context],
295
+ )
296
+ elapsed = time.time() - started_at
297
+ self._logger.info(
298
+ "[TimesFM] %.2fs | horizon=%d ctx=%d device=%s",
299
+ elapsed,
300
+ horizon,
301
+ context_len,
302
+ self.device,
303
+ )
304
+
305
+ if torch is not None and torch.cuda.is_available():
306
+ torch.cuda.empty_cache()
307
+
308
+ p10, p50, p90, output_validation = self._validate_output_tensors(
309
+ point_forecast=point_forecast,
310
+ quantile_forecast=quantile_forecast,
311
+ horizon=horizon,
312
+ )
313
+
314
+ return {
315
+ "p10": p10,
316
+ "p50": p50,
317
+ "p90": p90,
318
+ "model_name": self.MODEL_HF_ID,
319
+ "context_length": context_len,
320
+ "output_horizon": horizon,
321
+ "input_semantics": {
322
+ "feature_channels": ["ohlc4"],
323
+ "active_forecast_channels": ["ohlc4"],
324
+ "ignored_channels": ["volume"],
325
+ "price_mode": "ohlc4_single_channel",
326
+ "base_signal": "ohlc4",
327
+ "volume_mode": "omitted",
328
+ "amount_mode": "omitted",
329
+ "adapter_mode": "timesfm_native",
330
+ "normalization": "timesfm_internal_revin",
331
+ },
332
+ "input_validation": {
333
+ "series_field": "ohlc4",
334
+ "dtype": str(ohlc4_context.dtype),
335
+ "is_1d": True,
336
+ "finite": True,
337
+ },
338
+ "output_semantics": {
339
+ "forecast_channel": "ohlc4",
340
+ "forecast_mode": "single_future_ohlc4_line",
341
+ "quantile_fields": ["p10", "p50", "p90"],
342
+ "candle_projection": "omitted",
343
+ "reference_baseline": "last_ohlc4",
344
+ },
345
+ "output_validation": output_validation,
346
+ }
347
+ except HTTPException:
348
+ raise
349
+ except Exception as exc:
350
+ self._logger.error("[TimesFM] Forecast failed: %s", exc, exc_info=True)
351
+ raise HTTPException(status_code=500, detail=f"TimesFM prediction failed: {exc}")
backend/forecasting/rules.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, Dict
7
+
8
+
9
+ RULES_PATH = Path(__file__).with_name("AI_MODEL_RULES.md")
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ForecastRuleDocument:
14
+ path: str
15
+ version: str
16
+ content: str
17
+ default_horizon: int
18
+ recommended_context_length: int
19
+ default_line_width: int
20
+
21
+ def to_metadata(self) -> Dict[str, Any]:
22
+ return {
23
+ "path": self.path,
24
+ "version": self.version,
25
+ "default_horizon": self.default_horizon,
26
+ "recommended_context_length": self.recommended_context_length,
27
+ "default_line_width": self.default_line_width,
28
+ }
29
+
30
+
31
+ def load_forecast_rule_document(path: Path | None = None) -> ForecastRuleDocument:
32
+ rules_path = path or RULES_PATH
33
+ content = rules_path.read_text(encoding="utf-8").strip()
34
+ version = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
35
+ return ForecastRuleDocument(
36
+ path=str(rules_path),
37
+ version=version,
38
+ content=content,
39
+ default_horizon=10,
40
+ recommended_context_length=512,
41
+ default_line_width=1,
42
+ )
43
+
44
+
45
+ FORECAST_RULE_DOCUMENT = load_forecast_rule_document()
backend/frontend_assets.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Dict, Iterable
7
+
8
+ from fastapi import FastAPI, HTTPException
9
+ from fastapi.responses import FileResponse, HTMLResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class FrontendAssets:
15
+ root: Path
16
+ index_html: Path
17
+ app_js: Path
18
+ forecast_models_js: Path
19
+ workspace_js: Path
20
+ workspace_css: Path
21
+ background_image: Path
22
+ favicon_svg: Path
23
+
24
+ @property
25
+ def exists(self) -> bool:
26
+ return self.root.exists()
27
+
28
+ def versioned_assets(self) -> tuple[Path, ...]:
29
+ return (
30
+ self.index_html,
31
+ self.app_js,
32
+ self.forecast_models_js,
33
+ self.workspace_js,
34
+ self.workspace_css,
35
+ )
36
+
37
+
38
+ def resolve_frontend_assets(project_root: str | Path) -> FrontendAssets:
39
+ root = Path(project_root) / "frontend"
40
+ return FrontendAssets(
41
+ root=root,
42
+ index_html=root / "index.html",
43
+ app_js=root / "app.js",
44
+ forecast_models_js=root / "forecast-models.js",
45
+ workspace_js=root / "workspace.js",
46
+ workspace_css=root / "workspace.css",
47
+ background_image=root / "AIBG.png",
48
+ favicon_svg=root / "favicon.svg",
49
+ )
50
+
51
+
52
+ def frontend_asset_headers() -> Dict[str, str]:
53
+ return {
54
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
55
+ "Pragma": "no-cache",
56
+ "Expires": "0",
57
+ }
58
+
59
+
60
+ def build_frontend_asset_version(
61
+ *,
62
+ assets: FrontendAssets,
63
+ app_version: str,
64
+ cache_version: str,
65
+ ) -> str:
66
+ version_parts: list[str] = [app_version, cache_version]
67
+ for asset_path in assets.versioned_assets():
68
+ if asset_path.exists():
69
+ version_parts.append(str(int(asset_path.stat().st_mtime)))
70
+ return "-".join(version_parts)
71
+
72
+
73
+ def _serve_asset_file(
74
+ asset_path: Path,
75
+ *,
76
+ media_type: str,
77
+ not_found_detail: str,
78
+ ) -> FileResponse:
79
+ if not asset_path.exists():
80
+ raise HTTPException(status_code=404, detail=not_found_detail)
81
+ return FileResponse(
82
+ str(asset_path),
83
+ media_type=media_type,
84
+ headers=frontend_asset_headers(),
85
+ )
86
+
87
+
88
+ def _register_file_routes(
89
+ app: FastAPI,
90
+ *,
91
+ assets: FrontendAssets,
92
+ ) -> None:
93
+ def add_file_route(
94
+ route_path: str,
95
+ asset_path: Path,
96
+ *,
97
+ media_type: str,
98
+ not_found_detail: str,
99
+ route_name: str,
100
+ ) -> None:
101
+ async def serve_file() -> FileResponse:
102
+ return _serve_asset_file(
103
+ asset_path,
104
+ media_type=media_type,
105
+ not_found_detail=not_found_detail,
106
+ )
107
+
108
+ serve_file.__name__ = route_name
109
+ app.add_api_route(route_path, serve_file, include_in_schema=False, methods=["GET"])
110
+
111
+ file_specs: Iterable[tuple[str, Path, str, str, str]] = (
112
+ (
113
+ "/workspace.js",
114
+ assets.workspace_js,
115
+ "application/javascript",
116
+ "Workspace JS asset not found",
117
+ "serve_workspace_js",
118
+ ),
119
+ (
120
+ "/app.js",
121
+ assets.app_js,
122
+ "application/javascript",
123
+ "App JS asset not found",
124
+ "serve_app_js",
125
+ ),
126
+ (
127
+ "/forecast-models.js",
128
+ assets.forecast_models_js,
129
+ "application/javascript",
130
+ "Forecast model registry asset not found",
131
+ "serve_forecast_models_js",
132
+ ),
133
+ (
134
+ "/workspace.css",
135
+ assets.workspace_css,
136
+ "text/css",
137
+ "Workspace CSS asset not found",
138
+ "serve_workspace_css",
139
+ ),
140
+ (
141
+ "/AIBG.png",
142
+ assets.background_image,
143
+ "image/png",
144
+ "AIBG asset not found",
145
+ "serve_aibg",
146
+ ),
147
+ (
148
+ "/favicon.svg",
149
+ assets.favicon_svg,
150
+ "image/svg+xml",
151
+ "Favicon not found",
152
+ "serve_favicon_svg",
153
+ ),
154
+ (
155
+ "/favicon.ico",
156
+ assets.favicon_svg,
157
+ "image/svg+xml",
158
+ "Favicon not found",
159
+ "serve_favicon_ico",
160
+ ),
161
+ )
162
+ for route_path, asset_path, media_type, not_found_detail, route_name in file_specs:
163
+ add_file_route(
164
+ route_path,
165
+ asset_path,
166
+ media_type=media_type,
167
+ not_found_detail=not_found_detail,
168
+ route_name=route_name,
169
+ )
170
+
171
+
172
+ def register_frontend_assets(
173
+ app: FastAPI,
174
+ *,
175
+ project_root: str | Path,
176
+ app_version: str,
177
+ cache_version: str,
178
+ logger: logging.Logger,
179
+ ) -> None:
180
+ assets = resolve_frontend_assets(project_root)
181
+ if not assets.exists:
182
+ logger.warning("Frontend path not found: %s", assets.root)
183
+ return
184
+
185
+ async def serve_frontend_index() -> HTMLResponse:
186
+ if not assets.index_html.exists():
187
+ raise HTTPException(status_code=404, detail="Frontend index not found")
188
+
189
+ html = assets.index_html.read_text(encoding="utf-8")
190
+ html = html.replace(
191
+ "__FRONTEND_ASSET_VERSION__",
192
+ build_frontend_asset_version(
193
+ assets=assets,
194
+ app_version=app_version,
195
+ cache_version=cache_version,
196
+ ),
197
+ )
198
+ return HTMLResponse(content=html, headers=frontend_asset_headers())
199
+
200
+ app.add_api_route("/", serve_frontend_index, include_in_schema=False, methods=["GET"])
201
+ app.add_api_route("/index.html", serve_frontend_index, include_in_schema=False, methods=["GET"])
202
+ _register_file_routes(app, assets=assets)
203
+ app.mount("/", StaticFiles(directory=str(assets.root), html=True), name="frontend")
204
+ logger.info("Mounted frontend: %s", assets.root)
backend/kronos_adapter.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import logging
6
+ import os
7
+ import random
8
+ import time
9
+ from typing import Any, Dict, Optional, Tuple
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+ from fastapi import HTTPException
14
+
15
+ try:
16
+ import torch
17
+ TORCH_IMPORT_ERROR: Optional[str] = None
18
+ except Exception as exc: # pragma: no cover - environment dependent
19
+ torch = None # type: ignore[assignment]
20
+ TORCH_IMPORT_ERROR = str(exc)
21
+
22
+ KRONOS_AVAILABLE = False
23
+ KRONOS_IMPORT_ERROR: Optional[str] = None
24
+ Kronos = None
25
+ KronosTokenizer = None
26
+ KronosPredictor = None
27
+
28
+ if TORCH_IMPORT_ERROR:
29
+ KRONOS_IMPORT_ERROR = TORCH_IMPORT_ERROR
30
+ else:
31
+ try:
32
+ from backend.kronos_core.model import ( # type: ignore[assignment]
33
+ Kronos,
34
+ KronosPredictor,
35
+ KronosTokenizer,
36
+ )
37
+
38
+ KRONOS_AVAILABLE = True
39
+ except Exception as exc: # pragma: no cover - import environment dependent
40
+ KRONOS_IMPORT_ERROR = str(exc)
41
+
42
+
43
+ class KronosForecaster:
44
+ """Async OHLC4-only wrapper around the vendored Kronos model."""
45
+
46
+ MODEL_HF_ID = os.getenv("KRONOS_MODEL_HF_ID", "NeoQuasar/Kronos-base")
47
+ TOKENIZER_HF_ID = "NeoQuasar/Kronos-Tokenizer-base"
48
+ MAX_CONTEXT = 512
49
+ MIN_CONTEXT = 32
50
+ DEFAULT_SAMPLE_COUNT = 5
51
+ DEFAULT_TEMPERATURE = 0.9
52
+ DEFAULT_TOP_P = 0.9
53
+ DEFAULT_TOP_K = 0
54
+
55
+ def __init__(self, logger: logging.Logger) -> None:
56
+ self._logger = logger
57
+ self._model: Optional[Any] = None
58
+ self._tokenizer: Optional[Any] = None
59
+ self._predictor: Optional[Any] = None
60
+ self._loaded = False
61
+ self._load_lock: Optional[asyncio.Lock] = None
62
+ self._predict_lock: Optional[asyncio.Lock] = None
63
+
64
+ async def _get_load_lock(self) -> asyncio.Lock:
65
+ if self._load_lock is None:
66
+ self._load_lock = asyncio.Lock()
67
+ return self._load_lock
68
+
69
+ async def _get_predict_lock(self) -> asyncio.Lock:
70
+ if self._predict_lock is None:
71
+ self._predict_lock = asyncio.Lock()
72
+ return self._predict_lock
73
+
74
+ @property
75
+ def is_ready(self) -> bool:
76
+ return self._loaded
77
+
78
+ @property
79
+ def device(self) -> str:
80
+ if self._predictor is None:
81
+ return "not_loaded"
82
+ try:
83
+ return str(self._predictor.device)
84
+ except Exception: # pragma: no cover - defensive
85
+ return "cpu"
86
+
87
+ @staticmethod
88
+ def _extract_timestamps(df: pd.DataFrame) -> pd.Series:
89
+ if "timestamps" in df.columns:
90
+ ts = pd.to_datetime(df["timestamps"], utc=True)
91
+ elif "time" in df.columns:
92
+ ts = pd.to_datetime(df["time"], unit="s", utc=True)
93
+ else:
94
+ raise HTTPException(status_code=422, detail="Kronos requires timestamps or time column")
95
+ if ts.isna().any():
96
+ raise HTTPException(status_code=422, detail="Kronos input timestamps contain NaT")
97
+ return ts.reset_index(drop=True)
98
+
99
+ @classmethod
100
+ def _build_ohlc4_proxy_frame(cls, df: pd.DataFrame) -> Tuple[pd.DataFrame, np.ndarray]:
101
+ required_cols = {"open", "high", "low", "close"}
102
+ missing_cols = sorted(required_cols - set(df.columns))
103
+ if missing_cols:
104
+ raise HTTPException(
105
+ status_code=422,
106
+ detail=f"Kronos input is missing OHLC columns: {', '.join(missing_cols)}",
107
+ )
108
+
109
+ ohlc4 = (
110
+ df[["open", "high", "low", "close"]]
111
+ .mean(axis=1)
112
+ .to_numpy(dtype=np.float32, copy=False)
113
+ )
114
+ if ohlc4.ndim != 1 or len(ohlc4) < cls.MIN_CONTEXT:
115
+ raise HTTPException(
116
+ status_code=422,
117
+ detail=f"Kronos requires at least {cls.MIN_CONTEXT} OHLC4 points",
118
+ )
119
+ if not np.isfinite(ohlc4).all():
120
+ raise HTTPException(status_code=422, detail="Kronos input contains non-finite OHLC4 values")
121
+
122
+ proxy = pd.DataFrame(
123
+ {
124
+ "open": ohlc4,
125
+ "high": ohlc4,
126
+ "low": ohlc4,
127
+ "close": ohlc4,
128
+ "volume": np.zeros(len(ohlc4), dtype=np.float32),
129
+ "amount": np.zeros(len(ohlc4), dtype=np.float32),
130
+ }
131
+ )
132
+ return proxy, ohlc4.astype(float)
133
+
134
+ @staticmethod
135
+ def _build_future_timestamps(
136
+ history_timestamps: pd.Series,
137
+ horizon: int,
138
+ step_seconds: int,
139
+ ) -> pd.Series:
140
+ last_ts = pd.Timestamp(history_timestamps.iloc[-1])
141
+ last_ts = last_ts.tz_convert("UTC") if last_ts.tzinfo is not None else last_ts.tz_localize("UTC")
142
+ delta = pd.to_timedelta(step_seconds, unit="s")
143
+ future_index = [last_ts + (delta * (step + 1)) for step in range(horizon)]
144
+ return pd.Series(pd.DatetimeIndex(future_index), copy=False)
145
+
146
+ @staticmethod
147
+ def _stable_seed(
148
+ symbol: str,
149
+ interval: str,
150
+ horizon: int,
151
+ context_length: int,
152
+ last_timestamp: pd.Timestamp,
153
+ ) -> int:
154
+ payload = "|".join(
155
+ [
156
+ symbol,
157
+ interval,
158
+ str(horizon),
159
+ str(context_length),
160
+ str(int(last_timestamp.timestamp())),
161
+ ]
162
+ )
163
+ return int(hashlib.sha256(payload.encode("utf-8")).hexdigest()[:8], 16)
164
+
165
+ @staticmethod
166
+ def _build_output_validation(
167
+ p10: np.ndarray,
168
+ p50: np.ndarray,
169
+ p90: np.ndarray,
170
+ samples: np.ndarray,
171
+ ) -> Dict[str, Any]:
172
+ quantiles_monotonic = bool(
173
+ np.all(p10 <= (p50 + 1e-6))
174
+ and np.all(p50 <= (p90 + 1e-6))
175
+ )
176
+ if not quantiles_monotonic:
177
+ raise HTTPException(status_code=500, detail="Kronos returned non-monotonic sample quantiles")
178
+ if not (np.isfinite(p10).all() and np.isfinite(p50).all() and np.isfinite(p90).all()):
179
+ raise HTTPException(status_code=500, detail="Kronos output contains non-finite values")
180
+
181
+ return {
182
+ "sample_shape": [int(dim) for dim in samples.shape],
183
+ "quantile_shape": [1, int(p50.shape[0]), 3],
184
+ "quantiles_monotonic": quantiles_monotonic,
185
+ "quantile_source": "sample_paths",
186
+ }
187
+
188
+ async def _lazy_load(self) -> None:
189
+ if self._loaded:
190
+ return
191
+ load_lock = await self._get_load_lock()
192
+ async with load_lock:
193
+ if self._loaded:
194
+ return
195
+ if not KRONOS_AVAILABLE or Kronos is None or KronosTokenizer is None or KronosPredictor is None:
196
+ raise HTTPException(
197
+ status_code=503,
198
+ detail=(
199
+ "Kronos is unavailable. Ensure backend/kronos_core is present and "
200
+ "dependencies from its requirements are installed."
201
+ ),
202
+ )
203
+ try:
204
+ self._logger.info("[Kronos] Loading %s ...", self.MODEL_HF_ID)
205
+ tokenizer = await asyncio.to_thread(
206
+ KronosTokenizer.from_pretrained,
207
+ self.TOKENIZER_HF_ID,
208
+ )
209
+ model = await asyncio.to_thread(
210
+ Kronos.from_pretrained,
211
+ self.MODEL_HF_ID,
212
+ )
213
+ predictor = KronosPredictor(
214
+ model,
215
+ tokenizer,
216
+ device="cuda:0" if torch is not None and torch.cuda.is_available() else "cpu",
217
+ max_context=self.MAX_CONTEXT,
218
+ )
219
+ self._tokenizer = tokenizer
220
+ self._model = model
221
+ self._predictor = predictor
222
+ self._loaded = True
223
+ self._logger.info("[Kronos] Ready on %s", self.device)
224
+ except Exception as exc:
225
+ self._logger.error("[Kronos] Init failed: %s", exc, exc_info=True)
226
+ raise HTTPException(status_code=500, detail=f"Kronos init failed: {exc}")
227
+
228
+ async def forecast(
229
+ self,
230
+ df: pd.DataFrame,
231
+ horizon: int,
232
+ interval: str,
233
+ step_seconds: int,
234
+ symbol: str = "",
235
+ ) -> Dict[str, Any]:
236
+ await self._lazy_load()
237
+ if self._predictor is None:
238
+ raise HTTPException(status_code=500, detail="Kronos predictor is not initialized")
239
+
240
+ history_timestamps = self._extract_timestamps(df)
241
+ proxy_df, _ = self._build_ohlc4_proxy_frame(df)
242
+ context_length = min(len(proxy_df), self.MAX_CONTEXT)
243
+ proxy_context = proxy_df.tail(context_length).reset_index(drop=True)
244
+ x_timestamp = history_timestamps.tail(context_length).reset_index(drop=True)
245
+ y_timestamp = self._build_future_timestamps(
246
+ history_timestamps=x_timestamp,
247
+ horizon=horizon,
248
+ step_seconds=step_seconds,
249
+ )
250
+
251
+ sample_count = self.DEFAULT_SAMPLE_COUNT
252
+ seed = self._stable_seed(
253
+ symbol=symbol or "UNKNOWN",
254
+ interval=interval,
255
+ horizon=horizon,
256
+ context_length=context_length,
257
+ last_timestamp=(
258
+ pd.Timestamp(x_timestamp.iloc[-1]).tz_convert("UTC")
259
+ if pd.Timestamp(x_timestamp.iloc[-1]).tzinfo is not None
260
+ else pd.Timestamp(x_timestamp.iloc[-1]).tz_localize("UTC")
261
+ ),
262
+ )
263
+
264
+ predict_lock = await self._get_predict_lock()
265
+ async with predict_lock:
266
+ try:
267
+ random.seed(seed)
268
+ np.random.seed(seed % (2**32 - 1))
269
+ if torch is not None:
270
+ torch.manual_seed(seed)
271
+ if torch.cuda.is_available():
272
+ torch.cuda.manual_seed_all(seed)
273
+
274
+ t0 = time.time()
275
+ sample_tensor = await asyncio.to_thread(
276
+ self._predictor.predict_samples,
277
+ proxy_context,
278
+ x_timestamp,
279
+ y_timestamp,
280
+ horizon,
281
+ self.DEFAULT_TEMPERATURE,
282
+ self.DEFAULT_TOP_K,
283
+ self.DEFAULT_TOP_P,
284
+ sample_count,
285
+ False,
286
+ )
287
+ elapsed = time.time() - t0
288
+ self._logger.info(
289
+ "[Kronos] %.2fs | horizon=%d ctx=%d samples=%d device=%s",
290
+ elapsed,
291
+ horizon,
292
+ context_length,
293
+ sample_count,
294
+ self.device,
295
+ )
296
+ except Exception as exc:
297
+ self._logger.error("[Kronos] Forecast failed: %s", exc, exc_info=True)
298
+ raise HTTPException(status_code=500, detail=f"Kronos prediction failed: {exc}")
299
+
300
+ samples = np.asarray(sample_tensor, dtype=float)
301
+ if samples.ndim != 3 or samples.shape[0] != sample_count or samples.shape[1] != horizon or samples.shape[2] < 4:
302
+ raise HTTPException(
303
+ status_code=500,
304
+ detail=f"Kronos sample tensor has invalid shape: {list(samples.shape)}",
305
+ )
306
+
307
+ ohlc4_samples = samples[:, :, :4].mean(axis=2)
308
+ p10 = np.quantile(ohlc4_samples, 0.1, axis=0).astype(float)
309
+ p50 = np.quantile(ohlc4_samples, 0.5, axis=0).astype(float)
310
+ p90 = np.quantile(ohlc4_samples, 0.9, axis=0).astype(float)
311
+ output_validation = self._build_output_validation(p10, p50, p90, samples)
312
+
313
+ return {
314
+ "p10": p10,
315
+ "p50": p50,
316
+ "p90": p90,
317
+ "model_name": self.MODEL_HF_ID,
318
+ "tokenizer_name": self.TOKENIZER_HF_ID,
319
+ "context_length": context_length,
320
+ "output_horizon": horizon,
321
+ "sample_count": sample_count,
322
+ "seed": seed,
323
+ "input_semantics": {
324
+ "feature_channels": ["ohlc4"],
325
+ "active_forecast_channels": ["ohlc4"],
326
+ "proxy_channels": ["open", "high", "low", "close"],
327
+ "ignored_channels": ["volume", "amount"],
328
+ "price_mode": "ohlc4_single_channel",
329
+ "base_signal": "ohlc4",
330
+ "volume_mode": "synthetic_zero",
331
+ "amount_mode": "synthetic_zero",
332
+ "adapter_mode": "kronos_ohlc4_proxy",
333
+ },
334
+ "input_validation": {
335
+ "series_field": "ohlc4",
336
+ "dtype": "float32",
337
+ "context_length": context_length,
338
+ "finite": True,
339
+ },
340
+ "output_semantics": {
341
+ "forecast_channel": "ohlc4",
342
+ "forecast_mode": "single_future_ohlc4_line",
343
+ "quantile_fields": ["p10", "p50", "p90"],
344
+ "quantile_source": "sample_paths",
345
+ "candle_projection": "omitted",
346
+ "reference_baseline": "last_ohlc4",
347
+ },
348
+ "output_validation": output_validation,
349
+ }
backend/kronos_core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Vendored Kronos model package."""
backend/kronos_core/model/kronos.py CHANGED
@@ -2,12 +2,41 @@ import numpy as np
2
  import pandas as pd
3
  import torch
4
  from huggingface_hub import PyTorchModelHubMixin
5
- import sys
6
 
7
  from tqdm import trange
8
 
9
- sys.path.append("../")
10
- from model.module import *
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
 
13
  class KronosTokenizer(nn.Module, PyTorchModelHubMixin):
@@ -176,6 +205,9 @@ class KronosTokenizer(nn.Module, PyTorchModelHubMixin):
176
  z = self.head(z)
177
  return z
178
 
 
 
 
179
 
180
  class Kronos(nn.Module, PyTorchModelHubMixin):
181
  """
@@ -327,6 +359,9 @@ class Kronos(nn.Module, PyTorchModelHubMixin):
327
  x2 = self.dep_layer(context, sibling_embed, key_padding_mask=padding_mask)
328
  return self.head.cond_forward(x2)
329
 
 
 
 
330
 
331
  def top_k_top_p_filtering(
332
  logits,
@@ -386,7 +421,22 @@ def sample_from_logits(logits, temperature=1.0, top_k=None, top_p=None, sample_l
386
  return x
387
 
388
 
389
- def auto_regressive_inference(tokenizer, model, x, x_stamp, y_stamp, max_context, pred_len, clip=5, T=1.0, top_k=0, top_p=0.99, sample_count=5, verbose=False, return_samples=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  with torch.no_grad():
391
  x = torch.clip(x, -clip, clip)
392
 
@@ -464,7 +514,6 @@ def auto_regressive_inference(tokenizer, model, x, x_stamp, y_stamp, max_context
464
  z = tokenizer.decode(input_tokens, half=True)
465
  z = z.reshape(-1, sample_count, z.size(1), z.size(2))
466
  preds = z.cpu().numpy()
467
-
468
  if not return_samples:
469
  preds = np.mean(preds, axis=1)
470
 
@@ -504,18 +553,108 @@ class KronosPredictor:
504
 
505
  self.device = device
506
 
507
- self.tokenizer = self.tokenizer.to(self.device)
508
- self.model = self.model.to(self.device)
509
-
510
- def generate(self, x, x_stamp, y_stamp, pred_len, T, top_k, top_p, sample_count, verbose, return_samples=False):
 
 
 
 
 
 
 
 
 
 
 
 
511
 
512
  x_tensor = torch.from_numpy(np.array(x).astype(np.float32)).to(self.device)
513
  x_stamp_tensor = torch.from_numpy(np.array(x_stamp).astype(np.float32)).to(self.device)
514
  y_stamp_tensor = torch.from_numpy(np.array(y_stamp).astype(np.float32)).to(self.device)
515
 
516
- preds = auto_regressive_inference(self.tokenizer, self.model, x_tensor, x_stamp_tensor, y_stamp_tensor, self.max_context, pred_len,
517
- self.clip, T, top_k, top_p, sample_count, verbose, return_samples=return_samples)
518
- preds = preds[:, -pred_len:, :]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  return preds
520
 
521
  def predict(self, df, x_timestamp, y_timestamp, pred_len, T=1.0, top_k=0, top_p=0.9, sample_count=1, verbose=True):
 
2
  import pandas as pd
3
  import torch
4
  from huggingface_hub import PyTorchModelHubMixin
 
5
 
6
  from tqdm import trange
7
 
8
+ from .module import *
9
+
10
+
11
+ def _module_has_meta_tensors(module: nn.Module) -> bool:
12
+ return any(getattr(param, "is_meta", False) for param in module.parameters()) or any(
13
+ getattr(buffer, "is_meta", False) for buffer in module.buffers()
14
+ )
15
+
16
+
17
+ def _compat_load_state_dict(
18
+ module: nn.Module,
19
+ state_dict,
20
+ *,
21
+ strict: bool = True,
22
+ assign: bool = False,
23
+ ):
24
+ use_assign = assign or _module_has_meta_tensors(module)
25
+ if use_assign:
26
+ try:
27
+ return nn.Module.load_state_dict(module, state_dict, strict=strict, assign=True)
28
+ except TypeError:
29
+ pass
30
+ return nn.Module.load_state_dict(module, state_dict, strict=strict)
31
+
32
+
33
+ def _move_module_to_device(module: nn.Module, device: str):
34
+ if _module_has_meta_tensors(module):
35
+ try:
36
+ return module.to_empty(device=device)
37
+ except TypeError:
38
+ return module.to_empty(device=torch.device(device))
39
+ return module.to(device)
40
 
41
 
42
  class KronosTokenizer(nn.Module, PyTorchModelHubMixin):
 
205
  z = self.head(z)
206
  return z
207
 
208
+ def load_state_dict(self, state_dict, strict=True, assign=False):
209
+ return _compat_load_state_dict(self, state_dict, strict=strict, assign=assign)
210
+
211
 
212
  class Kronos(nn.Module, PyTorchModelHubMixin):
213
  """
 
359
  x2 = self.dep_layer(context, sibling_embed, key_padding_mask=padding_mask)
360
  return self.head.cond_forward(x2)
361
 
362
+ def load_state_dict(self, state_dict, strict=True, assign=False):
363
+ return _compat_load_state_dict(self, state_dict, strict=strict, assign=assign)
364
+
365
 
366
  def top_k_top_p_filtering(
367
  logits,
 
421
  return x
422
 
423
 
424
+ def auto_regressive_inference(
425
+ tokenizer,
426
+ model,
427
+ x,
428
+ x_stamp,
429
+ y_stamp,
430
+ max_context,
431
+ pred_len,
432
+ clip=5,
433
+ T=1.0,
434
+ top_k=0,
435
+ top_p=0.99,
436
+ sample_count=5,
437
+ verbose=False,
438
+ return_samples=False,
439
+ ):
440
  with torch.no_grad():
441
  x = torch.clip(x, -clip, clip)
442
 
 
514
  z = tokenizer.decode(input_tokens, half=True)
515
  z = z.reshape(-1, sample_count, z.size(1), z.size(2))
516
  preds = z.cpu().numpy()
 
517
  if not return_samples:
518
  preds = np.mean(preds, axis=1)
519
 
 
553
 
554
  self.device = device
555
 
556
+ self.tokenizer = _move_module_to_device(self.tokenizer, self.device)
557
+ self.model = _move_module_to_device(self.model, self.device)
558
+
559
+ def generate(
560
+ self,
561
+ x,
562
+ x_stamp,
563
+ y_stamp,
564
+ pred_len,
565
+ T,
566
+ top_k,
567
+ top_p,
568
+ sample_count,
569
+ verbose,
570
+ return_samples=False,
571
+ ):
572
 
573
  x_tensor = torch.from_numpy(np.array(x).astype(np.float32)).to(self.device)
574
  x_stamp_tensor = torch.from_numpy(np.array(x_stamp).astype(np.float32)).to(self.device)
575
  y_stamp_tensor = torch.from_numpy(np.array(y_stamp).astype(np.float32)).to(self.device)
576
 
577
+ preds = auto_regressive_inference(
578
+ self.tokenizer,
579
+ self.model,
580
+ x_tensor,
581
+ x_stamp_tensor,
582
+ y_stamp_tensor,
583
+ self.max_context,
584
+ pred_len,
585
+ self.clip,
586
+ T,
587
+ top_k,
588
+ top_p,
589
+ sample_count,
590
+ verbose,
591
+ return_samples=return_samples,
592
+ )
593
+ if return_samples:
594
+ preds = preds[:, :, -pred_len:, :]
595
+ else:
596
+ preds = preds[:, -pred_len:, :]
597
+ return preds
598
+
599
+ def predict_samples(
600
+ self,
601
+ df,
602
+ x_timestamp,
603
+ y_timestamp,
604
+ pred_len,
605
+ T=1.0,
606
+ top_k=0,
607
+ top_p=0.9,
608
+ sample_count=5,
609
+ verbose=False,
610
+ ):
611
+ if not isinstance(df, pd.DataFrame):
612
+ raise ValueError("Input must be a pandas DataFrame.")
613
+
614
+ if not all(col in df.columns for col in self.price_cols):
615
+ raise ValueError(f"Price columns {self.price_cols} not found in DataFrame.")
616
+
617
+ df = df.copy()
618
+ if self.vol_col not in df.columns:
619
+ df[self.vol_col] = 0.0
620
+ df[self.amt_vol] = 0.0
621
+ if self.amt_vol not in df.columns and self.vol_col in df.columns:
622
+ df[self.amt_vol] = df[self.vol_col] * df[self.price_cols].mean(axis=1)
623
+
624
+ if df[self.price_cols + [self.vol_col, self.amt_vol]].isnull().values.any():
625
+ raise ValueError("Input DataFrame contains NaN values in price or volume columns.")
626
+
627
+ x_time_df = calc_time_stamps(x_timestamp)
628
+ y_time_df = calc_time_stamps(y_timestamp)
629
+
630
+ x = df[self.price_cols + [self.vol_col, self.amt_vol]].values.astype(np.float32)
631
+ x_stamp = x_time_df.values.astype(np.float32)
632
+ y_stamp = y_time_df.values.astype(np.float32)
633
+
634
+ x_mean, x_std = np.mean(x, axis=0), np.std(x, axis=0)
635
+
636
+ x = (x - x_mean) / (x_std + 1e-5)
637
+ x = np.clip(x, -self.clip, self.clip)
638
+
639
+ x = x[np.newaxis, :]
640
+ x_stamp = x_stamp[np.newaxis, :]
641
+ y_stamp = y_stamp[np.newaxis, :]
642
+
643
+ preds = self.generate(
644
+ x,
645
+ x_stamp,
646
+ y_stamp,
647
+ pred_len,
648
+ T,
649
+ top_k,
650
+ top_p,
651
+ sample_count,
652
+ verbose,
653
+ return_samples=True,
654
+ )
655
+
656
+ preds = preds.squeeze(0)
657
+ preds = preds * (x_std + 1e-5) + x_mean
658
  return preds
659
 
660
  def predict(self, df, x_timestamp, y_timestamp, pred_len, T=1.0, top_k=0, top_p=0.9, sample_count=1, verbose=True):
backend/launcher.py CHANGED
@@ -2,8 +2,6 @@ from __future__ import annotations
2
 
3
  import logging
4
  import os
5
- import socket
6
- import sys
7
  import threading
8
  import time
9
  import webbrowser
@@ -11,6 +9,7 @@ from pathlib import Path
11
  from typing import Any
12
 
13
  import uvicorn
 
14
 
15
  logging.basicConfig(
16
  level=logging.INFO,
@@ -19,8 +18,19 @@ logging.basicConfig(
19
  logger = logging.getLogger("aiforecast-launcher")
20
 
21
  DEFAULT_HOST: str = "127.0.0.1"
22
- SERVER_BOOT_DELAY_SECONDS: float = 2.5
 
23
  PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
 
 
 
 
 
 
 
 
 
 
24
 
25
  BANNER = r"""
26
  _ _____ _____ _
@@ -35,65 +45,94 @@ def print_banner() -> None:
35
  """Render a clean startup banner in the local console."""
36
  os.system("cls" if os.name == "nt" else "clear")
37
  print("\033[96m" + BANNER + "\033[0m")
38
- print(" [*] Dang khoi dong he thong phan tich AI...")
39
- print(" [*] Dang nap du lieu thi truong...")
40
  print(" [*] Trinh duyet se tu dong mo khi server san sang.")
41
  print()
42
 
43
 
44
- def load_app() -> Any:
45
- """Import the FastAPI app after runtime settings are in place."""
46
- project_root = str(PROJECT_ROOT)
47
- if project_root not in sys.path:
48
- sys.path.insert(0, project_root)
49
- from backend.main import app as fastapi_app
50
- return fastapi_app
 
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
- def find_free_port(host: str = DEFAULT_HOST) -> int:
54
- """Ask the OS for a currently available local TCP port."""
55
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
56
- server_socket.bind((host, 0))
57
- server_socket.listen(1)
58
- return int(server_socket.getsockname()[1])
59
 
60
 
61
  def resolve_server_port() -> int:
62
- """Use PORT when defined, otherwise choose a random free local port."""
63
- raw_port = os.getenv("PORT", "").strip()
64
- if raw_port:
65
- try:
66
- return int(raw_port)
67
- except ValueError as exc:
68
- raise ValueError(f"Invalid PORT value: {raw_port}") from exc
69
-
70
- port = find_free_port()
71
- os.environ["PORT"] = str(port)
72
- return port
73
-
74
-
75
- def open_browser(url: str) -> None:
76
- """Wait briefly for the server to start, then open the dashboard."""
77
- time.sleep(SERVER_BOOT_DELAY_SECONDS)
 
 
 
 
 
78
  logger.info("Dang mo bang dieu khien tai %s", url)
79
  webbrowser.open(url)
80
 
81
 
82
  if __name__ == "__main__":
83
  print_banner()
 
84
 
85
  host = DEFAULT_HOST
86
  port = resolve_server_port()
87
  url = f"http://{host}:{port}"
88
  app = load_app()
 
 
 
89
 
90
- logger.info("Khoi dong Backend Core tren %s", url)
91
- threading.Thread(target=open_browser, args=(url,), daemon=True).start()
 
 
 
 
92
 
93
  try:
94
- uvicorn.run(app, host=host, port=port, log_level="warning")
 
 
 
 
95
  except Exception as exc:
96
  logger.exception("Loi khoi dong he thong: %s", exc)
97
- print("\n [!] Co loi xay ra trong qua trinh khoi dong.")
98
- print(" [!] Nhan Enter de thoat...")
99
- input()
 
2
 
3
  import logging
4
  import os
 
 
5
  import threading
6
  import time
7
  import webbrowser
 
9
  from typing import Any
10
 
11
  import uvicorn
12
+ from backend import server_runtime
13
 
14
  logging.basicConfig(
15
  level=logging.INFO,
 
18
  logger = logging.getLogger("aiforecast-launcher")
19
 
20
  DEFAULT_HOST: str = "127.0.0.1"
21
+ SERVER_READY_TIMEOUT_SECONDS: float = 15.0
22
+ SERVER_READY_POLL_INTERVAL_SECONDS: float = 0.25
23
  PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
24
+ ENV_FILE: Path = PROJECT_ROOT / ".env"
25
+ CONSOLE_MODE_ENV: str = "AIFORECAST_CONSOLE_MODE"
26
+
27
+
28
+ def load_runtime_env() -> None:
29
+ """Load local runtime settings before resolving the server port."""
30
+ server_runtime.load_runtime_env(ENV_FILE, override=False)
31
+
32
+
33
+ load_runtime_env()
34
 
35
  BANNER = r"""
36
  _ _____ _____ _
 
45
  """Render a clean startup banner in the local console."""
46
  os.system("cls" if os.name == "nt" else "clear")
47
  print("\033[96m" + BANNER + "\033[0m")
48
+ print(" [*] Dang khoi dong SuperAI Forecast...")
49
+ print(" [*] Dang khoi tao he thong du bao Kronos / TimesFM / Chronos...")
50
  print(" [*] Trinh duyet se tu dong mo khi server san sang.")
51
  print()
52
 
53
 
54
+ def apply_console_window_mode() -> None:
55
+ """Minimize or hide this console window on Windows without affecting logs."""
56
+ if os.name != "nt":
57
+ return
58
+
59
+ mode = os.getenv(CONSOLE_MODE_ENV, "minimize").strip().lower()
60
+ if mode in {"", "show", "visible", "keep"}:
61
+ return
62
 
63
+ try:
64
+ import ctypes
65
+
66
+ kernel32 = ctypes.windll.kernel32
67
+ user32 = ctypes.windll.user32
68
+ console_window = kernel32.GetConsoleWindow()
69
+ if not console_window:
70
+ return
71
+
72
+ show_code = 0 if mode == "hide" else 6
73
+ user32.ShowWindow(console_window, show_code)
74
+ except Exception as exc:
75
+ logger.debug("Khong the doi che do cua so console: %s", exc)
76
 
77
+
78
+ def load_app() -> Any:
79
+ """Import the FastAPI app after runtime settings are in place."""
80
+ return server_runtime.load_fastapi_app(PROJECT_ROOT)
 
 
81
 
82
 
83
  def resolve_server_port() -> int:
84
+ """Use PORT when available and free, otherwise choose a free local port."""
85
+ return server_runtime.resolve_server_port(DEFAULT_HOST, logger=logger)
86
+
87
+
88
+ def wait_for_server_start(server: uvicorn.Server, startup_finished: threading.Event) -> bool:
89
+ """Wait until this Uvicorn instance reports a successful startup."""
90
+ deadline = time.monotonic() + SERVER_READY_TIMEOUT_SECONDS
91
+ while time.monotonic() < deadline:
92
+ if server.started:
93
+ return True
94
+ if startup_finished.is_set():
95
+ return False
96
+ time.sleep(SERVER_READY_POLL_INTERVAL_SECONDS)
97
+ return False
98
+
99
+
100
+ def open_browser(url: str, server: uvicorn.Server, startup_finished: threading.Event) -> None:
101
+ """Open the dashboard only after this backend instance is ready."""
102
+ if not wait_for_server_start(server, startup_finished):
103
+ logger.warning("Bo qua mo trinh duyet vi backend khong san sang tai %s", url)
104
+ return
105
  logger.info("Dang mo bang dieu khien tai %s", url)
106
  webbrowser.open(url)
107
 
108
 
109
  if __name__ == "__main__":
110
  print_banner()
111
+ apply_console_window_mode()
112
 
113
  host = DEFAULT_HOST
114
  port = resolve_server_port()
115
  url = f"http://{host}:{port}"
116
  app = load_app()
117
+ config = uvicorn.Config(app, host=host, port=port, log_level="warning")
118
+ server = uvicorn.Server(config)
119
+ startup_finished = threading.Event()
120
 
121
+ logger.info("Khoi dong SuperAI Forecast Backend tren %s", url)
122
+ threading.Thread(
123
+ target=open_browser,
124
+ args=(url, server, startup_finished),
125
+ daemon=True,
126
+ ).start()
127
 
128
  try:
129
+ server.run()
130
+ except SystemExit as exc:
131
+ if exc.code not in (0, None):
132
+ logger.error("Loi khoi dong he thong tren %s (exit=%s)", url, exc.code)
133
+ raise
134
  except Exception as exc:
135
  logger.exception("Loi khoi dong he thong: %s", exc)
136
+ raise
137
+ finally:
138
+ startup_finished.set()
backend/main.py CHANGED
The diff for this file is too large to render. See raw diff
 
backend/runtime_utils.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations
2
 
3
  import os
 
4
  from copy import deepcopy
5
  from dataclasses import dataclass
6
  from typing import Any, List
@@ -15,6 +16,22 @@ class RuntimePaths:
15
  project_root: str
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def parse_cors_origins(raw_origins: str) -> List[str]:
19
  origins = [origin.strip() for origin in raw_origins.split(",") if origin.strip()]
20
  return origins or ["*"]
 
1
  from __future__ import annotations
2
 
3
  import os
4
+ import socket
5
  from copy import deepcopy
6
  from dataclasses import dataclass
7
  from typing import Any, List
 
16
  project_root: str
17
 
18
 
19
+ def can_bind_tcp_port(host: str, port: int) -> bool:
20
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
21
+ try:
22
+ server_socket.bind((host, port))
23
+ except OSError:
24
+ return False
25
+ return True
26
+
27
+
28
+ def find_free_tcp_port(host: str = "127.0.0.1") -> int:
29
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
30
+ server_socket.bind((host, 0))
31
+ server_socket.listen(1)
32
+ return int(server_socket.getsockname()[1])
33
+
34
+
35
  def parse_cors_origins(raw_origins: str) -> List[str]:
36
  origins = [origin.strip() for origin in raw_origins.split(",") if origin.strip()]
37
  return origins or ["*"]
backend/server_runtime.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Mapping, MutableMapping, Protocol
7
+
8
+ from dotenv import load_dotenv
9
+
10
+ from backend.runtime_utils import can_bind_tcp_port, find_free_tcp_port
11
+
12
+ DEFAULT_HUGGINGFACE_PORT = 7860
13
+
14
+
15
+ class LoggerLike(Protocol):
16
+ def warning(self, msg: str, *args: Any, **kwargs: Any) -> None: ...
17
+
18
+
19
+ def is_huggingface_space(env: Mapping[str, str] | None = None) -> bool:
20
+ runtime_env = env or os.environ
21
+ return bool(runtime_env.get("SPACE_ID") or runtime_env.get("SPACE_HOST"))
22
+
23
+
24
+ def bootstrap_runtime_port(
25
+ env: MutableMapping[str, str] | None = None,
26
+ *,
27
+ huggingface_port: int = DEFAULT_HUGGINGFACE_PORT,
28
+ ) -> None:
29
+ runtime_env = env or os.environ
30
+ if is_huggingface_space(runtime_env) and not runtime_env.get("PORT", "").strip():
31
+ runtime_env["PORT"] = str(huggingface_port)
32
+
33
+
34
+ def load_runtime_env(
35
+ env_file: Path,
36
+ *,
37
+ override: bool = False,
38
+ ) -> bool:
39
+ if not env_file.exists():
40
+ return False
41
+ load_dotenv(env_file, override=override)
42
+ return True
43
+
44
+
45
+ def load_fastapi_app(
46
+ project_root: Path,
47
+ *,
48
+ module_name: str = "backend.main",
49
+ attr_name: str = "app",
50
+ ) -> Any:
51
+ project_root_str = str(project_root)
52
+ if project_root_str not in sys.path:
53
+ sys.path.insert(0, project_root_str)
54
+
55
+ module = __import__(module_name, fromlist=[attr_name])
56
+ return getattr(module, attr_name)
57
+
58
+
59
+ def resolve_server_port(
60
+ host: str,
61
+ *,
62
+ env: MutableMapping[str, str] | None = None,
63
+ logger: LoggerLike | None = None,
64
+ huggingface_port: int = DEFAULT_HUGGINGFACE_PORT,
65
+ port_checker: Callable[[str, int], bool] = can_bind_tcp_port,
66
+ free_port_finder: Callable[[str], int] = find_free_tcp_port,
67
+ ) -> int:
68
+ runtime_env = env or os.environ
69
+ raw_port = runtime_env.get("PORT", "").strip()
70
+
71
+ if raw_port:
72
+ try:
73
+ configured_port = int(raw_port)
74
+ except ValueError as exc:
75
+ raise ValueError(f"Invalid PORT value: {raw_port}") from exc
76
+
77
+ if is_huggingface_space(runtime_env) or port_checker(host, configured_port):
78
+ return configured_port
79
+
80
+ fallback_port = free_port_finder(host)
81
+ runtime_env["PORT"] = str(fallback_port)
82
+ if logger is not None:
83
+ logger.warning(
84
+ "PORT %s dang ban tren %s, tu dong chuyen sang cong %s",
85
+ configured_port,
86
+ host,
87
+ fallback_port,
88
+ )
89
+ return fallback_port
90
+
91
+ if is_huggingface_space(runtime_env):
92
+ runtime_env["PORT"] = str(huggingface_port)
93
+ return huggingface_port
94
+
95
+ fallback_port = free_port_finder(host)
96
+ runtime_env["PORT"] = str(fallback_port)
97
+ return fallback_port
backend/startup_utils.py CHANGED
@@ -83,17 +83,32 @@ async def warmup_timesfm(
83
  startup_timesfm_state: MutableMapping[str, Any],
84
  logger: LoggerLike,
85
  ) -> None:
86
- startup_timesfm_state["warming"] = True
87
- startup_timesfm_state["last_error"] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  try:
89
  await forecaster._lazy_load()
90
- startup_timesfm_state["loaded"] = forecaster.is_ready
91
- startup_timesfm_state["device"] = forecaster.device
92
- logger.info("[startup] TimesFM warmup finished on %s", forecaster.device)
93
  except Exception as ex:
94
- startup_timesfm_state["loaded"] = False
95
- startup_timesfm_state["device"] = forecaster.device
96
- startup_timesfm_state["last_error"] = str(ex)
97
- logger.warning("[startup] TimesFM warmup failed: %s", ex)
98
  finally:
99
- startup_timesfm_state["warming"] = False
 
83
  startup_timesfm_state: MutableMapping[str, Any],
84
  logger: LoggerLike,
85
  ) -> None:
86
+ await warmup_forecaster(
87
+ forecaster=forecaster,
88
+ startup_state=startup_timesfm_state,
89
+ logger=logger,
90
+ label="TimesFM",
91
+ )
92
+
93
+
94
+ async def warmup_forecaster(
95
+ *,
96
+ forecaster: ForecasterLike,
97
+ startup_state: MutableMapping[str, Any],
98
+ logger: LoggerLike,
99
+ label: str,
100
+ ) -> None:
101
+ startup_state["warming"] = True
102
+ startup_state["last_error"] = None
103
  try:
104
  await forecaster._lazy_load()
105
+ startup_state["loaded"] = forecaster.is_ready
106
+ startup_state["device"] = forecaster.device
107
+ logger.info("[startup] %s warmup finished on %s", label, forecaster.device)
108
  except Exception as ex:
109
+ startup_state["loaded"] = False
110
+ startup_state["device"] = forecaster.device
111
+ startup_state["last_error"] = str(ex)
112
+ logger.warning("[startup] %s warmup failed: %s", label, ex)
113
  finally:
114
+ startup_state["warming"] = False
backend/test_api_regressions.py CHANGED
@@ -18,6 +18,117 @@ class ApiRegressionTests(unittest.TestCase):
18
  cls.client = TestClient(main.app)
19
  cls.symbol_a, cls.symbol_b = list(main.SYMBOLS.keys())[:2]
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def test_cache_stats_requires_runtime_admin_token(self) -> None:
22
  with patch.object(main, "ADMIN_TOKEN", "test-admin-token"):
23
  unauthorized = self.client.get("/api/cache/stats")
@@ -35,6 +146,36 @@ class ApiRegressionTests(unittest.TestCase):
35
  )
36
  self.assertEqual(authorized.status_code, 200)
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def test_watchlist_tickers_deduplicates_and_reports_invalid_symbols(self) -> None:
39
  async def fake_fetch_ticker(symbol: str) -> dict[str, object]:
40
  return {"symbol": symbol, "price": 123.45}
@@ -95,6 +236,116 @@ class ApiRegressionTests(unittest.TestCase):
95
  self.assertNotIn("binance", main._get_source_priority("EURUSD"))
96
  self.assertEqual(main._get_source_priority("DXY"), ["yfinance", "twelvedata"])
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  def test_crypto_dynamic_source_mappings_are_derived_for_hf_safe_fallbacks(self) -> None:
99
  self.assertEqual(main._get_symbol_mapping("BTCUSD", "twelvedata"), "BTC/USD")
100
  self.assertEqual(main._get_symbol_mapping("BTCUSD", "yfinance"), "BTC-USD")
@@ -466,7 +717,7 @@ class ApiRegressionTests(unittest.TestCase):
466
  self.assertEqual({item[0] for item in calls}, {"twelvedata"})
467
  self.assertEqual({item[2] for item in calls}, {"4h"})
468
 
469
- def test_kronos_feature_prep_collapses_prices_to_ohlc4_and_zeroes_volume_channels(self) -> None:
470
  df = main.pd.DataFrame(
471
  [
472
  {"open": 1.0, "high": 1.1, "low": 0.9, "close": 1.05, "volume": 0.0, "amount": 0.0},
@@ -474,13 +725,13 @@ class ApiRegressionTests(unittest.TestCase):
474
  ]
475
  )
476
 
477
- prepared = main.KronosForecaster._prepare_feature_frame(df)
478
  expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32)
479
 
480
  self.assertEqual(list(prepared.columns), ["ohlc4"])
481
  self.assertTrue(main.np.allclose(prepared["ohlc4"].values, expected_ohlc4.values))
482
 
483
- def test_kronos_feature_prep_ignores_upstream_volume_and_amount_noise(self) -> None:
484
  df = main.pd.DataFrame(
485
  [
486
  {"open": 10.0, "high": 11.0, "low": 9.0, "close": 10.5, "volume": 100.0, "amount": 1050.0},
@@ -488,36 +739,90 @@ class ApiRegressionTests(unittest.TestCase):
488
  ]
489
  )
490
 
491
- prepared = main.KronosForecaster._prepare_feature_frame(df)
492
  expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32)
493
 
494
  self.assertEqual(list(prepared.columns), ["ohlc4"])
495
  self.assertTrue(main.np.allclose(prepared["ohlc4"].values, expected_ohlc4.values))
496
 
497
- def test_kronos_normalization_uses_std_plus_epsilon_contract(self) -> None:
498
- x = main.np.array(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  [
500
- [1.0, 1.1, 0.9, 1.0, 0.0, 0.0],
501
- [1.1, 1.2, 1.0, 1.1, 0.0, 0.0],
502
- [1.2, 1.3, 1.1, 1.2, 0.0, 0.0],
 
503
  ],
504
  dtype=main.np.float32,
505
  )
506
 
507
- x_norm, x_mean, x_scale = main.KronosForecaster._normalize_feature_matrix(x, clip=5.0)
508
-
509
- expected_scale = main.np.std(x, axis=0).astype(main.np.float32) + 1e-5
510
- expected_norm = main.np.clip((x - x_mean) / expected_scale, -5.0, 5.0)
 
511
 
512
- self.assertTrue(main.np.allclose(x_scale, expected_scale))
513
- self.assertTrue(main.np.allclose(x_norm, expected_norm))
 
 
 
 
 
514
 
515
  def test_forecast_payload_schema_guard_rejects_legacy_blended_payload(self) -> None:
516
  legacy_payload = {
517
  "forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}],
518
  "forecast_candles": [],
519
- "display": {"mode": "raw_kronos_ohlc_p50"},
520
- "ensemble": {"mode": "kronos_plus_anchor", "confidence": 55.0},
521
  "model": {
522
  "input_semantics": {
523
  "feature_channels": ["open", "high", "low", "close", "volume", "amount"],
@@ -531,60 +836,13 @@ class ApiRegressionTests(unittest.TestCase):
531
  }
532
  self.assertFalse(main._forecast_payload_is_current(legacy_payload))
533
 
534
- def test_forecast_payload_schema_guard_accepts_current_ohlc4_input_mode(self) -> None:
535
- current_payload = {
536
- "forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}],
537
- "display": {
538
- "mode": "raw_kronos_ohlc4_line",
539
- "channels": ["ohlc4"],
540
- "output_mode": "single_future_ohlc4_line",
541
- },
542
- "model": {
543
- "input_semantics": {
544
- "feature_channels": ["ohlc4"],
545
- "price_mode": "ohlc4_single_channel",
546
- "base_signal": "ohlc4",
547
- "volume_mode": "omitted",
548
- "amount_mode": "omitted",
549
- "active_forecast_channels": ["ohlc4"],
550
- "adapter_mode": "tokenizer_6ch_to_1ch_ohlc4",
551
- },
552
- "output_semantics": {
553
- "forecast_channel": "ohlc4",
554
- "forecast_mode": "single_future_ohlc4_line",
555
- "candle_projection": "omitted",
556
- }
557
- },
558
- }
559
  self.assertTrue(main._forecast_payload_is_current(current_payload))
560
 
561
  def test_forecast_payload_schema_guard_rejects_legacy_forecast_candles_field(self) -> None:
562
- stale_payload = {
563
- "forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}],
564
- "forecast_candles": [],
565
- "display": {
566
- "mode": "raw_kronos_ohlc4_line",
567
- "channels": ["ohlc4"],
568
- "output_mode": "single_future_ohlc4_line",
569
- },
570
- "model": {
571
- "input_semantics": {
572
- "feature_channels": ["ohlc4"],
573
- "price_mode": "ohlc4_single_channel",
574
- "base_signal": "ohlc4",
575
- "volume_mode": "omitted",
576
- "amount_mode": "omitted",
577
- "active_forecast_channels": ["ohlc4"],
578
- "adapter_mode": "tokenizer_6ch_to_1ch_ohlc4",
579
- },
580
- "output_semantics": {
581
- "forecast_channel": "ohlc4",
582
- "forecast_mode": "single_future_ohlc4_line",
583
- "candle_projection": "omitted",
584
- },
585
- },
586
- }
587
-
588
  self.assertFalse(main._forecast_payload_is_current(stale_payload))
589
 
590
  def test_finalize_forecast_error_payload_omits_legacy_forecast_candles_field(self) -> None:
@@ -601,6 +859,60 @@ class ApiRegressionTests(unittest.TestCase):
601
 
602
  self.assertNotIn("forecast_candles", response)
603
  self.assertEqual(response["display"]["output_mode"], "single_future_ohlc4_line")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
 
605
  def test_synthetic_component_history_reuses_source_cache(self) -> None:
606
  calls: list[tuple[str, str, str, int]] = []
 
18
  cls.client = TestClient(main.app)
19
  cls.symbol_a, cls.symbol_b = list(main.SYMBOLS.keys())[:2]
20
 
21
+ def _build_current_forecast_payload(
22
+ self,
23
+ requested: dict[str, bool] | None = None,
24
+ ) -> dict[str, object]:
25
+ requested_models = dict(main.DEFAULT_FORECAST_MODEL_SELECTION)
26
+ if requested:
27
+ requested_models.update(requested)
28
+ if not any(requested_models.values()):
29
+ requested_models["kronos"] = True
30
+
31
+ def build_model_meta(model_key: str) -> dict[str, object]:
32
+ adapter_mode = {
33
+ "kronos": "kronos_ohlc4_proxy",
34
+ "timesfm": "timesfm_native",
35
+ "chronos": "chronos_native",
36
+ }[model_key]
37
+ is_kronos = model_key == "kronos"
38
+ return {
39
+ "model_key": model_key,
40
+ "name": model_key,
41
+ "context_length": 64,
42
+ "available_history": 500,
43
+ "input_semantics": {
44
+ "feature_channels": ["ohlc4"],
45
+ "price_mode": "ohlc4_single_channel",
46
+ "base_signal": "ohlc4",
47
+ "volume_mode": "synthetic_zero" if is_kronos else "omitted",
48
+ "amount_mode": "synthetic_zero" if is_kronos else "omitted",
49
+ "active_forecast_channels": ["ohlc4"],
50
+ "adapter_mode": adapter_mode,
51
+ },
52
+ "output_semantics": {
53
+ "forecast_channel": "ohlc4",
54
+ "forecast_mode": "single_future_ohlc4_line",
55
+ "candle_projection": "omitted",
56
+ "reference_baseline": "last_ohlc4",
57
+ },
58
+ }
59
+
60
+ forecast_rows = [
61
+ {"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2},
62
+ {"time": 2, "p10": 1.1, "p50": 1.2, "p90": 1.3},
63
+ ]
64
+ forecast_models: dict[str, object] = {}
65
+ components: dict[str, object] = {}
66
+ active_models: list[str] = []
67
+ for model_key in main.FORECAST_MODEL_ORDER:
68
+ enabled = bool(requested_models.get(model_key, False))
69
+ if enabled:
70
+ model_meta = build_model_meta(model_key)
71
+ forecast_models[model_key] = {
72
+ "enabled": True,
73
+ "available": True,
74
+ "success": True,
75
+ "skipped": False,
76
+ "error": None,
77
+ "forecast": forecast_rows,
78
+ "model": model_meta,
79
+ "ensemble": {"confidence": 61.0},
80
+ "model_diagnostics": {},
81
+ }
82
+ components[model_key] = model_meta
83
+ active_models.append(model_key)
84
+ else:
85
+ forecast_models[model_key] = {
86
+ "enabled": False,
87
+ "available": True,
88
+ "success": False,
89
+ "skipped": True,
90
+ "error": None,
91
+ "forecast": [],
92
+ "model": {},
93
+ "ensemble": {},
94
+ "model_diagnostics": {},
95
+ }
96
+
97
+ return {
98
+ "forecast": forecast_rows,
99
+ "display": {
100
+ "mode": (
101
+ "raw_kronos_ohlc4_line"
102
+ if active_models == ["kronos"]
103
+ else "raw_timesfm_ohlc4_line"
104
+ if active_models == ["timesfm"]
105
+ else "raw_chronos_ohlc4_line"
106
+ if active_models == ["chronos"]
107
+ else "multi_model_ohlc4_line"
108
+ ),
109
+ "channels": ["ohlc4"],
110
+ "output_mode": "single_future_ohlc4_line",
111
+ "reference_series": "ohlc4",
112
+ "market_price_field": "last_close",
113
+ "forecast_reference_field": "last_ohlc4",
114
+ "visual_anchor_field": "last_close",
115
+ "combination_mode": "mean_of_enabled_models",
116
+ },
117
+ "forecast_models": forecast_models,
118
+ "model_selection": {
119
+ "requested": requested_models,
120
+ "active": active_models,
121
+ "defaults": dict(main.DEFAULT_FORECAST_MODEL_SELECTION),
122
+ "combination_mode": "mean_of_enabled_models",
123
+ },
124
+ "model": {
125
+ "name": active_models[0] if len(active_models) == 1 else "mean_of_enabled_models",
126
+ "active_models": active_models,
127
+ "components": components,
128
+ "cache_version": main.CACHE_VERSION,
129
+ },
130
+ }
131
+
132
  def test_cache_stats_requires_runtime_admin_token(self) -> None:
133
  with patch.object(main, "ADMIN_TOKEN", "test-admin-token"):
134
  unauthorized = self.client.get("/api/cache/stats")
 
146
  )
147
  self.assertEqual(authorized.status_code, 200)
148
 
149
+ def test_forecast_rules_endpoint_exposes_versioned_contract(self) -> None:
150
+ response = self.client.get("/api/forecasting/rules")
151
+ self.assertEqual(response.status_code, 200)
152
+ body = response.json()
153
+ self.assertEqual(body["rules"]["version"], main.FORECAST_RULE_DOCUMENT.version)
154
+ self.assertEqual(body["rules"]["default_horizon"], 10)
155
+ self.assertEqual(body["rules"]["recommended_context_length"], 512)
156
+ self.assertEqual(body["rules"]["default_line_width"], 1)
157
+ self.assertIn("chronos", body["models"])
158
+
159
+ def test_frontend_forecast_model_registry_has_dedicated_no_cache_route(self) -> None:
160
+ response = self.client.get("/forecast-models.js")
161
+ self.assertEqual(response.status_code, 200)
162
+ self.assertIn("Cache-Control", response.headers)
163
+ self.assertEqual(
164
+ response.headers["Cache-Control"],
165
+ "no-store, no-cache, must-revalidate, max-age=0",
166
+ )
167
+
168
+ def test_forecast_model_selection_supports_chronos(self) -> None:
169
+ selection = main._normalize_forecast_model_selection(
170
+ use_kronos=False,
171
+ use_timesfm=False,
172
+ use_chronos=True,
173
+ )
174
+ self.assertEqual(
175
+ selection,
176
+ {"kronos": False, "timesfm": False, "chronos": True},
177
+ )
178
+
179
  def test_watchlist_tickers_deduplicates_and_reports_invalid_symbols(self) -> None:
180
  async def fake_fetch_ticker(symbol: str) -> dict[str, object]:
181
  return {"symbol": symbol, "price": 123.45}
 
236
  self.assertNotIn("binance", main._get_source_priority("EURUSD"))
237
  self.assertEqual(main._get_source_priority("DXY"), ["yfinance", "twelvedata"])
238
 
239
+ def test_xauusd_priority_prefers_binance_paxg_proxy_first(self) -> None:
240
+ self.assertEqual(
241
+ main._get_source_priority("XAUUSD", "1d"),
242
+ ["binance", "twelvedata", "yfinance"],
243
+ )
244
+
245
+ def test_forecast_context_window_prefers_recent_tail(self) -> None:
246
+ self.assertEqual(main._resolve_forecast_context_window("1h", 1000), 256)
247
+ self.assertEqual(main._resolve_forecast_context_window("1d", 180), 180)
248
+ self.assertEqual(main._resolve_forecast_context_window("1w", 400), 192)
249
+
250
+ def test_forecast_context_candidates_include_shorter_windows_for_responsiveness(self) -> None:
251
+ self.assertEqual(main._resolve_forecast_context_candidates("1d", 400), [48, 64, 128, 256])
252
+ self.assertEqual(main._resolve_forecast_context_candidates("4h", 300), [64, 96, 128, 256])
253
+ self.assertEqual(main._resolve_forecast_context_candidates("1d", 60), [48, 60])
254
+
255
+ def test_kronos_context_candidates_cap_direct_model_window_for_latency(self) -> None:
256
+ self.assertEqual(main._resolve_kronos_context_candidates("1d", 1000), [64, 128, 256])
257
+ self.assertEqual(main._resolve_model_context_cap("kronos", "1d", 1000), 256)
258
+
259
+ def test_forecast_context_candidate_score_penalizes_near_flat_paths(self) -> None:
260
+ responsive = main._score_forecast_context_candidate(
261
+ analysis_bundle={
262
+ "p50": main.np.array([100.0, 99.4, 98.8, 98.1], dtype=float),
263
+ "confidence": 58.0,
264
+ },
265
+ last_ohlc4=100.0,
266
+ recent_abs_step_pct=1.0,
267
+ )
268
+ flat = main._score_forecast_context_candidate(
269
+ analysis_bundle={
270
+ "p50": main.np.array([100.0, 99.98, 100.01, 100.0], dtype=float),
271
+ "confidence": 58.0,
272
+ },
273
+ last_ohlc4=100.0,
274
+ recent_abs_step_pct=1.0,
275
+ )
276
+
277
+ self.assertGreater(responsive["score"], flat["score"])
278
+ self.assertGreater(responsive["step_abs_mean_pct"], flat["step_abs_mean_pct"])
279
+
280
+ def test_forecast_amplitude_calibration_scales_flat_path_toward_historical_targets(self) -> None:
281
+ raw_bundle = main._build_raw_ohlc4_bundle(
282
+ {
283
+ "p10": main.np.array([99.98, 99.96, 99.94], dtype=float),
284
+ "p50": main.np.array([100.0, 99.99, 99.98], dtype=float),
285
+ "p90": main.np.array([100.02, 100.01, 100.0], dtype=float),
286
+ },
287
+ 100.0,
288
+ )
289
+ calibrated_bundle, calibration_meta = main._apply_forecast_amplitude_calibration(
290
+ raw_bundle=raw_bundle,
291
+ last_ohlc4=100.0,
292
+ target_profile={
293
+ "target_step_abs_mean_pct": 0.45,
294
+ "target_range_pct": 1.2,
295
+ "recent_abs_step_pct": 0.8,
296
+ "rolling_targets": None,
297
+ "regime_targets": None,
298
+ },
299
+ )
300
+
301
+ self.assertGreater(calibration_meta["scale"], 1.0)
302
+ self.assertGreater(
303
+ calibrated_bundle["path_metrics"]["final_return_pct"],
304
+ raw_bundle["path_metrics"]["final_return_pct"] * 3,
305
+ )
306
+ self.assertGreater(
307
+ calibration_meta["calibrated_path"]["step_abs_mean_pct"],
308
+ calibration_meta["raw_path"]["step_abs_mean_pct"],
309
+ )
310
+
311
+ def test_forecast_path_texture_adds_stepwise_zigzag_to_future_points(self) -> None:
312
+ base_bundle = main._build_raw_ohlc4_bundle(
313
+ {
314
+ "p10": main.np.array([99.6, 99.4, 99.2, 99.0], dtype=float),
315
+ "p50": main.np.array([99.9, 99.8, 99.7, 99.6], dtype=float),
316
+ "p90": main.np.array([100.2, 100.1, 100.0, 99.9], dtype=float),
317
+ },
318
+ 100.0,
319
+ )
320
+ textured_bundle, texture_meta = main._apply_forecast_path_texture(
321
+ base_bundle=base_bundle,
322
+ last_ohlc4=100.0,
323
+ target_profile={
324
+ "target_step_abs_mean_pct": 0.45,
325
+ "target_range_pct": 1.4,
326
+ "recent_abs_step_pct": 0.8,
327
+ },
328
+ texture_template={
329
+ "step_returns_pct": main.np.array([0.22, -0.35, 0.28, -0.12], dtype=float),
330
+ "step_abs_mean_pct": 0.3133,
331
+ "range_pct": 0.40,
332
+ "match_count": 6,
333
+ "signature_len": 48,
334
+ },
335
+ )
336
+
337
+ self.assertTrue(texture_meta["applied"])
338
+ self.assertGreater(texture_meta["blend_alpha"], 0.0)
339
+ self.assertGreater(
340
+ texture_meta["textured_path"]["step_abs_mean_pct"],
341
+ texture_meta["base_path"]["step_abs_mean_pct"],
342
+ )
343
+ self.assertNotAlmostEqual(
344
+ textured_bundle["path_metrics"]["final_return_pct"],
345
+ base_bundle["path_metrics"]["final_return_pct"],
346
+ delta=0.02,
347
+ )
348
+
349
  def test_crypto_dynamic_source_mappings_are_derived_for_hf_safe_fallbacks(self) -> None:
350
  self.assertEqual(main._get_symbol_mapping("BTCUSD", "twelvedata"), "BTC/USD")
351
  self.assertEqual(main._get_symbol_mapping("BTCUSD", "yfinance"), "BTC-USD")
 
717
  self.assertEqual({item[0] for item in calls}, {"twelvedata"})
718
  self.assertEqual({item[2] for item in calls}, {"4h"})
719
 
720
+ def test_timesfm_feature_prep_collapses_prices_to_ohlc4(self) -> None:
721
  df = main.pd.DataFrame(
722
  [
723
  {"open": 1.0, "high": 1.1, "low": 0.9, "close": 1.05, "volume": 0.0, "amount": 0.0},
 
725
  ]
726
  )
727
 
728
+ prepared = main.TimesFMForecaster._prepare_feature_frame(df)
729
  expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32)
730
 
731
  self.assertEqual(list(prepared.columns), ["ohlc4"])
732
  self.assertTrue(main.np.allclose(prepared["ohlc4"].values, expected_ohlc4.values))
733
 
734
+ def test_timesfm_feature_prep_ignores_upstream_volume_and_amount_noise(self) -> None:
735
  df = main.pd.DataFrame(
736
  [
737
  {"open": 10.0, "high": 11.0, "low": 9.0, "close": 10.5, "volume": 100.0, "amount": 1050.0},
 
739
  ]
740
  )
741
 
742
+ prepared = main.TimesFMForecaster._prepare_feature_frame(df)
743
  expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32)
744
 
745
  self.assertEqual(list(prepared.columns), ["ohlc4"])
746
  self.assertTrue(main.np.allclose(prepared["ohlc4"].values, expected_ohlc4.values))
747
 
748
+ def test_timesfm_input_series_uses_float32_ohlc4_contract(self) -> None:
749
+ df = main.pd.DataFrame(
750
+ [
751
+ {"open": 1.0, "high": 1.1, "low": 0.9, "close": 1.0, "volume": 0.0},
752
+ {"open": 1.1, "high": 1.2, "low": 1.0, "close": 1.1, "volume": 0.0},
753
+ {"open": 1.2, "high": 1.3, "low": 1.1, "close": 1.2, "volume": 0.0},
754
+ {"open": 1.3, "high": 1.4, "low": 1.2, "close": 1.3, "volume": 0.0},
755
+ {"open": 1.4, "high": 1.5, "low": 1.3, "close": 1.4, "volume": 0.0},
756
+ {"open": 1.5, "high": 1.6, "low": 1.4, "close": 1.5, "volume": 0.0},
757
+ {"open": 1.6, "high": 1.7, "low": 1.5, "close": 1.6, "volume": 0.0},
758
+ {"open": 1.7, "high": 1.8, "low": 1.6, "close": 1.7, "volume": 0.0},
759
+ {"open": 1.8, "high": 1.9, "low": 1.7, "close": 1.8, "volume": 0.0},
760
+ {"open": 1.9, "high": 2.0, "low": 1.8, "close": 1.9, "volume": 0.0},
761
+ {"open": 2.0, "high": 2.1, "low": 1.9, "close": 2.0, "volume": 0.0},
762
+ {"open": 2.1, "high": 2.2, "low": 2.0, "close": 2.1, "volume": 0.0},
763
+ {"open": 2.2, "high": 2.3, "low": 2.1, "close": 2.2, "volume": 0.0},
764
+ {"open": 2.3, "high": 2.4, "low": 2.2, "close": 2.3, "volume": 0.0},
765
+ {"open": 2.4, "high": 2.5, "low": 2.3, "close": 2.4, "volume": 0.0},
766
+ {"open": 2.5, "high": 2.6, "low": 2.4, "close": 2.5, "volume": 0.0},
767
+ {"open": 2.6, "high": 2.7, "low": 2.5, "close": 2.6, "volume": 0.0},
768
+ {"open": 2.7, "high": 2.8, "low": 2.6, "close": 2.7, "volume": 0.0},
769
+ {"open": 2.8, "high": 2.9, "low": 2.7, "close": 2.8, "volume": 0.0},
770
+ {"open": 2.9, "high": 3.0, "low": 2.8, "close": 2.9, "volume": 0.0},
771
+ {"open": 3.0, "high": 3.1, "low": 2.9, "close": 3.0, "volume": 0.0},
772
+ {"open": 3.1, "high": 3.2, "low": 3.0, "close": 3.1, "volume": 0.0},
773
+ {"open": 3.2, "high": 3.3, "low": 3.1, "close": 3.2, "volume": 0.0},
774
+ {"open": 3.3, "high": 3.4, "low": 3.2, "close": 3.3, "volume": 0.0},
775
+ {"open": 3.4, "high": 3.5, "low": 3.3, "close": 3.4, "volume": 0.0},
776
+ {"open": 3.5, "high": 3.6, "low": 3.4, "close": 3.5, "volume": 0.0},
777
+ {"open": 3.6, "high": 3.7, "low": 3.5, "close": 3.6, "volume": 0.0},
778
+ {"open": 3.7, "high": 3.8, "low": 3.6, "close": 3.7, "volume": 0.0},
779
+ {"open": 3.8, "high": 3.9, "low": 3.7, "close": 3.8, "volume": 0.0},
780
+ {"open": 3.9, "high": 4.0, "low": 3.8, "close": 3.9, "volume": 0.0},
781
+ {"open": 4.0, "high": 4.1, "low": 3.9, "close": 4.0, "volume": 0.0},
782
+ {"open": 4.1, "high": 4.2, "low": 4.0, "close": 4.1, "volume": 0.0},
783
+ {"open": 4.2, "high": 4.3, "low": 4.1, "close": 4.2, "volume": 0.0},
784
+ {"open": 4.3, "high": 4.4, "low": 4.2, "close": 4.3, "volume": 0.0},
785
+ ]
786
+ )
787
+
788
+ ohlc4_series = main.TimesFMForecaster._extract_ohlc4_series(df)
789
+ expected_ohlc4 = df[["open", "high", "low", "close"]].mean(axis=1).astype(main.np.float32)
790
+
791
+ self.assertEqual(ohlc4_series.dtype, main.np.float32)
792
+ self.assertTrue(main.np.allclose(ohlc4_series, expected_ohlc4.values))
793
+
794
+ def test_timesfm_output_validation_prefers_point_forecast_median(self) -> None:
795
+ point = main.np.array([[101.0, 102.0]], dtype=main.np.float32)
796
+ quantiles = main.np.array(
797
  [
798
+ [
799
+ [100.5, 99.0, 99.5, 100.0, 100.5, 101.0, 101.5, 102.0, 102.5, 103.0],
800
+ [101.5, 100.0, 100.5, 101.0, 101.5, 102.0, 102.5, 103.0, 103.5, 104.0],
801
+ ]
802
  ],
803
  dtype=main.np.float32,
804
  )
805
 
806
+ p10, p50, p90, diagnostics = main.TimesFMForecaster._validate_output_tensors(
807
+ point_forecast=point,
808
+ quantile_forecast=quantiles,
809
+ horizon=2,
810
+ )
811
 
812
+ self.assertTrue(main.np.allclose(p10, [99.0, 100.0]))
813
+ self.assertTrue(main.np.allclose(p50, point[0]))
814
+ self.assertTrue(main.np.allclose(p90, [103.0, 104.0]))
815
+ self.assertEqual(diagnostics["point_shape"], [1, 2])
816
+ self.assertEqual(diagnostics["quantile_shape"], [1, 2, 10])
817
+ self.assertTrue(diagnostics["median_matches_point_forecast"])
818
+ self.assertTrue(diagnostics["quantiles_monotonic"])
819
 
820
  def test_forecast_payload_schema_guard_rejects_legacy_blended_payload(self) -> None:
821
  legacy_payload = {
822
  "forecast": [{"time": 1, "p10": 1.0, "p50": 1.1, "p90": 1.2}],
823
  "forecast_candles": [],
824
+ "display": {"mode": "raw_legacy_ohlc_p50"},
825
+ "ensemble": {"mode": "legacy_plus_anchor", "confidence": 55.0},
826
  "model": {
827
  "input_semantics": {
828
  "feature_channels": ["open", "high", "low", "close", "volume", "amount"],
 
836
  }
837
  self.assertFalse(main._forecast_payload_is_current(legacy_payload))
838
 
839
+ def test_forecast_payload_schema_guard_accepts_current_multi_model_payload(self) -> None:
840
+ current_payload = self._build_current_forecast_payload()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
841
  self.assertTrue(main._forecast_payload_is_current(current_payload))
842
 
843
  def test_forecast_payload_schema_guard_rejects_legacy_forecast_candles_field(self) -> None:
844
+ stale_payload = self._build_current_forecast_payload()
845
+ stale_payload["forecast_candles"] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846
  self.assertFalse(main._forecast_payload_is_current(stale_payload))
847
 
848
  def test_finalize_forecast_error_payload_omits_legacy_forecast_candles_field(self) -> None:
 
859
 
860
  self.assertNotIn("forecast_candles", response)
861
  self.assertEqual(response["display"]["output_mode"], "single_future_ohlc4_line")
862
+ self.assertEqual(response["display"]["forecast_reference_field"], "last_ohlc4")
863
+ self.assertEqual(response["display"]["visual_anchor_field"], "last_close")
864
+
865
+ def test_forecast_payload_schema_guard_rejects_missing_reference_fields(self) -> None:
866
+ stale_payload = self._build_current_forecast_payload()
867
+ stale_payload["display"] = {
868
+ "mode": "multi_model_ohlc4_line",
869
+ "channels": ["ohlc4"],
870
+ "output_mode": "single_future_ohlc4_line",
871
+ }
872
+ self.assertFalse(main._forecast_payload_is_current(stale_payload))
873
+
874
+ def test_forecast_payload_schema_guard_rejects_missing_model_selection(self) -> None:
875
+ stale_payload = self._build_current_forecast_payload()
876
+ stale_payload.pop("model_selection", None)
877
+ self.assertFalse(main._forecast_payload_is_current(stale_payload))
878
+
879
+ def test_forecast_payload_schema_guard_rejects_missing_forecast_models(self) -> None:
880
+ stale_payload = self._build_current_forecast_payload()
881
+ stale_payload.pop("forecast_models", None)
882
+ self.assertFalse(main._forecast_payload_is_current(stale_payload))
883
+
884
+ def test_forecast_payload_schema_guard_rejects_missing_combination_mode(self) -> None:
885
+ stale_payload = self._build_current_forecast_payload()
886
+ stale_payload["display"].pop("combination_mode", None)
887
+ self.assertFalse(main._forecast_payload_is_current(stale_payload))
888
+
889
+ def test_forecast_payload_schema_guard_rejects_enabled_model_without_success(self) -> None:
890
+ stale_payload = self._build_current_forecast_payload()
891
+ stale_payload["forecast_models"]["kronos"]["success"] = False
892
+ self.assertFalse(main._forecast_payload_is_current(stale_payload))
893
+
894
+ def test_calc_ai_forecast_score_keeps_market_return_and_tracks_model_reference_return(self) -> None:
895
+ score = main._calc_ai_forecast_score(
896
+ blended={
897
+ "p10": [100.0, 101.0],
898
+ "p50": [101.0, 102.0],
899
+ "p90": [102.0, 103.0],
900
+ "confidence": 60.0,
901
+ "scale": 1.0,
902
+ },
903
+ forecast_rows=[],
904
+ last_close=100.0,
905
+ indicators={"trend": {}, "atr": {"pct": 1.0}, "rsi": {"value": 50.0}},
906
+ horizon=2,
907
+ interval="1h",
908
+ model_reference_price=101.0,
909
+ )
910
+
911
+ self.assertEqual(score["forecast_return_pct"], 2.0)
912
+ self.assertAlmostEqual(
913
+ score["model_reference_return_pct"],
914
+ round(((102.0 - 101.0) / 101.0) * 100.0, 2),
915
+ )
916
 
917
  def test_synthetic_component_history_reuses_source_cache(self) -> None:
918
  calls: list[tuple[str, str, str, int]] = []
backend/test_frontend_assets.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import tempfile
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.testclient import TestClient
11
+
12
+ from backend.frontend_assets import (
13
+ build_frontend_asset_version,
14
+ register_frontend_assets,
15
+ resolve_frontend_assets,
16
+ )
17
+
18
+
19
+ class FrontendAssetsTests(unittest.TestCase):
20
+ def _build_frontend_fixture(self, project_root: Path) -> None:
21
+ frontend_root = project_root / "frontend"
22
+ frontend_root.mkdir(parents=True, exist_ok=True)
23
+
24
+ files: dict[str, str] = {
25
+ "index.html": "<html><head></head><body>v=__FRONTEND_ASSET_VERSION__</body></html>",
26
+ "app.js": "console.log('app');",
27
+ "forecast-models.js": "console.log('forecast-models');",
28
+ "workspace.js": "console.log('workspace');",
29
+ "workspace.css": "body { color: #fff; }",
30
+ "favicon.svg": "<svg xmlns='http://www.w3.org/2000/svg'></svg>",
31
+ }
32
+ for file_name, content in files.items():
33
+ (frontend_root / file_name).write_text(content, encoding="utf-8")
34
+
35
+ (frontend_root / "AIBG.png").write_bytes(b"\x89PNG\r\n\x1a\n")
36
+
37
+ def test_asset_version_changes_when_forecast_model_registry_changes(self) -> None:
38
+ with tempfile.TemporaryDirectory() as temp_dir:
39
+ project_root = Path(temp_dir)
40
+ self._build_frontend_fixture(project_root)
41
+ assets = resolve_frontend_assets(project_root)
42
+
43
+ initial_version = build_frontend_asset_version(
44
+ assets=assets,
45
+ app_version="1.0.0",
46
+ cache_version="v1",
47
+ )
48
+
49
+ assets.forecast_models_js.write_text("console.log('updated');", encoding="utf-8")
50
+ new_mtime = assets.forecast_models_js.stat().st_mtime + 5
51
+ os.utime(assets.forecast_models_js, (new_mtime, new_mtime))
52
+
53
+ updated_version = build_frontend_asset_version(
54
+ assets=assets,
55
+ app_version="1.0.0",
56
+ cache_version="v1",
57
+ )
58
+
59
+ self.assertNotEqual(initial_version, updated_version)
60
+
61
+ def test_register_frontend_assets_serves_forecast_registry_with_no_cache_headers(self) -> None:
62
+ with tempfile.TemporaryDirectory() as temp_dir:
63
+ project_root = Path(temp_dir)
64
+ self._build_frontend_fixture(project_root)
65
+ app = FastAPI()
66
+ register_frontend_assets(
67
+ app,
68
+ project_root=project_root,
69
+ app_version="1.0.0",
70
+ cache_version="v1",
71
+ logger=logging.getLogger("test-frontend-assets"),
72
+ )
73
+ client = TestClient(app)
74
+
75
+ response = client.get("/forecast-models.js")
76
+
77
+ self.assertEqual(response.status_code, 200)
78
+ self.assertIn("forecast-models", response.text)
79
+ self.assertEqual(
80
+ response.headers["Cache-Control"],
81
+ "no-store, no-cache, must-revalidate, max-age=0",
82
+ )
83
+
84
+ def test_register_frontend_assets_replaces_version_placeholder_in_index(self) -> None:
85
+ with tempfile.TemporaryDirectory() as temp_dir:
86
+ project_root = Path(temp_dir)
87
+ self._build_frontend_fixture(project_root)
88
+ app = FastAPI()
89
+ register_frontend_assets(
90
+ app,
91
+ project_root=project_root,
92
+ app_version="2.0.0",
93
+ cache_version="v9",
94
+ logger=logging.getLogger("test-frontend-assets"),
95
+ )
96
+ client = TestClient(app)
97
+
98
+ response = client.get("/")
99
+
100
+ self.assertEqual(response.status_code, 200)
101
+ self.assertNotIn("__FRONTEND_ASSET_VERSION__", response.text)
102
+ self.assertIn("2.0.0-v9-", response.text)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ unittest.main()
backend/test_frontend_static_contract.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+
7
+ class FrontendStaticContractTests(unittest.TestCase):
8
+ @classmethod
9
+ def setUpClass(cls) -> None:
10
+ cls.app_js = (Path(__file__).resolve().parent.parent / "frontend" / "app.js").read_text(
11
+ encoding="utf-8",
12
+ )
13
+ cls.index_html = (Path(__file__).resolve().parent.parent / "frontend" / "index.html").read_text(
14
+ encoding="utf-8",
15
+ )
16
+ cls.workspace_js = (Path(__file__).resolve().parent.parent / "frontend" / "workspace.js").read_text(
17
+ encoding="utf-8",
18
+ )
19
+
20
+ def test_app_js_does_not_use_inline_onclick_handlers(self) -> None:
21
+ self.assertNotIn('onclick="', self.app_js)
22
+
23
+ def test_explorer_interactions_use_data_attributes_and_global_compat_exports(self) -> None:
24
+ self.assertIn('data-explorer-cat="', self.app_js)
25
+ self.assertIn('data-symbol="${escapeHtml(s.symbol)}"', self.app_js)
26
+ self.assertIn("window.selectExplorerCat = selectExplorerCat;", self.app_js)
27
+ self.assertIn("window.explorerSelectSymbol = explorerSelectSymbol;", self.app_js)
28
+
29
+ def test_market_toggle_dom_ref_is_explicit(self) -> None:
30
+ self.assertIn("const toggleMarketBtn = document.getElementById('toggleMarketBtn');", self.app_js)
31
+
32
+ def test_layout_switcher_reconciles_actual_workspace_state_before_noop(self) -> None:
33
+ self.assertIn("function inferWorkspaceLayoutPreset()", self.app_js)
34
+ self.assertIn("const currentPreset = syncWorkspaceLayoutPreset();", self.app_js)
35
+ self.assertIn("const isPresetFullyApplied = (", self.app_js)
36
+
37
+ def test_progressive_ai_forecast_contract_is_exposed(self) -> None:
38
+ self.assertIn(
39
+ "window.buildCombinedForecastPayloadForPane = buildCombinedForecastPayloadForPane;",
40
+ self.app_js,
41
+ )
42
+ self.assertIn("async fetchForecastModel(symbol, interval, horizon, modelKey, signal)", self.workspace_js)
43
+ self.assertIn("complete: false", self.workspace_js)
44
+
45
+ def test_shared_chart_viewport_contract_is_centralized(self) -> None:
46
+ self.assertIn("const CHART_RIGHT_OFFSET = 50;", self.app_js)
47
+ self.assertIn("function buildSharedTimeScaleOptions(overrides = {})", self.app_js)
48
+ self.assertIn("function getAllOpenChartInstances()", self.app_js)
49
+ self.assertIn("function zoomAllCharts(direction)", self.app_js)
50
+ self.assertIn("function panAllCharts(direction)", self.app_js)
51
+ self.assertIn("function restoreOrFitChartViewport(chartKey, chartInstance, context)", self.app_js)
52
+ self.assertIn("function registerChartViewportTracking(chartInstance, chartKey, getContext)", self.app_js)
53
+ self.assertIn("function alignChartViewportToReservedSpace(chartKey, chartInstance, context, options = {})", self.app_js)
54
+ self.assertIn("function resolveChartRightEdgeAnchor(chartKey)", self.app_js)
55
+ self.assertIn("const zoomOutBtn = document.getElementById('zoomOutBtn');", self.app_js)
56
+ self.assertIn("const zoomInBtn = document.getElementById('zoomInBtn');", self.app_js)
57
+ self.assertIn("const panLeftBtn = document.getElementById('panLeftBtn');", self.app_js)
58
+ self.assertIn("const panRightBtn = document.getElementById('panRightBtn');", self.app_js)
59
+ self.assertIn("function setupChartNavigationCluster()", self.app_js)
60
+ self.assertIn('id="zoomOutBtn"', self.index_html)
61
+ self.assertIn('id="zoomInBtn"', self.index_html)
62
+ self.assertIn('id="panLeftBtn"', self.index_html)
63
+ self.assertIn('id="panRightBtn"', self.index_html)
64
+ self.assertIn('.chart-nav-cluster', self.index_html)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
backend/test_kronos_meta_compat.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ import torch
6
+
7
+ from backend.kronos_core.model.kronos import (
8
+ _compat_load_state_dict,
9
+ _module_has_meta_tensors,
10
+ _move_module_to_device,
11
+ )
12
+
13
+
14
+ class KronosMetaCompatibilityTests(unittest.TestCase):
15
+ def test_meta_module_detection(self) -> None:
16
+ meta_linear = torch.nn.Linear(2, 2, device="meta")
17
+ self.assertTrue(_module_has_meta_tensors(meta_linear))
18
+
19
+ def test_compat_load_state_dict_assigns_into_meta_parameters(self) -> None:
20
+ source = torch.nn.Linear(2, 2)
21
+ meta_linear = torch.nn.Linear(2, 2, device="meta")
22
+
23
+ _compat_load_state_dict(meta_linear, source.state_dict(), strict=True)
24
+
25
+ self.assertFalse(_module_has_meta_tensors(meta_linear))
26
+ self.assertTrue(torch.allclose(meta_linear.weight.detach(), source.weight.detach()))
27
+ self.assertTrue(torch.allclose(meta_linear.bias.detach(), source.bias.detach()))
28
+
29
+ def test_move_module_to_device_handles_meta_modules(self) -> None:
30
+ meta_linear = torch.nn.Linear(2, 2, device="meta")
31
+
32
+ moved = _move_module_to_device(meta_linear, "cpu")
33
+
34
+ self.assertFalse(_module_has_meta_tensors(moved))
35
+ self.assertEqual(str(next(moved.parameters()).device), "cpu")
36
+
37
+
38
+ if __name__ == "__main__":
39
+ unittest.main()
backend/test_launcher.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import unittest
5
+ from unittest.mock import Mock, patch
6
+
7
+ from backend import launcher
8
+
9
+
10
+ class _FakeServer:
11
+ def __init__(self, *, started: bool = False) -> None:
12
+ self.started = started
13
+
14
+
15
+ class LauncherTests(unittest.TestCase):
16
+ def test_wait_for_server_start_returns_true_when_uvicorn_started(self) -> None:
17
+ server = _FakeServer(started=True)
18
+ finished = threading.Event()
19
+ self.assertTrue(launcher.wait_for_server_start(server, finished))
20
+
21
+ def test_wait_for_server_start_returns_false_when_startup_finishes_early(self) -> None:
22
+ server = _FakeServer(started=False)
23
+ finished = threading.Event()
24
+ finished.set()
25
+ with patch.object(launcher, "SERVER_READY_TIMEOUT_SECONDS", 0.3):
26
+ with patch.object(launcher, "SERVER_READY_POLL_INTERVAL_SECONDS", 0.05):
27
+ self.assertFalse(launcher.wait_for_server_start(server, finished))
28
+
29
+ def test_open_browser_skips_when_backend_never_starts(self) -> None:
30
+ browser_open = Mock()
31
+ server = _FakeServer(started=False)
32
+ finished = threading.Event()
33
+
34
+ with patch.object(launcher, "wait_for_server_start", return_value=False):
35
+ with patch.object(launcher.webbrowser, "open", browser_open):
36
+ launcher.open_browser("http://127.0.0.1:8000", server, finished)
37
+
38
+ browser_open.assert_not_called()
39
+
40
+ def test_resolve_server_port_delegates_to_shared_runtime(self) -> None:
41
+ with patch.object(launcher.server_runtime, "resolve_server_port", return_value=8123) as resolver:
42
+ self.assertEqual(launcher.resolve_server_port(), 8123)
43
+ resolver.assert_called_once_with(launcher.DEFAULT_HOST, logger=launcher.logger)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ unittest.main()
backend/test_runtime_utils.py CHANGED
@@ -1,11 +1,15 @@
1
  from __future__ import annotations
2
 
3
  import unittest
 
4
 
5
  import numpy as np
 
6
 
7
  from backend.runtime_utils import (
 
8
  clone_cache_payload,
 
9
  make_json_compatible,
10
  parse_cors_origins,
11
  resolve_runtime_paths,
@@ -13,6 +17,10 @@ from backend.runtime_utils import (
13
 
14
 
15
  class RuntimeUtilsTests(unittest.TestCase):
 
 
 
 
16
  def test_parse_cors_origins_splits_and_trims(self) -> None:
17
  result = parse_cors_origins(" https://a.com,https://b.com , , ")
18
  self.assertEqual(result, ["https://a.com", "https://b.com"])
@@ -20,6 +28,20 @@ class RuntimeUtilsTests(unittest.TestCase):
20
  def test_parse_cors_origins_defaults_to_wildcard(self) -> None:
21
  self.assertEqual(parse_cors_origins(" "), ["*"])
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  def test_clone_cache_payload_returns_independent_copy(self) -> None:
24
  original = {"nested": {"value": 1}, "items": [1, 2, 3]}
25
  cloned = clone_cache_payload(original)
@@ -45,17 +67,20 @@ class RuntimeUtilsTests(unittest.TestCase):
45
  self.assertEqual(result["series"], [1.0, 2.0])
46
 
47
  def test_resolve_runtime_paths_for_dev_mode(self) -> None:
 
 
 
48
  paths = resolve_runtime_paths(
49
- module_file=r"D:\Python\Kronos_Platform_V1\backend\main.py",
50
  is_frozen=False,
51
  bundle_dir=r"D:\Bundle",
52
  )
53
- self.assertEqual(paths.current_dir, r"D:\Python\Kronos_Platform_V1\backend")
54
- self.assertEqual(paths.project_root, r"D:\Python\Kronos_Platform_V1")
55
 
56
  def test_resolve_runtime_paths_for_frozen_mode(self) -> None:
57
  paths = resolve_runtime_paths(
58
- module_file=r"D:\Python\Kronos_Platform_V1\backend\main.py",
59
  is_frozen=True,
60
  bundle_dir=r"D:\Bundle",
61
  )
 
1
  from __future__ import annotations
2
 
3
  import unittest
4
+ from pathlib import Path
5
 
6
  import numpy as np
7
+ import socket
8
 
9
  from backend.runtime_utils import (
10
+ can_bind_tcp_port,
11
  clone_cache_payload,
12
+ find_free_tcp_port,
13
  make_json_compatible,
14
  parse_cors_origins,
15
  resolve_runtime_paths,
 
17
 
18
 
19
  class RuntimeUtilsTests(unittest.TestCase):
20
+ @staticmethod
21
+ def _module_file() -> str:
22
+ return str(Path(__file__).resolve().with_name("main.py"))
23
+
24
  def test_parse_cors_origins_splits_and_trims(self) -> None:
25
  result = parse_cors_origins(" https://a.com,https://b.com , , ")
26
  self.assertEqual(result, ["https://a.com", "https://b.com"])
 
28
  def test_parse_cors_origins_defaults_to_wildcard(self) -> None:
29
  self.assertEqual(parse_cors_origins(" "), ["*"])
30
 
31
+ def test_can_bind_tcp_port_detects_busy_listener(self) -> None:
32
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket:
33
+ server_socket.bind(("127.0.0.1", 0))
34
+ server_socket.listen(1)
35
+ busy_port = int(server_socket.getsockname()[1])
36
+ self.assertFalse(can_bind_tcp_port("127.0.0.1", busy_port))
37
+
38
+ self.assertTrue(can_bind_tcp_port("127.0.0.1", busy_port))
39
+
40
+ def test_find_free_tcp_port_returns_bindable_port(self) -> None:
41
+ port = find_free_tcp_port("127.0.0.1")
42
+ self.assertGreater(port, 0)
43
+ self.assertTrue(can_bind_tcp_port("127.0.0.1", port))
44
+
45
  def test_clone_cache_payload_returns_independent_copy(self) -> None:
46
  original = {"nested": {"value": 1}, "items": [1, 2, 3]}
47
  cloned = clone_cache_payload(original)
 
67
  self.assertEqual(result["series"], [1.0, 2.0])
68
 
69
  def test_resolve_runtime_paths_for_dev_mode(self) -> None:
70
+ module_file = self._module_file()
71
+ expected_current_dir = str(Path(module_file).resolve().parent)
72
+ expected_project_root = str(Path(module_file).resolve().parent.parent)
73
  paths = resolve_runtime_paths(
74
+ module_file=module_file,
75
  is_frozen=False,
76
  bundle_dir=r"D:\Bundle",
77
  )
78
+ self.assertEqual(paths.current_dir, expected_current_dir)
79
+ self.assertEqual(paths.project_root, expected_project_root)
80
 
81
  def test_resolve_runtime_paths_for_frozen_mode(self) -> None:
82
  paths = resolve_runtime_paths(
83
+ module_file=self._module_file(),
84
  is_frozen=True,
85
  bundle_dir=r"D:\Bundle",
86
  )
backend/test_server_runtime.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import tempfile
5
+ import textwrap
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ from backend import server_runtime
10
+
11
+
12
+ class _FakeLogger:
13
+ def __init__(self) -> None:
14
+ self.warnings: list[tuple[str, tuple[object, ...]]] = []
15
+
16
+ def warning(self, msg: str, *args: object, **kwargs: object) -> None:
17
+ self.warnings.append((msg, args))
18
+
19
+
20
+ class ServerRuntimeTests(unittest.TestCase):
21
+ def test_bootstrap_runtime_port_sets_huggingface_default(self) -> None:
22
+ env = {"SPACE_ID": "demo-space"}
23
+
24
+ server_runtime.bootstrap_runtime_port(env, huggingface_port=7860)
25
+
26
+ self.assertEqual(env["PORT"], "7860")
27
+
28
+ def test_bootstrap_runtime_port_keeps_existing_port(self) -> None:
29
+ env = {"SPACE_HOST": "space.example", "PORT": "9000"}
30
+
31
+ server_runtime.bootstrap_runtime_port(env, huggingface_port=7860)
32
+
33
+ self.assertEqual(env["PORT"], "9000")
34
+
35
+ def test_resolve_server_port_keeps_configured_port_when_available(self) -> None:
36
+ env = {"PORT": "8000"}
37
+ port = server_runtime.resolve_server_port(
38
+ "127.0.0.1",
39
+ env=env,
40
+ port_checker=lambda host, value: host == "127.0.0.1" and value == 8000,
41
+ free_port_finder=lambda host: 8123,
42
+ )
43
+ self.assertEqual(port, 8000)
44
+ self.assertEqual(env["PORT"], "8000")
45
+
46
+ def test_resolve_server_port_falls_back_when_configured_port_is_busy(self) -> None:
47
+ env = {"PORT": "8000"}
48
+ logger = _FakeLogger()
49
+
50
+ port = server_runtime.resolve_server_port(
51
+ "127.0.0.1",
52
+ env=env,
53
+ logger=logger,
54
+ port_checker=lambda host, value: False,
55
+ free_port_finder=lambda host: 8123,
56
+ )
57
+
58
+ self.assertEqual(port, 8123)
59
+ self.assertEqual(env["PORT"], "8123")
60
+ self.assertEqual(len(logger.warnings), 1)
61
+
62
+ def test_resolve_server_port_uses_huggingface_default_when_missing(self) -> None:
63
+ env = {"SPACE_ID": "demo-space"}
64
+
65
+ port = server_runtime.resolve_server_port(
66
+ "0.0.0.0",
67
+ env=env,
68
+ port_checker=lambda host, value: False,
69
+ free_port_finder=lambda host: 8123,
70
+ )
71
+
72
+ self.assertEqual(port, 7860)
73
+ self.assertEqual(env["PORT"], "7860")
74
+
75
+ def test_load_runtime_env_returns_false_for_missing_file(self) -> None:
76
+ with tempfile.TemporaryDirectory() as temp_dir:
77
+ env_file = Path(temp_dir) / "missing-aiforecast.env"
78
+ self.assertFalse(server_runtime.load_runtime_env(env_file))
79
+
80
+ def test_load_fastapi_app_imports_requested_module_attribute(self) -> None:
81
+ with tempfile.TemporaryDirectory() as temp_dir:
82
+ project_root = Path(temp_dir)
83
+ module_name = "temp_runtime_app_module"
84
+ module_path = project_root / f"{module_name}.py"
85
+ module_path.write_text(
86
+ textwrap.dedent(
87
+ """
88
+ app = {"name": "demo-app"}
89
+ """
90
+ ).strip(),
91
+ encoding="utf-8",
92
+ )
93
+
94
+ try:
95
+ app = server_runtime.load_fastapi_app(
96
+ project_root,
97
+ module_name=module_name,
98
+ attr_name="app",
99
+ )
100
+ self.assertEqual(app, {"name": "demo-app"})
101
+ self.assertIn(str(project_root), sys.path)
102
+ finally:
103
+ sys.modules.pop(module_name, None)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ unittest.main()
backend/test_startup_utils.py CHANGED
@@ -7,7 +7,7 @@ from backend.startup_utils import (
7
  build_source_selftest_urls,
8
  clear_stale_ip_limits,
9
  run_source_selftest,
10
- warmup_kronos,
11
  )
12
 
13
 
@@ -92,14 +92,14 @@ class StartupUtilsTests(unittest.IsolatedAsyncioTestCase):
92
  self.assertEqual(startup_sources["broken"]["error"], "network down")
93
  self.assertEqual(startup_sources["binance"]["checked_at"], "2026-04-26T00:00:00+00:00")
94
 
95
- async def test_warmup_kronos_updates_success_state(self) -> None:
96
  logger = _FakeLogger()
97
  state = {"warming": False, "last_error": "old", "loaded": False, "device": "not_loaded"}
98
  forecaster = _FakeForecaster()
99
 
100
- await warmup_kronos(
101
  forecaster=forecaster,
102
- startup_kronos_state=state,
103
  logger=logger,
104
  )
105
 
@@ -108,14 +108,14 @@ class StartupUtilsTests(unittest.IsolatedAsyncioTestCase):
108
  self.assertTrue(state["loaded"])
109
  self.assertEqual(state["device"], "cpu")
110
 
111
- async def test_warmup_kronos_tracks_failure_state(self) -> None:
112
  logger = _FakeLogger()
113
  state = {"warming": False, "last_error": None, "loaded": True, "device": "not_loaded"}
114
  forecaster = _FakeForecaster(should_fail=True)
115
 
116
- await warmup_kronos(
117
  forecaster=forecaster,
118
- startup_kronos_state=state,
119
  logger=logger,
120
  )
121
 
 
7
  build_source_selftest_urls,
8
  clear_stale_ip_limits,
9
  run_source_selftest,
10
+ warmup_timesfm,
11
  )
12
 
13
 
 
92
  self.assertEqual(startup_sources["broken"]["error"], "network down")
93
  self.assertEqual(startup_sources["binance"]["checked_at"], "2026-04-26T00:00:00+00:00")
94
 
95
+ async def test_warmup_timesfm_updates_success_state(self) -> None:
96
  logger = _FakeLogger()
97
  state = {"warming": False, "last_error": "old", "loaded": False, "device": "not_loaded"}
98
  forecaster = _FakeForecaster()
99
 
100
+ await warmup_timesfm(
101
  forecaster=forecaster,
102
+ startup_timesfm_state=state,
103
  logger=logger,
104
  )
105
 
 
108
  self.assertTrue(state["loaded"])
109
  self.assertEqual(state["device"], "cpu")
110
 
111
+ async def test_warmup_timesfm_tracks_failure_state(self) -> None:
112
  logger = _FakeLogger()
113
  state = {"warming": False, "last_error": None, "loaded": True, "device": "not_loaded"}
114
  forecaster = _FakeForecaster(should_fail=True)
115
 
116
+ await warmup_timesfm(
117
  forecaster=forecaster,
118
+ startup_timesfm_state=state,
119
  logger=logger,
120
  )
121
 
backend/test_timesfm_provider.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import types
5
+ import unittest
6
+ from unittest.mock import patch
7
+
8
+ from backend.forecasting.providers import timesfm_provider
9
+
10
+
11
+ class _FakeParameter:
12
+ def __init__(self, *, is_meta: bool) -> None:
13
+ self.is_meta = is_meta
14
+
15
+
16
+ class _FakeTimesfmModel:
17
+ def __init__(self, *, is_meta: bool) -> None:
18
+ self.device = "cpu"
19
+ self._parameters = [_FakeParameter(is_meta=is_meta)]
20
+ self.calls: list[tuple[object, ...]] = []
21
+
22
+ def parameters(self): # type: ignore[no-untyped-def]
23
+ return iter(self._parameters)
24
+
25
+ def load_state_dict(self, tensors, strict: bool = True, assign: bool = False) -> None:
26
+ self.calls.append(("load_state_dict", tensors, strict, assign))
27
+
28
+ def to_empty(self, *, device: str) -> None:
29
+ self.calls.append(("to_empty", device))
30
+
31
+ def to(self, device: str) -> None:
32
+ self.calls.append(("to", device))
33
+
34
+ def eval(self) -> None:
35
+ self.calls.append(("eval",))
36
+
37
+
38
+ class TimesfmProviderCompatibilityTests(unittest.TestCase):
39
+ def setUp(self) -> None:
40
+ timesfm_provider._TIMESFM_RUNTIME_PATCHED = False
41
+
42
+ def test_runtime_patch_enables_assign_for_meta_models(self) -> None:
43
+ fake_internal_module = types.SimpleNamespace(
44
+ load_file=lambda path: {"path": path},
45
+ logging=logging,
46
+ TimesFM_2p5_200M_torch_module=type("FakeModelModule", (), {}),
47
+ )
48
+
49
+ with patch.object(timesfm_provider, "timesfm", object()), patch.object(
50
+ timesfm_provider.importlib,
51
+ "import_module",
52
+ return_value=fake_internal_module,
53
+ ):
54
+ timesfm_provider._patch_timesfm_runtime_compatibility(logging.getLogger("test"))
55
+
56
+ patched_cls = fake_internal_module.TimesFM_2p5_200M_torch_module
57
+ fake_model = _FakeTimesfmModel(is_meta=True)
58
+ patched_cls.load_checkpoint(fake_model, "checkpoint.safetensors", torch_compile=False)
59
+
60
+ self.assertIn(("load_state_dict", {"path": "checkpoint.safetensors"}, True, True), fake_model.calls)
61
+ self.assertIn(("to", "cpu"), fake_model.calls)
62
+ self.assertIn(("eval",), fake_model.calls)
63
+ self.assertTrue(getattr(patched_cls, "_aiforecast_meta_patch"))
64
+
65
+ def test_runtime_patch_keeps_standard_loading_for_non_meta_models(self) -> None:
66
+ fake_internal_module = types.SimpleNamespace(
67
+ load_file=lambda path: {"path": path},
68
+ logging=logging,
69
+ TimesFM_2p5_200M_torch_module=type("FakeModelModule", (), {}),
70
+ )
71
+
72
+ with patch.object(timesfm_provider, "timesfm", object()), patch.object(
73
+ timesfm_provider.importlib,
74
+ "import_module",
75
+ return_value=fake_internal_module,
76
+ ):
77
+ timesfm_provider._patch_timesfm_runtime_compatibility(logging.getLogger("test"))
78
+
79
+ patched_cls = fake_internal_module.TimesFM_2p5_200M_torch_module
80
+ fake_model = _FakeTimesfmModel(is_meta=False)
81
+ patched_cls.load_checkpoint(fake_model, "checkpoint.safetensors", torch_compile=False)
82
+
83
+ self.assertIn(("load_state_dict", {"path": "checkpoint.safetensors"}, True, False), fake_model.calls)
84
+ self.assertNotIn(("to_empty", "cpu"), fake_model.calls)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ unittest.main()
frontend/app.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/forecast-models.js ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (() => {
2
+ const lineStyleSolid = window.LightweightCharts?.LineStyle?.Solid ?? 0;
3
+ const modelOrder = Object.freeze(['kronos', 'timesfm', 'chronos']);
4
+ const defaultLineWidth = 1;
5
+ const modelLabels = Object.freeze({
6
+ kronos: 'Kronos',
7
+ timesfm: 'TimesFM',
8
+ chronos: 'Chronos',
9
+ });
10
+ const defaultSelection = Object.freeze(
11
+ Object.fromEntries(modelOrder.map((modelKey) => [modelKey, true]))
12
+ );
13
+ const modelTonePalette = Object.freeze({
14
+ kronos: Object.freeze({
15
+ up: '#2563eb',
16
+ flat: '#c2410c',
17
+ down: '#b91c1c',
18
+ }),
19
+ timesfm: Object.freeze({
20
+ up: '#16a34a',
21
+ flat: '#eab308',
22
+ down: '#ef4444',
23
+ }),
24
+ chronos: Object.freeze({
25
+ up: '#166534',
26
+ flat: '#a39b6b',
27
+ down: '#be185d',
28
+ }),
29
+ });
30
+ const modelSeriesStyle = Object.freeze(
31
+ Object.fromEntries(
32
+ modelOrder.map((modelKey) => [
33
+ modelKey,
34
+ Object.freeze({
35
+ lineWidth: defaultLineWidth,
36
+ lineStyle: lineStyleSolid,
37
+ }),
38
+ ])
39
+ )
40
+ );
41
+
42
+ function normalizeSelection(selection = null) {
43
+ const requested = selection && typeof selection === 'object' ? selection : defaultSelection;
44
+ const normalized = Object.fromEntries(
45
+ modelOrder.map((modelKey) => [
46
+ modelKey,
47
+ Boolean(
48
+ Object.prototype.hasOwnProperty.call(requested, modelKey)
49
+ ? requested[modelKey]
50
+ : defaultSelection[modelKey]
51
+ ),
52
+ ])
53
+ );
54
+ if (!Object.values(normalized).some(Boolean)) {
55
+ normalized[modelOrder[0]] = true;
56
+ }
57
+ return normalized;
58
+ }
59
+
60
+ function signature(selection = null) {
61
+ const normalized = normalizeSelection(selection);
62
+ return modelOrder
63
+ .map((modelKey) => `${modelKey[0]}${normalized[modelKey] ? 1 : 0}`)
64
+ .join('');
65
+ }
66
+
67
+ function createEmptyLines() {
68
+ return Object.fromEntries(modelOrder.map((modelKey) => [modelKey, []]));
69
+ }
70
+
71
+ window.AIFORECAST_MODEL_CONFIG = Object.freeze({
72
+ modelOrder,
73
+ modelLabels,
74
+ defaultSelection,
75
+ defaultLineWidth,
76
+ modelTonePalette,
77
+ modelSeriesStyle,
78
+ normalizeSelection,
79
+ signature,
80
+ createEmptyLines,
81
+ });
82
+ })();
frontend/index.html CHANGED
The diff for this file is too large to render. See raw diff
 
frontend/vendor/lightweight-charts.standalone.production.js ADDED
The diff for this file is too large to render. See raw diff
 
frontend/workspace.css CHANGED
@@ -1,5 +1,5 @@
1
  /* ═══════════════════════════════════════════════════════
2
- KRONOS MULTI-CHART WORKSPACE — CSS
3
  ═══════════════════════════════════════════════════════ */
4
 
5
  /* ── Layout Switcher (ultra-compact pill) ──────────── */
@@ -225,6 +225,48 @@
225
  bottom: 0;
226
  }
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  /* ── Pane Loader ───────────────────────────────────── */
229
  .pane-loader {
230
  position: absolute;
 
1
  /* ═══════════════════════════════════════════════════════
2
+ AI FORECAST MULTI-CHART WORKSPACE — CSS
3
  ═══════════════════════════════════════════════════════ */
4
 
5
  /* ── Layout Switcher (ultra-compact pill) ──────────── */
 
225
  bottom: 0;
226
  }
227
 
228
+ .pane-forecast-hover {
229
+ position: absolute;
230
+ top: 34px;
231
+ right: 8px;
232
+ z-index: 11;
233
+ min-height: 22px;
234
+ padding: 0 9px;
235
+ border-radius: 999px;
236
+ border: 1px solid rgba(102, 163, 255, 0.28);
237
+ background: rgba(7, 18, 38, 0.84);
238
+ color: rgba(235, 244, 255, 0.94);
239
+ display: inline-flex;
240
+ align-items: center;
241
+ gap: 7px;
242
+ font-family: var(--ff-display);
243
+ font-size: 0.56rem;
244
+ font-weight: 700;
245
+ letter-spacing: 0.08em;
246
+ text-transform: uppercase;
247
+ box-shadow: 0 10px 28px rgba(2, 8, 23, 0.26);
248
+ backdrop-filter: blur(10px);
249
+ -webkit-backdrop-filter: blur(10px);
250
+ pointer-events: none;
251
+ opacity: 1;
252
+ transform: translateY(0);
253
+ transition: opacity 0.16s ease, transform 0.16s ease, border-color 0.16s ease;
254
+ }
255
+
256
+ .pane-forecast-hover.hidden {
257
+ opacity: 0;
258
+ transform: translateY(-4px);
259
+ }
260
+
261
+ .pane-forecast-hover::before {
262
+ content: '';
263
+ width: 7px;
264
+ height: 7px;
265
+ border-radius: 50%;
266
+ background: currentColor;
267
+ box-shadow: 0 0 0 3px rgba(102, 163, 255, 0.18);
268
+ }
269
+
270
  /* ── Pane Loader ───────────────────────────────────── */
271
  .pane-loader {
272
  position: absolute;
frontend/workspace.js CHANGED
@@ -1,6 +1,6 @@
1
  /**
2
  * ═══════════════════════════════════════════════════════
3
- * KRONOS MULTI-CHART WORKSPACE ENGINE (V1)
4
  * Provides: PaneState, ChartPaneController, StreamManager,
5
  * DataCoordinator, WorkspaceController
6
  * ═══════════════════════════════════════════════════════
@@ -12,12 +12,36 @@ const CHART_HISTORY_LIMIT_WS = 500;
12
  const MAX_CONCURRENT_FETCHES = 4;
13
  const WS_RECONNECT_DELAY = 5000;
14
  const LAYOUT_PRESETS = [1, 2, 4, 8];
 
 
 
 
15
  const LAYOUT_GRID_MAP = {
16
  1: { cols: 1, rows: 1 },
17
  2: { cols: 2, rows: 1 },
18
  4: { cols: 2, rows: 2 },
19
  8: { cols: 4, rows: 2 },
20
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  /* ══════════════════════════════════════════════════════
23
  PaneState — Per-pane data container
@@ -29,12 +53,22 @@ class PaneState {
29
  this.interval = interval;
30
  this.indicatorMode = 'none';
31
  this.horizon = 10;
 
32
 
33
  // Chart instances (set by ChartPaneController)
34
  this.chartInstance = null;
35
  this.candleSeries = null;
36
- this.forecastSeries = { candles: null, p50: null, p10: null, p90: null, segments: [] };
 
 
 
 
 
 
 
 
37
  this.indicatorSeries = { bbUpper: null, bbMid: null, bbLower: null, rsi: null };
 
38
 
39
  // Network
40
  this.fetchController = null;
@@ -45,10 +79,23 @@ class PaneState {
45
 
46
  // Data
47
  this.lastCandleData = null;
48
- this.lastAnalysis = { payload: null, symbol: null, interval: null };
 
 
 
 
 
 
 
 
 
 
 
49
  this.chartContext = { symbol: null, interval: null };
50
- this.forecastContext = { symbol: null, interval: null, ready: false };
51
  this.priceFormat = { precision: 2, minMove: 0.01 };
 
 
52
 
53
  // UI
54
  this.loading = false;
@@ -70,8 +117,11 @@ class PaneState {
70
  return Boolean(
71
  this.lastAnalysis &&
72
  this.lastAnalysis.payload &&
 
73
  this.lastAnalysis.symbol === this.symbol &&
74
- this.lastAnalysis.interval === this.interval
 
 
75
  );
76
  }
77
 
@@ -80,17 +130,34 @@ class PaneState {
80
  this.forecastContext &&
81
  this.forecastContext.ready &&
82
  this.forecastContext.symbol === this.symbol &&
83
- this.forecastContext.interval === this.interval
 
 
84
  );
85
  }
86
 
 
 
 
 
 
 
 
 
87
 
88
  async fetchAI(options = {}) {
89
  const horizon = this.horizon || 24;
90
  const requestSymbol = this.symbol;
91
  const requestInterval = this.interval;
92
  const requestHorizon = horizon;
93
- const requestKey = `${requestSymbol}|${requestInterval}|${requestHorizon}`;
 
 
 
 
 
 
 
94
 
95
  if (!options.force && this.analysisRequestPromise && this.analysisRequestKey === requestKey) {
96
  return this.analysisRequestPromise;
@@ -104,121 +171,165 @@ class PaneState {
104
  this.analysisFetchController.abort();
105
  }
106
 
107
- if (typeof window.clearPaneForecastCandlesOnly === 'function') {
108
- window.clearPaneForecastCandlesOnly(this);
109
- } else if (this.forecastSeries?.candles?.setData) {
110
- this.forecastSeries.candles.setData([]);
111
- if (this.forecastSeries.candles.applyOptions) {
112
- this.forecastSeries.candles.applyOptions({ visible: false });
 
 
113
  }
114
  }
115
 
116
  const controller = new AbortController();
117
  this.analysisFetchController = controller;
118
  this.analysisRequestKey = requestKey;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
- // Show loading in mini gauges
121
  const shouldRenderPaneGauges = !(window.Workspace?.layoutPreset === 1 && this.paneId === 'pane-0');
122
  if (this.gaugesEl && shouldRenderPaneGauges && !this.hasMatchingAnalysis()) {
123
  this.gaugesEl.innerHTML = '<div class="loader-ring" style="width:16px;height:16px;border:2px solid rgba(40,80,140,0.15);border-top-color:var(--accent);animation:spin 0.8s linear infinite;border-radius:50%;"></div>';
124
  }
125
 
126
  const requestPromise = (async () => {
127
- try {
128
- const fData = await DataCoordinator.fetchForecast(requestSymbol, requestInterval, requestHorizon, controller.signal);
129
- if (controller.signal.aborted) return null;
130
- if (
131
- this.symbol !== requestSymbol
132
- || this.interval !== requestInterval
133
- || (this.horizon || 24) !== requestHorizon
134
- ) {
 
 
 
 
 
 
135
  return null;
136
  }
137
-
138
- const hasAnalysis = Boolean(fData?.analysis);
139
- if (hasAnalysis) {
140
- this.lastAnalysis = { payload: fData, symbol: requestSymbol, interval: requestInterval };
 
 
 
 
 
 
141
  }
142
-
143
- const forecastPoints = Array.isArray(fData.forecast) ? fData.forecast : [];
144
- if (this.lastCandleData && this.forecastSeries?.p50) {
145
- const fallbackActualPoint = {
146
- time: this.lastCandleData.time,
147
- value: (
148
- Number(this.lastCandleData.open ?? 0)
149
- + Number(this.lastCandleData.high ?? 0)
150
- + Number(this.lastCandleData.low ?? 0)
151
- + Number(this.lastCandleData.close ?? 0)
152
- ) / 4,
153
- };
154
- const forecastLine = typeof window.buildForecastLineFromRows === 'function'
155
- ? window.buildForecastLineFromRows(forecastPoints, fallbackActualPoint)
156
- : [fallbackActualPoint, ...forecastPoints
157
- .filter(d => d && d.time !== undefined && d.p50 !== undefined && d.time !== this.lastCandleData.time)
158
- .map(d => ({ time: d.time, value: d.p50 }))];
159
- const hasForecast = forecastLine.length > 1;
160
-
161
- if (hasForecast) {
162
- if (typeof window.renderPaneForecastVisuals === 'function') {
163
- window.renderPaneForecastVisuals(this, forecastLine);
164
- } else {
165
- if (this.forecastSeries.candles?.setData) {
166
- this.forecastSeries.candles.setData([]);
167
- }
168
- if (this.forecastSeries.p10?.setData) {
169
- this.forecastSeries.p10.setData([]);
170
- }
171
- if (this.forecastSeries.p90?.setData) {
172
- this.forecastSeries.p90.setData([]);
173
- }
174
- this.forecastSeries.p50.setData(forecastLine);
175
  }
176
- this.forecastContext = { symbol: requestSymbol, interval: requestInterval, ready: true };
177
- }
178
- }
 
 
 
 
 
 
179
 
180
- if (typeof window.renderPaneAnalysisUI === 'function') {
181
- window.renderPaneAnalysisUI(this);
182
  }
183
 
184
- if (
185
- hasAnalysis &&
186
- typeof window.Workspace !== 'undefined' &&
187
- window.Workspace?.activePaneId === this.paneId &&
188
- typeof window.renderCompactGauges === 'function'
189
- ) {
190
- window.renderCompactGauges(this.symbol, this.interval, fData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  }
192
 
193
- if (
194
- hasAnalysis &&
195
- this.analysisOpen &&
196
- typeof window.renderAnalysisPanel === 'function' &&
197
- typeof document !== 'undefined'
198
- ) {
199
- const analysisPanel = document.getElementById('analysisPanel');
200
- if (analysisPanel?.classList.contains('active')) {
201
- window.renderAnalysisPanel(this.symbol, this.interval, fData);
202
- if (typeof window.updateDashboardScale === 'function') {
203
- setTimeout(window.updateDashboardScale, 10);
204
- }
205
  }
206
  }
207
 
208
- return fData;
209
  } catch (e) {
210
  if (e.name === 'AbortError') return null;
211
  console.error(`[Pane ${this.paneId}] AI fetch error:`, e);
 
212
  if (typeof window.renderPaneAnalysisUI === 'function') {
213
  window.renderPaneAnalysisUI(this);
214
  }
215
-
216
- // Retry
217
  this.analysisRetryTimer = setTimeout(() => {
218
  this.analysisRetryTimer = null;
219
  this.fetchAI({ force: true });
220
  }, 15000);
221
-
222
  return null;
223
  } finally {
224
  if (this.analysisFetchController === controller) {
@@ -274,6 +385,7 @@ class PaneState {
274
  interval: this.interval,
275
  indicator: this.indicatorMode,
276
  horizon: this.horizon,
 
277
  };
278
  }
279
  }
@@ -308,8 +420,10 @@ const StreamManager = {
308
  }
309
 
310
  const apiBase = window.__AIFORECAST_API_BASE || '';
311
- const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
312
- const wsUrl = `${apiBase.replace(/^https?:\/\//, wsProtocol)}/ws/price/${symbol}?interval=${encodeURIComponent(interval)}`;
 
 
313
  console.log(`[StreamManager] New WS: ${wsUrl} (pane ${paneId})`);
314
 
315
  const ws = new WebSocket(wsUrl);
@@ -451,11 +565,20 @@ const DataCoordinator = {
451
  );
452
  },
453
 
454
- async fetchForecast(symbol, interval, horizon, signal) {
 
455
  return this.fetch(
456
- `/api/forecast/${encodeURIComponent(symbol)}?interval=${interval}&horizon=${horizon}`,
457
  signal
458
  );
 
 
 
 
 
 
 
 
459
  }
460
  };
461
 
@@ -601,7 +724,7 @@ const Workspace = {
601
  /* ── Persistence ───────────────────────────── */
602
  save() {
603
  const data = {
604
- version: 1,
605
  layoutPreset: this.layoutPreset,
606
  activePaneId: this.activePaneId,
607
  panes: Array.from(this.panes.values()).map(p => p.toJSON()),
@@ -616,7 +739,7 @@ const Workspace = {
616
  const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
617
  if (!raw) return null;
618
  const data = JSON.parse(raw);
619
- if (!data || data.version !== 1) return null;
620
  return data;
621
  } catch (_) {
622
  return null;
@@ -762,4 +885,8 @@ Workspace.setActivePane = function setActivePaneOverride(id) {
762
  window.renderPaneAnalysisUI(pane);
763
  }
764
  });
 
 
 
 
765
  };
 
1
  /**
2
  * ═══════════════════════════════════════════════════════
3
+ * AI FORECAST MULTI-CHART WORKSPACE ENGINE (V1)
4
  * Provides: PaneState, ChartPaneController, StreamManager,
5
  * DataCoordinator, WorkspaceController
6
  * ═══════════════════════════════════════════════════════
 
12
  const MAX_CONCURRENT_FETCHES = 4;
13
  const WS_RECONNECT_DELAY = 5000;
14
  const LAYOUT_PRESETS = [1, 2, 4, 8];
15
+ const MODEL_CONFIG = window.AIFORECAST_MODEL_CONFIG;
16
+ if (!MODEL_CONFIG) {
17
+ throw new Error('AIFORECAST_MODEL_CONFIG is missing');
18
+ }
19
  const LAYOUT_GRID_MAP = {
20
  1: { cols: 1, rows: 1 },
21
  2: { cols: 2, rows: 1 },
22
  4: { cols: 2, rows: 2 },
23
  8: { cols: 4, rows: 2 },
24
  };
25
+ const FORECAST_MODEL_ORDER = MODEL_CONFIG.modelOrder;
26
+ const DEFAULT_AI_MODEL_SELECTION = MODEL_CONFIG.defaultSelection;
27
+
28
+ function normalizeAiModelSelection(selection = null) {
29
+ return MODEL_CONFIG.normalizeSelection(selection);
30
+ }
31
+
32
+ function getAiModelSignature(selection = null) {
33
+ return MODEL_CONFIG.signature(selection);
34
+ }
35
+
36
+ function buildWebSocketUrl(apiBase, path) {
37
+ const fallbackOrigin = (
38
+ window.location.origin &&
39
+ window.location.origin !== 'null'
40
+ ) ? window.location.origin : 'http://127.0.0.1';
41
+ const resolvedUrl = new URL(path, apiBase || fallbackOrigin);
42
+ resolvedUrl.protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
43
+ return resolvedUrl.toString();
44
+ }
45
 
46
  /* ══════════════════════════════════════════════════════
47
  PaneState — Per-pane data container
 
53
  this.interval = interval;
54
  this.indicatorMode = 'none';
55
  this.horizon = 10;
56
+ this.aiModels = normalizeAiModelSelection();
57
 
58
  // Chart instances (set by ChartPaneController)
59
  this.chartInstance = null;
60
  this.candleSeries = null;
61
+ this.forecastSeries = {
62
+ candles: null,
63
+ reserve: null,
64
+ p50: null,
65
+ p10: null,
66
+ p90: null,
67
+ segments: [],
68
+ models: Object.fromEntries(FORECAST_MODEL_ORDER.map((modelKey) => [modelKey, null])),
69
+ };
70
  this.indicatorSeries = { bbUpper: null, bbMid: null, bbLower: null, rsi: null };
71
+ this.forecastHoverEl = null;
72
 
73
  // Network
74
  this.fetchController = null;
 
79
 
80
  // Data
81
  this.lastCandleData = null;
82
+ this.lastAnalysis = {
83
+ payload: null,
84
+ symbol: null,
85
+ interval: null,
86
+ horizon: null,
87
+ modelSignature: null,
88
+ complete: false,
89
+ };
90
+ this.analysisPayloadCache = {};
91
+ this.partialModelPayloads = {};
92
+ this.analysisProgress = { requested: [], ready: [], pending: [], failed: [] };
93
+ this.cachedForecastModelLines = MODEL_CONFIG.createEmptyLines();
94
  this.chartContext = { symbol: null, interval: null };
95
+ this.forecastContext = { symbol: null, interval: null, horizon: null, modelSignature: null, ready: false };
96
  this.priceFormat = { precision: 2, minMove: 0.01 };
97
+ this.historicalBarCount = 0;
98
+ this.loadedFutureBars = 0;
99
 
100
  // UI
101
  this.loading = false;
 
117
  return Boolean(
118
  this.lastAnalysis &&
119
  this.lastAnalysis.payload &&
120
+ this.lastAnalysis.complete === true &&
121
  this.lastAnalysis.symbol === this.symbol &&
122
+ this.lastAnalysis.interval === this.interval &&
123
+ this.lastAnalysis.horizon === this.horizon &&
124
+ this.lastAnalysis.modelSignature === this.getAiModelSignature()
125
  );
126
  }
127
 
 
130
  this.forecastContext &&
131
  this.forecastContext.ready &&
132
  this.forecastContext.symbol === this.symbol &&
133
+ this.forecastContext.interval === this.interval &&
134
+ this.forecastContext.horizon === this.horizon &&
135
+ this.forecastContext.modelSignature === this.getAiModelSignature()
136
  );
137
  }
138
 
139
+ getAiModelSelection() {
140
+ return normalizeAiModelSelection(this.aiModels);
141
+ }
142
+
143
+ getAiModelSignature() {
144
+ return getAiModelSignature(this.aiModels);
145
+ }
146
+
147
 
148
  async fetchAI(options = {}) {
149
  const horizon = this.horizon || 24;
150
  const requestSymbol = this.symbol;
151
  const requestInterval = this.interval;
152
  const requestHorizon = horizon;
153
+ const requestModels = this.getAiModelSelection();
154
+ const requestModelSignature = getAiModelSignature(requestModels);
155
+ const requestKey = `${requestSymbol}|${requestInterval}|${requestHorizon}|${requestModelSignature}`;
156
+ const enabledModelKeys = FORECAST_MODEL_ORDER.filter((modelKey) => requestModels[modelKey]);
157
+
158
+ if (!options.force && this.hasMatchingAnalysis()) {
159
+ return this.lastAnalysis.payload;
160
+ }
161
 
162
  if (!options.force && this.analysisRequestPromise && this.analysisRequestKey === requestKey) {
163
  return this.analysisRequestPromise;
 
171
  this.analysisFetchController.abort();
172
  }
173
 
174
+ if (!options.preserveForecastVisuals) {
175
+ if (typeof window.clearPaneForecastCandlesOnly === 'function') {
176
+ window.clearPaneForecastCandlesOnly(this);
177
+ } else if (this.forecastSeries?.candles?.setData) {
178
+ this.forecastSeries.candles.setData([]);
179
+ if (this.forecastSeries.candles.applyOptions) {
180
+ this.forecastSeries.candles.applyOptions({ visible: false });
181
+ }
182
  }
183
  }
184
 
185
  const controller = new AbortController();
186
  this.analysisFetchController = controller;
187
  this.analysisRequestKey = requestKey;
188
+ this.error = null;
189
+ this.partialModelPayloads = {};
190
+ this.analysisProgress = {
191
+ requested: [...enabledModelKeys],
192
+ ready: [],
193
+ pending: [...enabledModelKeys],
194
+ failed: [],
195
+ };
196
+ this.lastAnalysis = {
197
+ payload: null,
198
+ symbol: requestSymbol,
199
+ interval: requestInterval,
200
+ horizon: requestHorizon,
201
+ modelSignature: requestModelSignature,
202
+ complete: false,
203
+ };
204
+ if (typeof window.renderPaneAnalysisUI === 'function') {
205
+ window.renderPaneAnalysisUI(this);
206
+ }
207
 
 
208
  const shouldRenderPaneGauges = !(window.Workspace?.layoutPreset === 1 && this.paneId === 'pane-0');
209
  if (this.gaugesEl && shouldRenderPaneGauges && !this.hasMatchingAnalysis()) {
210
  this.gaugesEl.innerHTML = '<div class="loader-ring" style="width:16px;height:16px;border:2px solid rgba(40,80,140,0.15);border-top-color:var(--accent);animation:spin 0.8s linear infinite;border-radius:50%;"></div>';
211
  }
212
 
213
  const requestPromise = (async () => {
214
+ const successfulPayloads = {};
215
+ const failedModels = [];
216
+ const removePendingModel = (modelKey) => {
217
+ this.analysisProgress.pending = this.analysisProgress.pending.filter((key) => key !== modelKey);
218
+ };
219
+ const isRequestStale = () => (
220
+ controller.signal.aborted
221
+ || this.symbol !== requestSymbol
222
+ || this.interval !== requestInterval
223
+ || (this.horizon || 24) !== requestHorizon
224
+ || this.getAiModelSignature() !== requestModelSignature
225
+ );
226
+ const applyProgressivePayload = (isComplete = false) => {
227
+ if (typeof window.buildCombinedForecastPayloadForPane !== 'function') {
228
  return null;
229
  }
230
+ const payload = window.buildCombinedForecastPayloadForPane(
231
+ requestModels,
232
+ successfulPayloads,
233
+ {
234
+ pending: this.analysisProgress.pending,
235
+ failed: failedModels,
236
+ },
237
+ );
238
+ if (!payload || typeof window.applyForecastPayloadToPane !== 'function') {
239
+ return payload;
240
  }
241
+ window.applyForecastPayloadToPane(
242
+ this,
243
+ payload,
244
+ {
245
+ symbol: requestSymbol,
246
+ interval: requestInterval,
247
+ horizon: requestHorizon,
248
+ models: requestModels,
249
+ modelSignature: requestModelSignature,
250
+ complete: isComplete,
251
+ },
252
+ );
253
+ return payload;
254
+ };
255
+
256
+ try {
257
+ const modelResults = await Promise.allSettled(
258
+ enabledModelKeys.map(async (modelKey) => {
259
+ const modelPayload = await DataCoordinator.fetchForecastModel(
260
+ requestSymbol,
261
+ requestInterval,
262
+ requestHorizon,
263
+ modelKey,
264
+ controller.signal,
265
+ );
266
+ if (isRequestStale()) {
267
+ return null;
 
 
 
 
 
 
268
  }
269
+ successfulPayloads[modelKey] = modelPayload;
270
+ this.partialModelPayloads = { ...successfulPayloads };
271
+ if (!this.analysisProgress.ready.includes(modelKey)) {
272
+ this.analysisProgress.ready = [...this.analysisProgress.ready, modelKey];
273
+ }
274
+ removePendingModel(modelKey);
275
+ return applyProgressivePayload(false);
276
+ }),
277
+ );
278
 
279
+ if (isRequestStale()) {
280
+ return null;
281
  }
282
 
283
+ modelResults.forEach((result, index) => {
284
+ const modelKey = enabledModelKeys[index];
285
+ if (result.status === 'fulfilled') {
286
+ return;
287
+ }
288
+ if (result.reason?.name === 'AbortError') {
289
+ return;
290
+ }
291
+ failedModels.push(modelKey);
292
+ removePendingModel(modelKey);
293
+ console.error(`[Pane ${this.paneId}] ${modelKey} forecast error:`, result.reason);
294
+ });
295
+
296
+ this.analysisProgress.failed = [...failedModels];
297
+ const finalPayload = applyProgressivePayload(
298
+ failedModels.length === 0
299
+ && this.analysisProgress.ready.length === enabledModelKeys.length,
300
+ );
301
+
302
+ if (!this.analysisProgress.ready.length || !finalPayload) {
303
+ const failureMessage = failedModels.length
304
+ ? `Khong co AI model nao tra ket qua: ${failedModels.join(', ')}`
305
+ : 'Khong co AI model nao tra ket qua';
306
+ throw new Error(failureMessage);
307
  }
308
 
309
+ if (failedModels.length) {
310
+ this.error = `Dang thieu du lieu: ${failedModels.join(', ')}`;
311
+ if (!this.analysisRetryTimer) {
312
+ this.analysisRetryTimer = setTimeout(() => {
313
+ this.analysisRetryTimer = null;
314
+ this.fetchAI({ force: true, preserveForecastVisuals: true });
315
+ }, 15000);
 
 
 
 
 
316
  }
317
  }
318
 
319
+ return finalPayload;
320
  } catch (e) {
321
  if (e.name === 'AbortError') return null;
322
  console.error(`[Pane ${this.paneId}] AI fetch error:`, e);
323
+ this.error = e.message || String(e);
324
  if (typeof window.renderPaneAnalysisUI === 'function') {
325
  window.renderPaneAnalysisUI(this);
326
  }
327
+
 
328
  this.analysisRetryTimer = setTimeout(() => {
329
  this.analysisRetryTimer = null;
330
  this.fetchAI({ force: true });
331
  }, 15000);
332
+
333
  return null;
334
  } finally {
335
  if (this.analysisFetchController === controller) {
 
385
  interval: this.interval,
386
  indicator: this.indicatorMode,
387
  horizon: this.horizon,
388
+ aiModels: this.getAiModelSelection(),
389
  };
390
  }
391
  }
 
420
  }
421
 
422
  const apiBase = window.__AIFORECAST_API_BASE || '';
423
+ const wsUrl = buildWebSocketUrl(
424
+ apiBase,
425
+ `/ws/price/${symbol}?interval=${encodeURIComponent(interval)}`,
426
+ );
427
  console.log(`[StreamManager] New WS: ${wsUrl} (pane ${paneId})`);
428
 
429
  const ws = new WebSocket(wsUrl);
 
565
  );
566
  },
567
 
568
+ async fetchForecast(symbol, interval, horizon, aiModels, signal) {
569
+ const normalizedModels = normalizeAiModelSelection(aiModels);
570
  return this.fetch(
571
+ `/api/forecast/${encodeURIComponent(symbol)}?interval=${interval}&horizon=${horizon}&use_kronos=${normalizedModels.kronos}&use_timesfm=${normalizedModels.timesfm}&use_chronos=${normalizedModels.chronos}`,
572
  signal
573
  );
574
+ },
575
+
576
+ async fetchForecastModel(symbol, interval, horizon, modelKey, signal) {
577
+ const selection = FORECAST_MODEL_ORDER.reduce((accumulator, key) => {
578
+ accumulator[key] = key === modelKey;
579
+ return accumulator;
580
+ }, {});
581
+ return this.fetchForecast(symbol, interval, horizon, selection, signal);
582
  }
583
  };
584
 
 
724
  /* ── Persistence ───────────────────────────── */
725
  save() {
726
  const data = {
727
+ version: 2,
728
  layoutPreset: this.layoutPreset,
729
  activePaneId: this.activePaneId,
730
  panes: Array.from(this.panes.values()).map(p => p.toJSON()),
 
739
  const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
740
  if (!raw) return null;
741
  const data = JSON.parse(raw);
742
+ if (!data || ![1, 2].includes(data.version)) return null;
743
  return data;
744
  } catch (_) {
745
  return null;
 
885
  window.renderPaneAnalysisUI(pane);
886
  }
887
  });
888
+
889
+ if (prevId !== id && typeof this.save === 'function') {
890
+ this.save();
891
+ }
892
  };
requirements.txt CHANGED
@@ -9,8 +9,12 @@ 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
 
 
 
 
9
  aiofiles==25.1.0
10
  pytz==2026.1.post1
11
  huggingface_hub==0.33.1
12
+ einops==0.8.1
13
  matplotlib==3.9.3
14
  tqdm==4.67.1
15
  safetensors==0.6.2
16
  python-dotenv==1.1.0
17
  timesfm[torch]>=2.0.0
18
+ transformers>=4.41,<5
19
+ accelerate>=0.34,<2
20
+ scikit-learn>=1.6,<2
run.bat CHANGED
@@ -1,12 +1,11 @@
1
  @echo off
2
- TITLE Kronos AI Trading Terminal
3
  COLOR 0B
4
  chcp 65001 >nul 2>&1
5
 
6
  echo.
7
- echo ====================================================
8
- echo KRONOS AI TRADING TERMINAL - STARTUP
9
- echo Powered by Google TimesFM 2.5 (200M params)
10
  echo ====================================================
11
  echo.
12
 
@@ -22,8 +21,8 @@ if not exist "%PYEXE%" (
22
  )
23
  echo [OK] venv found.
24
 
25
- :: [2/4] Check TimesFM installation
26
- echo [2/4] Checking TimesFM dependency...
27
  "%PYEXE%" -c "import timesfm; timesfm.TimesFM_2p5_200M_torch" >nul 2>&1
28
  if %ERRORLEVEL% neq 0 (
29
  echo [INFO] TimesFM not found. Installing...
@@ -50,6 +49,18 @@ if %ERRORLEVEL% neq 0 (
50
  echo [OK] TimesFM is ready.
51
  )
52
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  :: [3/4] Apply huggingface_hub compatibility patch
54
  echo [3/4] Applying compatibility patch...
55
  if exist "%~dp0scripts\patch_timesfm.py" (
@@ -62,8 +73,8 @@ if exist "%~dp0scripts\patch_timesfm.py" (
62
  :: [4/4] Launch server
63
  echo [4/4] Launching server...
64
  echo.
65
- echo [INFO] URL: http://127.0.0.1:8000
66
- echo [INFO] Browser opens automatically in ~3 seconds.
67
  echo [INFO] Press CTRL+C to stop.
68
  echo.
69
 
 
1
  @echo off
2
+ TITLE SuperAI Forecast Terminal
3
  COLOR 0B
4
  chcp 65001 >nul 2>&1
5
 
6
  echo.
7
+ echo SUPERAI FORECAST TERMINAL - STARTUP
8
+ echo Powered by Kronos + TimesFM + Chronos
 
9
  echo ====================================================
10
  echo.
11
 
 
21
  )
22
  echo [OK] venv found.
23
 
24
+ :: [2/4] Check AI model dependencies
25
+ echo [2/4] Checking AI model dependencies...
26
  "%PYEXE%" -c "import timesfm; timesfm.TimesFM_2p5_200M_torch" >nul 2>&1
27
  if %ERRORLEVEL% neq 0 (
28
  echo [INFO] TimesFM not found. Installing...
 
49
  echo [OK] TimesFM is ready.
50
  )
51
 
52
+ if exist "%~dp0libs\chronos-forecasting\src\chronos\__init__.py" (
53
+ echo [OK] Chronos repository is ready.
54
+ ) else (
55
+ echo [INFO] Chronos repository not found. Cloning...
56
+ git clone --depth=1 https://github.com/amazon-science/chronos-forecasting "%~dp0libs\chronos-forecasting"
57
+ if %ERRORLEVEL% neq 0 (
58
+ echo [WARN] Chronos clone failed. The app will continue, but Chronos may be unavailable.
59
+ ) else (
60
+ echo [OK] Chronos repository is ready.
61
+ )
62
+ )
63
+
64
  :: [3/4] Apply huggingface_hub compatibility patch
65
  echo [3/4] Applying compatibility patch...
66
  if exist "%~dp0scripts\patch_timesfm.py" (
 
73
  :: [4/4] Launch server
74
  echo [4/4] Launching server...
75
  echo.
76
+ echo [INFO] Browser opens automatically when the server is ready.
77
+ echo [INFO] Check launcher log for the exact local address.
78
  echo [INFO] Press CTRL+C to stop.
79
  echo.
80
 
scripts/patch_timesfm.py CHANGED
@@ -1,56 +1,105 @@
1
  """
2
- Auto-patch timesfm _from_pretrained to strip unknown kwargs (proxies, etc.)
3
- that huggingface_hub >=0.30 passes but TimesFM.__init__ doesn't accept.
4
 
5
- This script is safe to run multiple times (idempotent).
 
 
 
 
 
 
6
  """
7
- import importlib.util
 
 
8
  import sys
9
  from pathlib import Path
10
 
11
- MARKER = "# huggingface_hub >=0.30 passes extra kwargs"
12
- PATCH = '''\
13
- # huggingface_hub >=0.30 passes extra kwargs (proxies, headers, etc.)
14
- # that TimesFM.__init__ doesn't accept strip them out.
15
  _KNOWN_INIT_KWARGS = {"torch_compile", "config"}
16
  model_kwargs = {k: v for k, v in model_kwargs.items() if k in _KNOWN_INIT_KWARGS}
17
 
18
- '''
19
- TARGET_LINE = " # Create an instance of the model wrapper class.\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
22
- def find_timesfm_torch_file() -> list[Path]:
23
- """Find all timesfm_2p5_torch.py files accessible to the current Python."""
24
- candidates = []
25
- for sp in sys.path:
26
- p = Path(sp) / "timesfm" / "timesfm_2p5" / "timesfm_2p5_torch.py"
27
- if p.exists():
28
- candidates.append(p)
29
  return candidates
30
 
31
 
32
- def patch_file(path: Path) -> str:
33
- text = path.read_text(encoding="utf-8")
 
 
 
 
34
 
35
- if MARKER in text:
36
- return f"already patched: {path}"
37
 
38
- if TARGET_LINE not in text:
39
- return f"target line not found (different version?): {path}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- patched = text.replace(TARGET_LINE, PATCH + TARGET_LINE)
42
- path.write_text(patched, encoding="utf-8")
43
- return f"patched OK: {path}"
 
44
 
45
 
46
  if __name__ == "__main__":
47
- files = find_timesfm_torch_file()
48
  if not files:
49
  print("[patch_timesfm] No timesfm installation found in sys.path.")
50
  sys.exit(1)
51
 
52
- for f in files:
53
- result = patch_file(f)
54
- print(f"[patch_timesfm] {result}")
55
 
56
  sys.exit(0)
 
1
  """
2
+ Apply local compatibility patches to installed TimesFM source files.
 
3
 
4
+ Patches:
5
+ 1. Strip unknown kwargs that huggingface_hub >= 0.30 may forward into
6
+ TimesFM.__init__.
7
+ 2. Make checkpoint loading work when the model was created with meta tensors
8
+ by using assign=True (or a to_empty fallback).
9
+
10
+ This script is idempotent and safe to run multiple times.
11
  """
12
+
13
+ from __future__ import annotations
14
+
15
  import sys
16
  from pathlib import Path
17
 
18
+ INIT_MARKER = "# AI Forecast patch: strip unsupported hub kwargs"
19
+ INIT_TARGET = " # Create an instance of the model wrapper class.\n"
20
+ INIT_PATCH = """\
21
+ # AI Forecast patch: strip unsupported hub kwargs from huggingface_hub.
22
  _KNOWN_INIT_KWARGS = {"torch_compile", "config"}
23
  model_kwargs = {k: v for k, v in model_kwargs.items() if k in _KNOWN_INIT_KWARGS}
24
 
25
+ """
26
+
27
+ META_MARKER = " # AI Forecast patch: support loading checkpoints into meta tensors.\n"
28
+ META_TARGET = """\
29
+ def load_checkpoint(self, path: str, **kwargs):
30
+ \"\"\"Loads a PyTorch TimesFM model from a checkpoint.\"\"\"
31
+ tensors = load_file(path)
32
+ self.load_state_dict(tensors, strict=True)
33
+ self.to(self.device)
34
+ """
35
+ META_PATCH = """\
36
+ def load_checkpoint(self, path: str, **kwargs):
37
+ \"\"\"Loads a PyTorch TimesFM model from a checkpoint.\"\"\"
38
+ tensors = load_file(path)
39
+ # AI Forecast patch: support loading checkpoints into meta tensors.
40
+ has_meta_parameters = any(
41
+ getattr(parameter, "is_meta", False) for parameter in self.parameters()
42
+ )
43
+ try:
44
+ if has_meta_parameters:
45
+ self.load_state_dict(tensors, strict=True, assign=True)
46
+ else:
47
+ self.load_state_dict(tensors, strict=True)
48
+ except TypeError:
49
+ if has_meta_parameters:
50
+ self.to_empty(device=self.device)
51
+ self.load_state_dict(tensors, strict=True)
52
+ self.to(self.device)
53
+ """
54
 
55
 
56
+ def find_timesfm_torch_files() -> list[Path]:
57
+ candidates: list[Path] = []
58
+ for sys_path_entry in sys.path:
59
+ path = Path(sys_path_entry) / "timesfm" / "timesfm_2p5" / "timesfm_2p5_torch.py"
60
+ if path.exists():
61
+ candidates.append(path)
 
62
  return candidates
63
 
64
 
65
+ def apply_patch_once(text: str, *, marker: str, target: str, replacement: str) -> tuple[str, bool, str]:
66
+ if marker in text:
67
+ return text, False, "already patched"
68
+ if target not in text:
69
+ return text, False, "target not found"
70
+ return text.replace(target, replacement, 1), True, "patched"
71
 
 
 
72
 
73
+ def patch_file(path: Path) -> str:
74
+ original = path.read_text(encoding="utf-8")
75
+ updated = original
76
+
77
+ updated, _, init_status = apply_patch_once(
78
+ updated,
79
+ marker=INIT_MARKER,
80
+ target=INIT_TARGET,
81
+ replacement=INIT_PATCH + INIT_TARGET,
82
+ )
83
+ updated, _, meta_status = apply_patch_once(
84
+ updated,
85
+ marker=META_MARKER,
86
+ target=META_TARGET,
87
+ replacement=META_PATCH,
88
+ )
89
 
90
+ if updated != original:
91
+ path.write_text(updated, encoding="utf-8")
92
+ return f"patched OK: {path} | init={init_status} | meta={meta_status}"
93
+ return f"no changes: {path} | init={init_status} | meta={meta_status}"
94
 
95
 
96
  if __name__ == "__main__":
97
+ files = find_timesfm_torch_files()
98
  if not files:
99
  print("[patch_timesfm] No timesfm installation found in sys.path.")
100
  sys.exit(1)
101
 
102
+ for file_path in files:
103
+ print(f"[patch_timesfm] {patch_file(file_path)}")
 
104
 
105
  sys.exit(0)