Spaces:
Sleeping
Sleeping
Thang6822 commited on
Commit ·
9734b71
1
Parent(s): 5981eff
Update branding to SuperAI Forecast
Browse files- README.md +58 -53
- _ai_refactor.py +0 -166
- app.py +3 -35
- backend/forecasting/AI_MODEL_RULES.md +79 -0
- backend/forecasting/ARCHITECTURE.md +90 -0
- backend/forecasting/__init__.py +22 -0
- backend/forecasting/config.py +65 -0
- backend/forecasting/providers/__init__.py +19 -0
- backend/forecasting/providers/chronos_provider.py +328 -0
- backend/forecasting/providers/timesfm_provider.py +351 -0
- backend/forecasting/rules.py +45 -0
- backend/frontend_assets.py +204 -0
- backend/kronos_adapter.py +349 -0
- backend/kronos_core/__init__.py +1 -0
- backend/kronos_core/model/kronos.py +151 -12
- backend/launcher.py +79 -40
- backend/main.py +0 -0
- backend/runtime_utils.py +17 -0
- backend/server_runtime.py +97 -0
- backend/startup_utils.py +25 -10
- backend/test_api_regressions.py +380 -68
- backend/test_frontend_assets.py +106 -0
- backend/test_frontend_static_contract.py +68 -0
- backend/test_kronos_meta_compat.py +39 -0
- backend/test_launcher.py +47 -0
- backend/test_runtime_utils.py +29 -4
- backend/test_server_runtime.py +107 -0
- backend/test_startup_utils.py +7 -7
- backend/test_timesfm_provider.py +88 -0
- frontend/app.js +0 -0
- frontend/forecast-models.js +82 -0
- frontend/index.html +0 -0
- frontend/vendor/lightweight-charts.standalone.production.js +0 -0
- frontend/workspace.css +43 -1
- frontend/workspace.js +220 -93
- requirements.txt +4 -0
- run.bat +19 -8
- scripts/patch_timesfm.py +79 -30
README.md
CHANGED
|
@@ -1,59 +1,38 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
-
#
|
| 11 |
|
| 12 |
-
|
| 13 |
|
| 14 |
-
|
| 15 |
-
- calculating technical indicators
|
| 16 |
-
- generating AI forecasts with Kronos
|
| 17 |
-
- serving the web UI directly from the backend
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
|
| 25 |
-
## Project Layout
|
| 26 |
|
| 27 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
|
| 46 |
```bat
|
| 47 |
-
|
| 48 |
```
|
| 49 |
|
| 50 |
-
The
|
| 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 |
-
##
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
-
|
| 71 |
-
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
-
|
| 94 |
- the frontend loads from the same backend origin
|
| 95 |
|
| 96 |
## Troubleshooting
|
| 97 |
|
| 98 |
-
-
|
| 99 |
-
-
|
| 100 |
-
- If forecasts fail, check `/api/health` and confirm
|
| 101 |
-
- If market data fails,
|
|
|
|
| 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 |
-
|
| 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=
|
|
|
|
| 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 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 508 |
-
self.model = self.model
|
| 509 |
-
|
| 510 |
-
def generate(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 517 |
-
|
| 518 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 39 |
-
print(" [*] Dang
|
| 40 |
print(" [*] Trinh duyet se tu dong mo khi server san sang.")
|
| 41 |
print()
|
| 42 |
|
| 43 |
|
| 44 |
-
def
|
| 45 |
-
"""
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
server_socket.listen(1)
|
| 58 |
-
return int(server_socket.getsockname()[1])
|
| 59 |
|
| 60 |
|
| 61 |
def resolve_server_port() -> int:
|
| 62 |
-
"""Use PORT when
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 91 |
-
threading.Thread(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
try:
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
except Exception as exc:
|
| 96 |
logger.exception("Loi khoi dong he thong: %s", exc)
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 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 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
try:
|
| 89 |
await forecaster._lazy_load()
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
logger.info("[startup]
|
| 93 |
except Exception as ex:
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
logger.warning("[startup]
|
| 98 |
finally:
|
| 99 |
-
|
|
|
|
| 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
|
| 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.
|
| 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
|
| 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.
|
| 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
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
[
|
| 500 |
-
[
|
| 501 |
-
|
| 502 |
-
|
|
|
|
| 503 |
],
|
| 504 |
dtype=main.np.float32,
|
| 505 |
)
|
| 506 |
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
|
|
|
| 511 |
|
| 512 |
-
self.assertTrue(main.np.allclose(
|
| 513 |
-
self.assertTrue(main.np.allclose(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 520 |
-
"ensemble": {"mode": "
|
| 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
|
| 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 |
-
|
| 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=
|
| 50 |
is_frozen=False,
|
| 51 |
bundle_dir=r"D:\Bundle",
|
| 52 |
)
|
| 53 |
-
self.assertEqual(paths.current_dir,
|
| 54 |
-
self.assertEqual(paths.project_root,
|
| 55 |
|
| 56 |
def test_resolve_runtime_paths_for_frozen_mode(self) -> None:
|
| 57 |
paths = resolve_runtime_paths(
|
| 58 |
-
module_file=
|
| 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 |
-
|
| 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
|
| 96 |
logger = _FakeLogger()
|
| 97 |
state = {"warming": False, "last_error": "old", "loaded": False, "device": "not_loaded"}
|
| 98 |
forecaster = _FakeForecaster()
|
| 99 |
|
| 100 |
-
await
|
| 101 |
forecaster=forecaster,
|
| 102 |
-
|
| 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
|
| 112 |
logger = _FakeLogger()
|
| 113 |
state = {"warming": False, "last_error": None, "loaded": True, "device": "not_loaded"}
|
| 114 |
forecaster = _FakeForecaster(should_fail=True)
|
| 115 |
|
| 116 |
-
await
|
| 117 |
forecaster=forecaster,
|
| 118 |
-
|
| 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 |
-
|
| 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 |
-
*
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 108 |
-
window.clearPaneForecastCandlesOnly
|
| 109 |
-
|
| 110 |
-
this.forecastSeries.candles.setData
|
| 111 |
-
|
| 112 |
-
this.forecastSeries.candles.applyOptions
|
|
|
|
|
|
|
| 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 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
return null;
|
| 136 |
}
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
}
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 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 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
if (
|
| 181 |
-
|
| 182 |
}
|
| 183 |
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
-
if (
|
| 194 |
-
|
| 195 |
-
this.
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 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
|
| 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
|
| 312 |
-
|
|
|
|
|
|
|
| 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:
|
| 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
|
| 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
|
| 3 |
COLOR 0B
|
| 4 |
chcp 65001 >nul 2>&1
|
| 5 |
|
| 6 |
echo.
|
| 7 |
-
echo
|
| 8 |
-
echo
|
| 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
|
| 26 |
-
echo [2/4] Checking
|
| 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]
|
| 66 |
-
echo [INFO]
|
| 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 |
-
|
| 3 |
-
that huggingface_hub >=0.30 passes but TimesFM.__init__ doesn't accept.
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
-
|
|
|
|
|
|
|
| 8 |
import sys
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
#
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
-
def
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
candidates.append(p)
|
| 29 |
return candidates
|
| 30 |
|
| 31 |
|
| 32 |
-
def
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
if MARKER in text:
|
| 36 |
-
return f"already patched: {path}"
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
if __name__ == "__main__":
|
| 47 |
-
files =
|
| 48 |
if not files:
|
| 49 |
print("[patch_timesfm] No timesfm installation found in sys.path.")
|
| 50 |
sys.exit(1)
|
| 51 |
|
| 52 |
-
for
|
| 53 |
-
|
| 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)
|