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