Spaces:
Running
Running
Sync from GitHub (tests passed)
Browse files- adapters/db/lock.py +8 -3
- app/accel.py +4 -1
- app/data_manager.py +101 -20
- app/lock.py +5 -3
- app/main.py +72 -185
- deep_learning/data/futures_curve.py +14 -1
- deep_learning/inference/predictor.py +6 -2
- deep_learning/training/hyperopt.py +24 -7
- deep_learning/training/trainer.py +46 -28
- rust_accel/src/lib.rs +79 -0
- worker/tasks.py +2 -0
adapters/db/lock.py
CHANGED
|
@@ -95,6 +95,11 @@ def is_lock_held(session: Session, lock_key: str) -> bool:
|
|
| 95 |
|
| 96 |
Non-intrusively queries PostgreSQL's pg_locks view without acquiring
|
| 97 |
or releasing locks.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
"""
|
| 99 |
lock_id = _lock_key_to_id(lock_key)
|
| 100 |
classid = (lock_id >> 32) & 0xFFFFFFFF
|
|
@@ -107,8 +112,8 @@ def is_lock_held(session: Session, lock_key: str) -> bool:
|
|
| 107 |
SELECT 1 FROM pg_locks
|
| 108 |
WHERE locktype = 'advisory'
|
| 109 |
AND objsubid = 1
|
| 110 |
-
AND classid
|
| 111 |
-
AND objid
|
| 112 |
AND granted = true
|
| 113 |
LIMIT 1
|
| 114 |
"""
|
|
@@ -121,7 +126,7 @@ def is_lock_held(session: Session, lock_key: str) -> bool:
|
|
| 121 |
"pg_locks check failed for %s (id=%s): %s",
|
| 122 |
lock_key, lock_id, exc,
|
| 123 |
)
|
| 124 |
-
|
| 125 |
|
| 126 |
|
| 127 |
@contextmanager
|
|
|
|
| 95 |
|
| 96 |
Non-intrusively queries PostgreSQL's pg_locks view without acquiring
|
| 97 |
or releasing locks.
|
| 98 |
+
|
| 99 |
+
Raises:
|
| 100 |
+
Exception: If the pg_locks query fails (e.g. transient DB timeout or
|
| 101 |
+
connectivity failure). Callers must never interpret an inconclusive
|
| 102 |
+
error as an unheld lock.
|
| 103 |
"""
|
| 104 |
lock_id = _lock_key_to_id(lock_key)
|
| 105 |
classid = (lock_id >> 32) & 0xFFFFFFFF
|
|
|
|
| 112 |
SELECT 1 FROM pg_locks
|
| 113 |
WHERE locktype = 'advisory'
|
| 114 |
AND objsubid = 1
|
| 115 |
+
AND CAST(classid AS bigint) = CAST(:classid AS bigint)
|
| 116 |
+
AND CAST(objid AS bigint) = CAST(:objid AS bigint)
|
| 117 |
AND granted = true
|
| 118 |
LIMIT 1
|
| 119 |
"""
|
|
|
|
| 126 |
"pg_locks check failed for %s (id=%s): %s",
|
| 127 |
lock_key, lock_id, exc,
|
| 128 |
)
|
| 129 |
+
raise
|
| 130 |
|
| 131 |
|
| 132 |
@contextmanager
|
app/accel.py
CHANGED
|
@@ -256,7 +256,10 @@ def fast_strip_html(text: str) -> str:
|
|
| 256 |
soup = BeautifulSoup(text, "html.parser")
|
| 257 |
return soup.get_text(separator=" ", strip=True)
|
| 258 |
if HAS_RUST_EXT:
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
| 260 |
stripped = _HTML_TAG_RE.sub(" ", text)
|
| 261 |
if "&" in stripped:
|
| 262 |
stripped = html.unescape(stripped)
|
|
|
|
| 256 |
soup = BeautifulSoup(text, "html.parser")
|
| 257 |
return soup.get_text(separator=" ", strip=True)
|
| 258 |
if HAS_RUST_EXT:
|
| 259 |
+
cleaned = _rust_ext.clean_text_rs(text)
|
| 260 |
+
if "&" in cleaned:
|
| 261 |
+
cleaned = html.unescape(cleaned)
|
| 262 |
+
return cleaned
|
| 263 |
stripped = _HTML_TAG_RE.sub(" ", text)
|
| 264 |
if "&" in stripped:
|
| 265 |
stripped = html.unescape(stripped)
|
app/data_manager.py
CHANGED
|
@@ -20,6 +20,7 @@ from datetime import datetime, timedelta, timezone
|
|
| 20 |
from typing import Optional
|
| 21 |
|
| 22 |
import requests
|
|
|
|
| 23 |
import yfinance as yf
|
| 24 |
from rapidfuzz import fuzz, process as rf_process
|
| 25 |
from langdetect import detect, LangDetectException
|
|
@@ -445,7 +446,7 @@ def ingest_news(session: Session) -> dict:
|
|
| 445 |
# Price Ingestion
|
| 446 |
# =============================================================================
|
| 447 |
|
| 448 |
-
def fetch_symbol_with_retry(symbol: str, start_date, end_date, max_retries: int = 3, retry_delay:
|
| 449 |
"""
|
| 450 |
Fetch price data for a symbol with retry on rate limit.
|
| 451 |
|
|
@@ -454,7 +455,7 @@ def fetch_symbol_with_retry(symbol: str, start_date, end_date, max_retries: int
|
|
| 454 |
start_date: Start date
|
| 455 |
end_date: End date
|
| 456 |
max_retries: Maximum retry attempts
|
| 457 |
-
retry_delay:
|
| 458 |
|
| 459 |
Returns:
|
| 460 |
DataFrame or None if all retries failed
|
|
@@ -474,8 +475,9 @@ def fetch_symbol_with_retry(symbol: str, start_date, end_date, max_retries: int
|
|
| 474 |
error_msg = str(e).lower()
|
| 475 |
if "rate limit" in error_msg or "too many requests" in error_msg:
|
| 476 |
if attempt < max_retries - 1:
|
| 477 |
-
|
| 478 |
-
|
|
|
|
| 479 |
else:
|
| 480 |
logger.error(f"{symbol}: Rate limit exceeded after {max_retries} retries")
|
| 481 |
raise
|
|
@@ -484,6 +486,48 @@ def fetch_symbol_with_retry(symbol: str, start_date, end_date, max_retries: int
|
|
| 484 |
return None
|
| 485 |
|
| 486 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
def ingest_prices(session: Session) -> dict:
|
| 488 |
"""
|
| 489 |
Ingest price data for all configured symbols.
|
|
@@ -491,6 +535,7 @@ def ingest_prices(session: Session) -> dict:
|
|
| 491 |
Uses INCREMENTAL fetching: checks latest bar date per symbol in DB
|
| 492 |
and only fetches from that point forward (plus 3-day overlap for corrections).
|
| 493 |
Falls back to full lookback if no existing data found for a symbol.
|
|
|
|
| 494 |
|
| 495 |
Returns:
|
| 496 |
Dict with stats per symbol
|
|
@@ -517,8 +562,6 @@ def ingest_prices(session: Session) -> dict:
|
|
| 517 |
from app.price_utils import finite_number, finite_positive_price, latest_finite_price_dates, latest_finite_price_bar
|
| 518 |
latest_dates = latest_finite_price_dates(session, symbols)
|
| 519 |
|
| 520 |
-
from concurrent.futures import ThreadPoolExecutor
|
| 521 |
-
|
| 522 |
symbol_params = []
|
| 523 |
for symbol in symbols:
|
| 524 |
# Check latest bar in DB for incremental fetch (batch map with single-query fallback)
|
|
@@ -540,21 +583,59 @@ def ingest_prices(session: Session) -> dict:
|
|
| 540 |
mode = "full"
|
| 541 |
symbol_params.append((symbol, start_date, end_date, mode))
|
| 542 |
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
fetched_results = list(executor.map(_fetch_single, symbol_params))
|
| 556 |
else:
|
| 557 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 558 |
|
| 559 |
for symbol, df, mode, error in fetched_results:
|
| 560 |
try:
|
|
|
|
| 20 |
from typing import Optional
|
| 21 |
|
| 22 |
import requests
|
| 23 |
+
import pandas as pd
|
| 24 |
import yfinance as yf
|
| 25 |
from rapidfuzz import fuzz, process as rf_process
|
| 26 |
from langdetect import detect, LangDetectException
|
|
|
|
| 446 |
# Price Ingestion
|
| 447 |
# =============================================================================
|
| 448 |
|
| 449 |
+
def fetch_symbol_with_retry(symbol: str, start_date, end_date, max_retries: int = 3, retry_delay: float = 2.0):
|
| 450 |
"""
|
| 451 |
Fetch price data for a symbol with retry on rate limit.
|
| 452 |
|
|
|
|
| 455 |
start_date: Start date
|
| 456 |
end_date: End date
|
| 457 |
max_retries: Maximum retry attempts
|
| 458 |
+
retry_delay: Base seconds to wait between retries (uses exponential backoff capped at 8s)
|
| 459 |
|
| 460 |
Returns:
|
| 461 |
DataFrame or None if all retries failed
|
|
|
|
| 475 |
error_msg = str(e).lower()
|
| 476 |
if "rate limit" in error_msg or "too many requests" in error_msg:
|
| 477 |
if attempt < max_retries - 1:
|
| 478 |
+
sleep_time = min(float(retry_delay) * (2 ** attempt), 8.0)
|
| 479 |
+
logger.warning(f"{symbol}: Rate limited, waiting {sleep_time:.1f}s before retry {attempt + 2}/{max_retries}")
|
| 480 |
+
time.sleep(sleep_time)
|
| 481 |
else:
|
| 482 |
logger.error(f"{symbol}: Rate limit exceeded after {max_retries} retries")
|
| 483 |
raise
|
|
|
|
| 486 |
return None
|
| 487 |
|
| 488 |
|
| 489 |
+
_DEFAULT_FETCH_SYMBOL_WITH_RETRY = fetch_symbol_with_retry
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def _extract_ticker_slice_from_batch(batch_df: pd.DataFrame, symbol: str) -> Optional[pd.DataFrame]:
|
| 493 |
+
"""Safely extract single ticker dataframe from yf.download multi-ticker result."""
|
| 494 |
+
if batch_df is None or batch_df.empty:
|
| 495 |
+
return None
|
| 496 |
+
try:
|
| 497 |
+
sub = None
|
| 498 |
+
if isinstance(batch_df.columns, pd.MultiIndex):
|
| 499 |
+
if symbol in batch_df.columns.get_level_values(0):
|
| 500 |
+
sub = batch_df[symbol]
|
| 501 |
+
elif symbol in batch_df.columns.get_level_values(1):
|
| 502 |
+
sub = batch_df.xs(symbol, axis=1, level=1)
|
| 503 |
+
elif symbol in batch_df.columns:
|
| 504 |
+
sub = batch_df[[symbol]]
|
| 505 |
+
else:
|
| 506 |
+
sub = batch_df
|
| 507 |
+
|
| 508 |
+
if sub is None or sub.empty:
|
| 509 |
+
return None
|
| 510 |
+
|
| 511 |
+
# Verify close column is present and has at least one valid finite number;
|
| 512 |
+
# otherwise return None to trigger single-symbol fetch fallback.
|
| 513 |
+
close_col = None
|
| 514 |
+
for col in ["Close", "close"]:
|
| 515 |
+
if col in sub.columns:
|
| 516 |
+
close_col = col
|
| 517 |
+
break
|
| 518 |
+
if close_col is not None:
|
| 519 |
+
valid_close = pd.to_numeric(sub[close_col], errors="coerce").dropna()
|
| 520 |
+
if valid_close.empty:
|
| 521 |
+
return None
|
| 522 |
+
else:
|
| 523 |
+
return None
|
| 524 |
+
|
| 525 |
+
sub = sub.dropna(how="all")
|
| 526 |
+
return sub if not sub.empty else None
|
| 527 |
+
except Exception:
|
| 528 |
+
return None
|
| 529 |
+
|
| 530 |
+
|
| 531 |
def ingest_prices(session: Session) -> dict:
|
| 532 |
"""
|
| 533 |
Ingest price data for all configured symbols.
|
|
|
|
| 535 |
Uses INCREMENTAL fetching: checks latest bar date per symbol in DB
|
| 536 |
and only fetches from that point forward (plus 3-day overlap for corrections).
|
| 537 |
Falls back to full lookback if no existing data found for a symbol.
|
| 538 |
+
Uses batch downloading (yf.download) to minimize round-trips.
|
| 539 |
|
| 540 |
Returns:
|
| 541 |
Dict with stats per symbol
|
|
|
|
| 562 |
from app.price_utils import finite_number, finite_positive_price, latest_finite_price_dates, latest_finite_price_bar
|
| 563 |
latest_dates = latest_finite_price_dates(session, symbols)
|
| 564 |
|
|
|
|
|
|
|
| 565 |
symbol_params = []
|
| 566 |
for symbol in symbols:
|
| 567 |
# Check latest bar in DB for incremental fetch (batch map with single-query fallback)
|
|
|
|
| 583 |
mode = "full"
|
| 584 |
symbol_params.append((symbol, start_date, end_date, mode))
|
| 585 |
|
| 586 |
+
# Check if fetch_symbol_with_retry was monkeypatched by a test runner
|
| 587 |
+
is_custom_fetcher = fetch_symbol_with_retry is not _DEFAULT_FETCH_SYMBOL_WITH_RETRY
|
| 588 |
+
|
| 589 |
+
fetched_results = []
|
| 590 |
+
if is_custom_fetcher:
|
| 591 |
+
# Honor mocked/monkeypatched single fetcher directly
|
| 592 |
+
for sym, s_date, e_date, f_mode in symbol_params:
|
| 593 |
+
try:
|
| 594 |
+
df = fetch_symbol_with_retry(sym, s_date, e_date)
|
| 595 |
+
fetched_results.append((sym, df, f_mode, None))
|
| 596 |
+
except Exception as exc:
|
| 597 |
+
fetched_results.append((sym, None, f_mode, exc))
|
|
|
|
| 598 |
else:
|
| 599 |
+
# Group symbols by identical (start_str, end_str) to batch download with yf.download
|
| 600 |
+
from collections import defaultdict
|
| 601 |
+
date_groups = defaultdict(list)
|
| 602 |
+
for sym, s_date, e_date, f_mode in symbol_params:
|
| 603 |
+
s_str = s_date.strftime("%Y-%m-%d")
|
| 604 |
+
e_str = e_date.strftime("%Y-%m-%d")
|
| 605 |
+
date_groups[(s_str, e_str)].append((sym, s_date, e_date, f_mode))
|
| 606 |
+
|
| 607 |
+
for (s_str, e_str), group in date_groups.items():
|
| 608 |
+
group_symbols = [item[0] for item in group]
|
| 609 |
+
batch_data = None
|
| 610 |
+
if len(group_symbols) > 1:
|
| 611 |
+
try:
|
| 612 |
+
logger.info("Batch downloading prices for %d symbols (%s to %s)...", len(group_symbols), s_str, e_str)
|
| 613 |
+
batch_data = yf.download(
|
| 614 |
+
tickers=group_symbols,
|
| 615 |
+
start=s_str,
|
| 616 |
+
end=e_str,
|
| 617 |
+
interval="1d",
|
| 618 |
+
group_by="ticker",
|
| 619 |
+
threads=True,
|
| 620 |
+
progress=False,
|
| 621 |
+
auto_adjust=False,
|
| 622 |
+
)
|
| 623 |
+
except Exception as batch_exc:
|
| 624 |
+
logger.warning("Batch price download failed, falling back to per-symbol fetch: %s", batch_exc)
|
| 625 |
+
batch_data = None
|
| 626 |
+
|
| 627 |
+
for sym, s_date, e_date, f_mode in group:
|
| 628 |
+
sym_df = _extract_ticker_slice_from_batch(batch_data, sym) if batch_data is not None else None
|
| 629 |
+
if sym_df is not None and not sym_df.empty:
|
| 630 |
+
fetched_results.append((sym, sym_df, f_mode, None))
|
| 631 |
+
else:
|
| 632 |
+
# Fallback to single-symbol fetcher with retry
|
| 633 |
+
try:
|
| 634 |
+
logger.info("Fetching prices for %s (%s)...", sym, f_mode)
|
| 635 |
+
df = fetch_symbol_with_retry(sym, s_date, e_date)
|
| 636 |
+
fetched_results.append((sym, df, f_mode, None))
|
| 637 |
+
except Exception as exc:
|
| 638 |
+
fetched_results.append((sym, None, f_mode, exc))
|
| 639 |
|
| 640 |
for symbol, df, mode, error in fetched_results:
|
| 641 |
try:
|
app/lock.py
CHANGED
|
@@ -107,10 +107,10 @@ class PipelineLock:
|
|
| 107 |
return False
|
| 108 |
|
| 109 |
|
| 110 |
-
def is_pipeline_locked() -> bool:
|
| 111 |
"""
|
| 112 |
Check if the pipeline is currently locked.
|
| 113 |
-
Non-blocking check.
|
| 114 |
"""
|
| 115 |
# The API and ARQ worker are separate processes in production, so a local
|
| 116 |
# file lock cannot describe the worker's PostgreSQL advisory lock. Use the
|
|
@@ -121,8 +121,10 @@ def is_pipeline_locked() -> bool:
|
|
| 121 |
from adapters.db.lock import PIPELINE_LOCK_KEY, is_lock_held
|
| 122 |
|
| 123 |
try:
|
| 124 |
-
|
| 125 |
return is_lock_held(session, PIPELINE_LOCK_KEY)
|
|
|
|
|
|
|
| 126 |
except Exception as exc:
|
| 127 |
# This is only a preflight visibility check. The worker still uses
|
| 128 |
# try_acquire_lock as the race-safe authority.
|
|
|
|
| 107 |
return False
|
| 108 |
|
| 109 |
|
| 110 |
+
def is_pipeline_locked(session: Optional[object] = None) -> bool:
|
| 111 |
"""
|
| 112 |
Check if the pipeline is currently locked.
|
| 113 |
+
Non-blocking check. Optionally reuses an existing SQLAlchemy session.
|
| 114 |
"""
|
| 115 |
# The API and ARQ worker are separate processes in production, so a local
|
| 116 |
# file lock cannot describe the worker's PostgreSQL advisory lock. Use the
|
|
|
|
| 121 |
from adapters.db.lock import PIPELINE_LOCK_KEY, is_lock_held
|
| 122 |
|
| 123 |
try:
|
| 124 |
+
if session is not None:
|
| 125 |
return is_lock_held(session, PIPELINE_LOCK_KEY)
|
| 126 |
+
with SessionLocal() as s:
|
| 127 |
+
return is_lock_held(s, PIPELINE_LOCK_KEY)
|
| 128 |
except Exception as exc:
|
| 129 |
# This is only a preflight visibility check. The worker still uses
|
| 130 |
# try_acquire_lock as the race-safe authority.
|
app/main.py
CHANGED
|
@@ -28,6 +28,7 @@ from fastapi.concurrency import run_in_threadpool
|
|
| 28 |
from fastapi.middleware.cors import CORSMiddleware
|
| 29 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 30 |
from sqlalchemy import func, desc
|
|
|
|
| 31 |
|
| 32 |
from app.db import init_db, SessionLocal, get_db_type
|
| 33 |
from app.models import NewsArticle, PriceBar, DailySentiment, DailySentimentV2, AnalysisSnapshot, NewsSentimentV2, NewsProcessed, NewsRaw, PipelineRunMetrics
|
|
@@ -394,6 +395,30 @@ async def get_history(
|
|
| 394 |
)
|
| 395 |
|
| 396 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
@app.get(
|
| 398 |
"/api/health",
|
| 399 |
response_model=HealthResponse,
|
|
@@ -419,6 +444,7 @@ async def health_check():
|
|
| 419 |
news_count = None
|
| 420 |
price_count = None
|
| 421 |
last_snapshot_age = None
|
|
|
|
| 422 |
|
| 423 |
# Freshness metadata for the System page. Each field answers a distinct
|
| 424 |
# question — see HealthResponse for the exact definitions.
|
|
@@ -440,8 +466,8 @@ async def health_check():
|
|
| 440 |
|
| 441 |
try:
|
| 442 |
with SessionLocal() as session:
|
| 443 |
-
news_count =
|
| 444 |
-
|
| 445 |
|
| 446 |
from app.models import (
|
| 447 |
AnalysisSnapshot,
|
|
@@ -551,7 +577,8 @@ async def health_check():
|
|
| 551 |
redis_ok = False
|
| 552 |
|
| 553 |
# Determine status
|
| 554 |
-
pipeline_locked
|
|
|
|
| 555 |
|
| 556 |
if models_found == 0:
|
| 557 |
status = "degraded"
|
|
@@ -1257,53 +1284,7 @@ async def get_commentary(
|
|
| 1257 |
|
| 1258 |
_tft_cache: dict = {}
|
| 1259 |
_TFT_CACHE_TTL_S = 300 # 5 minutes
|
| 1260 |
-
_TFT_CACHE_MAX_ENTRIES = 128 # hard cap; live keys are also pruned per symbol
|
| 1261 |
-
_tft_live_inflight: dict[str, asyncio.Task[dict]] = {}
|
| 1262 |
-
_tft_live_inflight_lock = asyncio.Lock()
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
def _tft_entry_age_seconds(entry: dict, now: datetime) -> float:
|
| 1266 |
-
"""Age of a TFT cache entry in seconds, tolerating naive/legacy timestamps."""
|
| 1267 |
-
ts = entry.get("ts")
|
| 1268 |
-
if isinstance(ts, datetime):
|
| 1269 |
-
if ts.tzinfo is None:
|
| 1270 |
-
ts = ts.replace(tzinfo=timezone.utc)
|
| 1271 |
-
return (now - ts).total_seconds()
|
| 1272 |
-
try:
|
| 1273 |
-
return now.timestamp() - float(ts)
|
| 1274 |
-
except (TypeError, ValueError):
|
| 1275 |
-
return float("inf")
|
| 1276 |
-
|
| 1277 |
-
|
| 1278 |
-
def _tft_entry_sort_key(entry: dict) -> float:
|
| 1279 |
-
"""Oldest-first sort key; unparsable timestamps sort first (evicted first)."""
|
| 1280 |
-
ts = entry.get("ts")
|
| 1281 |
-
if isinstance(ts, datetime):
|
| 1282 |
-
return ts.timestamp()
|
| 1283 |
-
try:
|
| 1284 |
-
return float(ts)
|
| 1285 |
-
except (TypeError, ValueError):
|
| 1286 |
-
return 0.0
|
| 1287 |
-
|
| 1288 |
|
| 1289 |
-
def _remember_tft_entry(cache_key: str, data: dict) -> None:
|
| 1290 |
-
"""Store a TFT response and evict the oldest entries beyond the cap."""
|
| 1291 |
-
_tft_cache[cache_key] = {"data": data, "ts": datetime.now(timezone.utc)}
|
| 1292 |
-
overflow = len(_tft_cache) - _TFT_CACHE_MAX_ENTRIES
|
| 1293 |
-
if overflow > 0:
|
| 1294 |
-
for stale_key, _ in sorted(_tft_cache.items(), key=lambda item: _tft_entry_sort_key(item[1]))[:overflow]:
|
| 1295 |
-
_tft_cache.pop(stale_key, None)
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
def _forget_tft_live_task(cache_key: str, task: asyncio.Task[dict]) -> None:
|
| 1299 |
-
"""Drop a completed inference task and retrieve unobserved exceptions."""
|
| 1300 |
-
if _tft_live_inflight.get(cache_key) is task:
|
| 1301 |
-
_tft_live_inflight.pop(cache_key, None)
|
| 1302 |
-
if not task.cancelled():
|
| 1303 |
-
try:
|
| 1304 |
-
task.exception()
|
| 1305 |
-
except BaseException:
|
| 1306 |
-
pass
|
| 1307 |
|
| 1308 |
|
| 1309 |
def _latest_price_bar_date(session, symbol: str) -> Optional[date]:
|
|
@@ -1422,16 +1403,13 @@ def _tft_snapshot_invalid_reasons(
|
|
| 1422 |
summary="Get TFT-ASRO deep learning analysis",
|
| 1423 |
description=(
|
| 1424 |
"Returns probabilistic multi-quantile prediction from the Temporal "
|
| 1425 |
-
"Fusion Transformer model.
|
| 1426 |
-
"
|
| 1427 |
-
"PriceBar (persistent, cheap). A newer market bar automatically rolls "
|
| 1428 |
-
"the five-day forecast forward. Pass `source=live` to force a fresh "
|
| 1429 |
-
"inference run — useful for diagnostics."
|
| 1430 |
),
|
| 1431 |
responses={
|
| 1432 |
200: {"description": "TFT-ASRO analysis with quantile predictions"},
|
| 1433 |
-
|
| 1434 |
-
|
| 1435 |
},
|
| 1436 |
)
|
| 1437 |
async def get_tft_analysis(
|
|
@@ -1442,147 +1420,56 @@ async def get_tft_analysis(
|
|
| 1442 |
Get TFT-ASRO analysis for the given symbol.
|
| 1443 |
|
| 1444 |
`source` semantics:
|
| 1445 |
-
* `snapshot` (default) — serve the latest persisted TFTPredictionSnapshot
|
| 1446 |
-
|
| 1447 |
-
|
| 1448 |
-
forecast instead of truncating the previous forecast vintage.
|
| 1449 |
-
* `live` — always run a fresh inference. In-memory cached
|
| 1450 |
-
per PriceBar date for 5 minutes to protect the worker against the 60s
|
| 1451 |
-
polling loop.
|
| 1452 |
"""
|
| 1453 |
source = (source or "snapshot").strip().lower()
|
| 1454 |
-
if source
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1455 |
raise HTTPException(
|
| 1456 |
status_code=400,
|
| 1457 |
detail="source must be one of: snapshot, live",
|
| 1458 |
)
|
| 1459 |
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
# --- 1. Try persisted snapshot ------------------------------------------
|
| 1463 |
-
if source == "snapshot":
|
| 1464 |
-
try:
|
| 1465 |
-
from app.models import TFTPredictionSnapshot
|
| 1466 |
-
|
| 1467 |
-
with SessionLocal() as session:
|
| 1468 |
-
latest_price_date = _latest_price_bar_date(session, symbol)
|
| 1469 |
-
latest = (
|
| 1470 |
-
session.query(TFTPredictionSnapshot)
|
| 1471 |
-
.filter(TFTPredictionSnapshot.symbol == symbol)
|
| 1472 |
-
.order_by(TFTPredictionSnapshot.generated_at.desc())
|
| 1473 |
-
.first()
|
| 1474 |
-
)
|
| 1475 |
-
if latest is not None and isinstance(latest.payload_json, dict):
|
| 1476 |
-
payload = dict(latest.payload_json)
|
| 1477 |
-
gen_at = _as_utc(latest.generated_at)
|
| 1478 |
-
fallback_reasons = _tft_snapshot_invalid_reasons(
|
| 1479 |
-
session, symbol, latest, latest_price_date=latest_price_date
|
| 1480 |
-
)
|
| 1481 |
-
|
| 1482 |
-
if not fallback_reasons:
|
| 1483 |
-
prediction = (
|
| 1484 |
-
payload.get("prediction")
|
| 1485 |
-
if isinstance(payload.get("prediction"), dict)
|
| 1486 |
-
else {}
|
| 1487 |
-
)
|
| 1488 |
-
payload.setdefault("primary_horizon", "5D")
|
| 1489 |
-
payload.setdefault("primary_forecast_return", prediction.get("weekly_return"))
|
| 1490 |
-
payload.setdefault("primary_forecast_q10", prediction.get("weekly_return_q10_calibrated"))
|
| 1491 |
-
payload.setdefault("primary_forecast_q90", prediction.get("weekly_return_q90_calibrated"))
|
| 1492 |
-
payload.setdefault("t1_return", prediction.get("predicted_return_median"))
|
| 1493 |
-
payload.setdefault("t1_impulse", payload.get("t1_impulse"))
|
| 1494 |
-
payload.setdefault("return_space", prediction.get("return_space"))
|
| 1495 |
-
payload["source"] = "snapshot"
|
| 1496 |
-
payload["snapshot_generated_at"] = (
|
| 1497 |
-
gen_at.isoformat() if gen_at else None
|
| 1498 |
-
)
|
| 1499 |
-
return payload
|
| 1500 |
|
| 1501 |
-
|
| 1502 |
-
|
| 1503 |
-
|
| 1504 |
-
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
|
| 1508 |
-
|
| 1509 |
-
|
|
|
|
|
|
|
| 1510 |
)
|
| 1511 |
-
# No snapshot yet — silently fall through to live inference so the
|
| 1512 |
-
# UI can still show something on first deployment.
|
| 1513 |
-
|
| 1514 |
-
# --- 2. Live inference (explicit request or snapshot miss) --------------
|
| 1515 |
-
now = datetime.now(timezone.utc)
|
| 1516 |
-
if latest_price_date is None:
|
| 1517 |
-
try:
|
| 1518 |
-
with SessionLocal() as session:
|
| 1519 |
-
latest_price_date = _latest_price_bar_date(session, symbol)
|
| 1520 |
-
except Exception as exc:
|
| 1521 |
-
logger.warning("Latest PriceBar date lookup failed for %s: %s", symbol, exc)
|
| 1522 |
-
|
| 1523 |
-
price_vintage = latest_price_date.isoformat() if latest_price_date else "unknown"
|
| 1524 |
-
cache_key = f"{symbol}:live:{price_vintage}"
|
| 1525 |
-
cache_prefix = f"{symbol}:live:"
|
| 1526 |
-
for stale_key in tuple(_tft_cache):
|
| 1527 |
-
if stale_key.startswith(cache_prefix) and stale_key != cache_key:
|
| 1528 |
-
_tft_cache.pop(stale_key, None)
|
| 1529 |
-
cached = _tft_cache.get(cache_key)
|
| 1530 |
-
if cached and _tft_entry_age_seconds(cached, now) < _TFT_CACHE_TTL_S:
|
| 1531 |
-
return cached["data"]
|
| 1532 |
-
|
| 1533 |
-
def _generate_live_tft_sync() -> dict:
|
| 1534 |
-
from deep_learning.inference.predictor import generate_tft_analysis
|
| 1535 |
-
|
| 1536 |
-
with SessionLocal() as session:
|
| 1537 |
-
generated = generate_tft_analysis(session, symbol)
|
| 1538 |
-
|
| 1539 |
-
if "error" in generated:
|
| 1540 |
-
raise HTTPException(status_code=500, detail=generated["error"])
|
| 1541 |
-
|
| 1542 |
-
generated = dict(generated)
|
| 1543 |
-
generated["source"] = "live"
|
| 1544 |
-
return generated
|
| 1545 |
-
|
| 1546 |
-
async def _generate_and_cache_live_tft() -> dict:
|
| 1547 |
-
generated = await run_in_threadpool(_generate_live_tft_sync)
|
| 1548 |
-
_remember_tft_entry(cache_key, generated)
|
| 1549 |
-
return generated
|
| 1550 |
-
|
| 1551 |
-
async def _get_or_start_live_tft() -> dict:
|
| 1552 |
-
async with _tft_live_inflight_lock:
|
| 1553 |
-
recheck = _tft_cache.get(cache_key)
|
| 1554 |
-
if recheck and _tft_entry_age_seconds(recheck, datetime.now(timezone.utc)) < _TFT_CACHE_TTL_S:
|
| 1555 |
-
return recheck["data"]
|
| 1556 |
-
|
| 1557 |
-
task = _tft_live_inflight.get(cache_key)
|
| 1558 |
-
if task is None:
|
| 1559 |
-
task = asyncio.create_task(_generate_and_cache_live_tft())
|
| 1560 |
-
_tft_live_inflight[cache_key] = task
|
| 1561 |
-
task.add_done_callback(
|
| 1562 |
-
lambda completed: _forget_tft_live_task(cache_key, completed)
|
| 1563 |
-
)
|
| 1564 |
|
| 1565 |
-
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
|
| 1571 |
-
except FileNotFoundError:
|
| 1572 |
-
raise HTTPException(
|
| 1573 |
-
status_code=404,
|
| 1574 |
-
detail="TFT-ASRO model not trained yet. Run training pipeline first.",
|
| 1575 |
)
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1580 |
)
|
| 1581 |
-
|
| 1582 |
-
raise
|
| 1583 |
-
except Exception as exc:
|
| 1584 |
-
logger.error("TFT analysis failed: %s", exc, exc_info=True)
|
| 1585 |
-
raise HTTPException(status_code=500, detail=str(exc))
|
| 1586 |
|
| 1587 |
|
| 1588 |
@app.get(
|
|
|
|
| 28 |
from fastapi.middleware.cors import CORSMiddleware
|
| 29 |
from fastapi.middleware.gzip import GZipMiddleware
|
| 30 |
from sqlalchemy import func, desc
|
| 31 |
+
from sqlalchemy.orm import Session
|
| 32 |
|
| 33 |
from app.db import init_db, SessionLocal, get_db_type
|
| 34 |
from app.models import NewsArticle, PriceBar, DailySentiment, DailySentimentV2, AnalysisSnapshot, NewsSentimentV2, NewsProcessed, NewsRaw, PipelineRunMetrics
|
|
|
|
| 395 |
)
|
| 396 |
|
| 397 |
|
| 398 |
+
_HEALTH_COUNTS_CACHE: dict[str, Any] = {"ts": 0.0, "news_count": None, "price_count": None}
|
| 399 |
+
_HEALTH_COUNTS_TTL_S = 30.0
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def _get_health_counts(session: Session) -> tuple[Optional[int], Optional[int]]:
|
| 403 |
+
now = time.monotonic()
|
| 404 |
+
if (
|
| 405 |
+
now - _HEALTH_COUNTS_CACHE["ts"] < _HEALTH_COUNTS_TTL_S
|
| 406 |
+
and _HEALTH_COUNTS_CACHE["news_count"] is not None
|
| 407 |
+
):
|
| 408 |
+
return _HEALTH_COUNTS_CACHE["news_count"], _HEALTH_COUNTS_CACHE["price_count"]
|
| 409 |
+
|
| 410 |
+
try:
|
| 411 |
+
news_count = session.query(func.count(NewsArticle.id)).scalar()
|
| 412 |
+
price_count = session.query(func.count(PriceBar.id)).scalar()
|
| 413 |
+
_HEALTH_COUNTS_CACHE["ts"] = now
|
| 414 |
+
_HEALTH_COUNTS_CACHE["news_count"] = news_count
|
| 415 |
+
_HEALTH_COUNTS_CACHE["price_count"] = price_count
|
| 416 |
+
return news_count, price_count
|
| 417 |
+
except Exception as exc:
|
| 418 |
+
logger.warning("Failed to query table counts for /api/health: %s", exc)
|
| 419 |
+
return _HEALTH_COUNTS_CACHE["news_count"], _HEALTH_COUNTS_CACHE["price_count"]
|
| 420 |
+
|
| 421 |
+
|
| 422 |
@app.get(
|
| 423 |
"/api/health",
|
| 424 |
response_model=HealthResponse,
|
|
|
|
| 444 |
news_count = None
|
| 445 |
price_count = None
|
| 446 |
last_snapshot_age = None
|
| 447 |
+
pipeline_locked = None
|
| 448 |
|
| 449 |
# Freshness metadata for the System page. Each field answers a distinct
|
| 450 |
# question — see HealthResponse for the exact definitions.
|
|
|
|
| 466 |
|
| 467 |
try:
|
| 468 |
with SessionLocal() as session:
|
| 469 |
+
news_count, price_count = _get_health_counts(session)
|
| 470 |
+
pipeline_locked = is_pipeline_locked(session)
|
| 471 |
|
| 472 |
from app.models import (
|
| 473 |
AnalysisSnapshot,
|
|
|
|
| 577 |
redis_ok = False
|
| 578 |
|
| 579 |
# Determine status
|
| 580 |
+
if pipeline_locked is None:
|
| 581 |
+
pipeline_locked = is_pipeline_locked()
|
| 582 |
|
| 583 |
if models_found == 0:
|
| 584 |
status = "degraded"
|
|
|
|
| 1284 |
|
| 1285 |
_tft_cache: dict = {}
|
| 1286 |
_TFT_CACHE_TTL_S = 300 # 5 minutes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1288 |
|
| 1289 |
|
| 1290 |
def _latest_price_bar_date(session, symbol: str) -> Optional[date]:
|
|
|
|
| 1403 |
summary="Get TFT-ASRO deep learning analysis",
|
| 1404 |
description=(
|
| 1405 |
"Returns probabilistic multi-quantile prediction from the Temporal "
|
| 1406 |
+
"Fusion Transformer model. Serves the latest TFT snapshot produced by "
|
| 1407 |
+
"the daily pipeline worker. Snapshot-only: never runs inline live inference."
|
|
|
|
|
|
|
|
|
|
| 1408 |
),
|
| 1409 |
responses={
|
| 1410 |
200: {"description": "TFT-ASRO analysis with quantile predictions"},
|
| 1411 |
+
400: {"description": "Live inference request rejected"},
|
| 1412 |
+
404: {"description": "TFT snapshot not available"},
|
| 1413 |
},
|
| 1414 |
)
|
| 1415 |
async def get_tft_analysis(
|
|
|
|
| 1420 |
Get TFT-ASRO analysis for the given symbol.
|
| 1421 |
|
| 1422 |
`source` semantics:
|
| 1423 |
+
* `snapshot` (default) — serve the latest persisted TFTPredictionSnapshot.
|
| 1424 |
+
* `live` — rejected with 400 (heavy PyTorch inference belongs
|
| 1425 |
+
in background pipeline workers, not inline HTTP request handlers).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1426 |
"""
|
| 1427 |
source = (source or "snapshot").strip().lower()
|
| 1428 |
+
if source == "live":
|
| 1429 |
+
raise HTTPException(
|
| 1430 |
+
status_code=400,
|
| 1431 |
+
detail="Live TFT inference on HTTP handlers is prohibited by architecture contract. Use the pipeline worker.",
|
| 1432 |
+
)
|
| 1433 |
+
if source != "snapshot":
|
| 1434 |
raise HTTPException(
|
| 1435 |
status_code=400,
|
| 1436 |
detail="source must be one of: snapshot, live",
|
| 1437 |
)
|
| 1438 |
|
| 1439 |
+
from app.models import TFTPredictionSnapshot
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1440 |
|
| 1441 |
+
with SessionLocal() as session:
|
| 1442 |
+
latest = (
|
| 1443 |
+
session.query(TFTPredictionSnapshot)
|
| 1444 |
+
.filter(TFTPredictionSnapshot.symbol == symbol)
|
| 1445 |
+
.order_by(TFTPredictionSnapshot.generated_at.desc())
|
| 1446 |
+
.first()
|
| 1447 |
+
)
|
| 1448 |
+
if latest is None or not isinstance(latest.payload_json, dict):
|
| 1449 |
+
raise HTTPException(
|
| 1450 |
+
status_code=404,
|
| 1451 |
+
detail=f"No TFT prediction snapshot found for {symbol}. Run the pipeline worker to generate a forecast.",
|
| 1452 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1453 |
|
| 1454 |
+
payload = dict(latest.payload_json)
|
| 1455 |
+
gen_at = _as_utc(latest.generated_at)
|
| 1456 |
+
prediction = (
|
| 1457 |
+
payload.get("prediction")
|
| 1458 |
+
if isinstance(payload.get("prediction"), dict)
|
| 1459 |
+
else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1460 |
)
|
| 1461 |
+
payload.setdefault("primary_horizon", "5D")
|
| 1462 |
+
payload.setdefault("primary_forecast_return", prediction.get("weekly_return"))
|
| 1463 |
+
payload.setdefault("primary_forecast_q10", prediction.get("weekly_return_q10_calibrated"))
|
| 1464 |
+
payload.setdefault("primary_forecast_q90", prediction.get("weekly_return_q90_calibrated"))
|
| 1465 |
+
payload.setdefault("t1_return", prediction.get("predicted_return_median"))
|
| 1466 |
+
payload.setdefault("t1_impulse", payload.get("t1_impulse"))
|
| 1467 |
+
payload.setdefault("return_space", prediction.get("return_space"))
|
| 1468 |
+
payload["source"] = "snapshot"
|
| 1469 |
+
payload["snapshot_generated_at"] = (
|
| 1470 |
+
gen_at.isoformat() if gen_at else None
|
| 1471 |
)
|
| 1472 |
+
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1473 |
|
| 1474 |
|
| 1475 |
@app.get(
|
deep_learning/data/futures_curve.py
CHANGED
|
@@ -132,6 +132,19 @@ def build_futures_features_from_yfinance(
|
|
| 132 |
zscore_std = features["futures_spread_pct"].rolling(60, min_periods=10).std().replace(0, np.nan)
|
| 133 |
features["futures_spread_zscore"] = (features["futures_spread_pct"] - zscore_mean) / zscore_std
|
| 134 |
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
return features
|
|
|
|
| 132 |
zscore_std = features["futures_spread_pct"].rolling(60, min_periods=10).std().replace(0, np.nan)
|
| 133 |
features["futures_spread_zscore"] = (features["futures_spread_pct"] - zscore_mean) / zscore_std
|
| 134 |
|
| 135 |
+
# Utilize Rust-accelerated fast_ols_row_slope via compute_curve_slope across term-structure maturities
|
| 136 |
+
curve_df = compute_curve_slope({
|
| 137 |
+
"1_spot": close,
|
| 138 |
+
"2_5d": ma_5,
|
| 139 |
+
"3_20d": ma_20,
|
| 140 |
+
"4_60d": ma_60,
|
| 141 |
+
})
|
| 142 |
+
if not curve_df.empty and "futures_curve_slope" in curve_df.columns:
|
| 143 |
+
features["futures_curve_slope"] = curve_df["futures_curve_slope"]
|
| 144 |
+
features["futures_curve_slope_ma5"] = curve_df["futures_curve_slope_ma5"]
|
| 145 |
+
else:
|
| 146 |
+
fallback_slope = (spread_20_60 / ma_60.replace(0, np.nan)).rolling(5, min_periods=1).mean()
|
| 147 |
+
features["futures_curve_slope"] = fallback_slope
|
| 148 |
+
features["futures_curve_slope_ma5"] = fallback_slope
|
| 149 |
|
| 150 |
return features
|
deep_learning/inference/predictor.py
CHANGED
|
@@ -623,8 +623,9 @@ class TFTPredictor:
|
|
| 623 |
# baseline unambiguously ("Futures HG=F, close of 2026-04-16").
|
| 624 |
result["instrument"] = self._describe_instrument(symbol)
|
| 625 |
result["baseline_staleness_days"] = staleness_days
|
|
|
|
| 626 |
result["lazy_ingest_triggered"] = False
|
| 627 |
-
result["is_forecast_healthy"] =
|
| 628 |
|
| 629 |
result["generated_at"] = datetime.now(timezone.utc).isoformat()
|
| 630 |
result["symbol"] = symbol
|
|
@@ -996,13 +997,16 @@ def generate_tft_analysis(
|
|
| 996 |
return obj
|
| 997 |
|
| 998 |
is_forecast_healthy = bool(prediction.get("is_forecast_healthy", True))
|
|
|
|
| 999 |
|
| 1000 |
raw_result = {
|
| 1001 |
"symbol": symbol,
|
| 1002 |
"model_type": "TFT-ASRO",
|
| 1003 |
-
"model_state": "ok" if is_forecast_healthy else "
|
| 1004 |
"quality_state": "ok" if is_forecast_healthy else "degraded",
|
| 1005 |
"is_forecast_healthy": is_forecast_healthy,
|
|
|
|
|
|
|
| 1006 |
"direction": direction,
|
| 1007 |
"weekly_trend": direction,
|
| 1008 |
"primary_horizon": "5D",
|
|
|
|
| 623 |
# baseline unambiguously ("Futures HG=F, close of 2026-04-16").
|
| 624 |
result["instrument"] = self._describe_instrument(symbol)
|
| 625 |
result["baseline_staleness_days"] = staleness_days
|
| 626 |
+
result["is_stale"] = is_stale
|
| 627 |
result["lazy_ingest_triggered"] = False
|
| 628 |
+
result["is_forecast_healthy"] = True
|
| 629 |
|
| 630 |
result["generated_at"] = datetime.now(timezone.utc).isoformat()
|
| 631 |
result["symbol"] = symbol
|
|
|
|
| 997 |
return obj
|
| 998 |
|
| 999 |
is_forecast_healthy = bool(prediction.get("is_forecast_healthy", True))
|
| 1000 |
+
is_stale = bool(prediction.get("is_stale", False))
|
| 1001 |
|
| 1002 |
raw_result = {
|
| 1003 |
"symbol": symbol,
|
| 1004 |
"model_type": "TFT-ASRO",
|
| 1005 |
+
"model_state": "ok" if is_forecast_healthy else "retrain_required",
|
| 1006 |
"quality_state": "ok" if is_forecast_healthy else "degraded",
|
| 1007 |
"is_forecast_healthy": is_forecast_healthy,
|
| 1008 |
+
"is_stale": is_stale,
|
| 1009 |
+
"baseline_staleness_days": prediction.get("baseline_staleness_days", 0),
|
| 1010 |
"direction": direction,
|
| 1011 |
"weekly_trend": direction,
|
| 1012 |
"primary_horizon": "5D",
|
deep_learning/training/hyperopt.py
CHANGED
|
@@ -746,7 +746,23 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
|
|
| 746 |
fold_weekly_interval_score_96 = 0.0
|
| 747 |
|
| 748 |
try:
|
| 749 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
if hasattr(pred_tensor, "cpu"):
|
| 751 |
pred_np = pred_tensor.cpu().numpy()
|
| 752 |
else:
|
|
@@ -768,12 +784,13 @@ def _objective(trial, base_cfg: TFTASROConfig, master_data: tuple) -> float:
|
|
| 768 |
ordered_pred_np = monotonic_quantiles_np(eval_pred_np, median_idx=median_idx)
|
| 769 |
raw_ordered_pred_np = monotonic_quantiles_np(pred_np, median_idx=median_idx)
|
| 770 |
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
|
|
|
| 777 |
n_path = min(len(y_actual_path), len(pred_np))
|
| 778 |
metrics = evaluate_quantile_predictions(
|
| 779 |
y_actual_path[:n_path],
|
|
|
|
| 746 |
fold_weekly_interval_score_96 = 0.0
|
| 747 |
|
| 748 |
try:
|
| 749 |
+
# Single-pass prediction + target extraction to avoid DataLoader double iteration
|
| 750 |
+
y_actual_path = None
|
| 751 |
+
try:
|
| 752 |
+
pred_out = model.predict(fold_val_dl, mode="quantiles", return_y=True)
|
| 753 |
+
if hasattr(pred_out, "output") and hasattr(pred_out, "y"):
|
| 754 |
+
pred_tensor = pred_out.output
|
| 755 |
+
y_val = pred_out.y[0] if isinstance(pred_out.y, (list, tuple)) else pred_out.y
|
| 756 |
+
y_actual_path = y_val.cpu().numpy() if hasattr(y_val, "cpu") else np.asarray(y_val)
|
| 757 |
+
elif isinstance(pred_out, (list, tuple)) and len(pred_out) == 2:
|
| 758 |
+
pred_tensor, y_val = pred_out
|
| 759 |
+
y_val = y_val[0] if isinstance(y_val, (list, tuple)) else y_val
|
| 760 |
+
y_actual_path = y_val.cpu().numpy() if hasattr(y_val, "cpu") else np.asarray(y_val)
|
| 761 |
+
else:
|
| 762 |
+
pred_tensor = pred_out
|
| 763 |
+
except TypeError:
|
| 764 |
+
pred_tensor = model.predict(fold_val_dl, mode="quantiles")
|
| 765 |
+
|
| 766 |
if hasattr(pred_tensor, "cpu"):
|
| 767 |
pred_np = pred_tensor.cpu().numpy()
|
| 768 |
else:
|
|
|
|
| 784 |
ordered_pred_np = monotonic_quantiles_np(eval_pred_np, median_idx=median_idx)
|
| 785 |
raw_ordered_pred_np = monotonic_quantiles_np(pred_np, median_idx=median_idx)
|
| 786 |
|
| 787 |
+
if y_actual_path is None:
|
| 788 |
+
y_actual_parts = []
|
| 789 |
+
for batch in fold_val_dl:
|
| 790 |
+
y_actual_parts.append(
|
| 791 |
+
batch[1][0] if isinstance(batch[1], (list, tuple)) else batch[1]
|
| 792 |
+
)
|
| 793 |
+
y_actual_path = torch.cat(y_actual_parts).cpu().numpy()
|
| 794 |
n_path = min(len(y_actual_path), len(pred_np))
|
| 795 |
metrics = evaluate_quantile_predictions(
|
| 796 |
y_actual_path[:n_path],
|
deep_learning/training/trainer.py
CHANGED
|
@@ -284,10 +284,41 @@ def _validate_quantile_prediction_shape(pred_np: np.ndarray, cfg: TFTASROConfig)
|
|
| 284 |
)
|
| 285 |
|
| 286 |
|
| 287 |
-
def _predict_quantiles_to_np(
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
pred_np = pred.cpu().numpy() if hasattr(pred, "cpu") else np.asarray(pred)
|
| 290 |
_validate_quantile_prediction_shape(pred_np, cfg)
|
|
|
|
|
|
|
| 291 |
return pred_np
|
| 292 |
|
| 293 |
|
|
@@ -836,16 +867,10 @@ def train_tft_model(
|
|
| 836 |
fit_weekly_interval_scale,
|
| 837 |
)
|
| 838 |
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
)
|
| 844 |
-
if val_actual_parts:
|
| 845 |
-
import torch
|
| 846 |
-
|
| 847 |
-
val_actual_path = torch.cat(val_actual_parts).cpu().numpy()
|
| 848 |
-
val_pred_np = _predict_quantiles_to_np(evaluation_model, val_dl, cfg)
|
| 849 |
direction_calibration = fit_direction_sign_calibration(
|
| 850 |
val_actual_path,
|
| 851 |
val_pred_np,
|
|
@@ -965,14 +990,10 @@ def train_tft_model(
|
|
| 965 |
apply_weekly_sign_correction_np,
|
| 966 |
)
|
| 967 |
|
| 968 |
-
#
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
batch[1][0] if isinstance(batch[1], (list, tuple)) else batch[1]
|
| 973 |
-
)
|
| 974 |
-
y_actual_path = torch.cat(y_actual_parts).cpu().numpy()
|
| 975 |
-
pred_np = _predict_quantiles_to_np(evaluation_model, test_dl, cfg)
|
| 976 |
ensemble_size = 1
|
| 977 |
logger.info("Promoted checkpoint evaluation: 1 model")
|
| 978 |
|
|
@@ -1198,15 +1219,12 @@ def _write_conformal_calibration_artifact(
|
|
| 1198 |
monotonic_quantiles_np,
|
| 1199 |
)
|
| 1200 |
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
|
|
|
| 1205 |
return None
|
| 1206 |
-
|
| 1207 |
-
y_actual_path = torch.cat(y_parts).cpu().numpy()
|
| 1208 |
-
pred = model.predict(val_dl, mode="quantiles")
|
| 1209 |
-
pred_np = pred.cpu().numpy() if hasattr(pred, "cpu") else np.asarray(pred)
|
| 1210 |
pred_np = pred_np * int(direction_sign_multiplier)
|
| 1211 |
pred_np = apply_weekly_sign_correction_np(
|
| 1212 |
pred_np,
|
|
|
|
| 284 |
)
|
| 285 |
|
| 286 |
|
| 287 |
+
def _predict_quantiles_to_np(
|
| 288 |
+
mdl,
|
| 289 |
+
dataloader,
|
| 290 |
+
cfg: TFTASROConfig,
|
| 291 |
+
return_y: bool = False,
|
| 292 |
+
):
|
| 293 |
+
y_actual_np = None
|
| 294 |
+
pred = None
|
| 295 |
+
try:
|
| 296 |
+
pred_out = mdl.predict(dataloader, mode="quantiles", return_y=True)
|
| 297 |
+
if hasattr(pred_out, "output") and hasattr(pred_out, "y"):
|
| 298 |
+
pred = pred_out.output
|
| 299 |
+
y_val = pred_out.y[0] if isinstance(pred_out.y, (list, tuple)) else pred_out.y
|
| 300 |
+
y_actual_np = y_val.cpu().numpy() if hasattr(y_val, "cpu") else np.asarray(y_val)
|
| 301 |
+
elif isinstance(pred_out, (list, tuple)) and len(pred_out) == 2:
|
| 302 |
+
pred, y_val = pred_out
|
| 303 |
+
y_val = y_val[0] if isinstance(y_val, (list, tuple)) else y_val
|
| 304 |
+
y_actual_np = y_val.cpu().numpy() if hasattr(y_val, "cpu") else np.asarray(y_val)
|
| 305 |
+
else:
|
| 306 |
+
pred = pred_out
|
| 307 |
+
except TypeError:
|
| 308 |
+
pred = mdl.predict(dataloader, mode="quantiles")
|
| 309 |
+
|
| 310 |
+
if return_y and y_actual_np is None:
|
| 311 |
+
y_parts = []
|
| 312 |
+
for batch in dataloader:
|
| 313 |
+
y_parts.append(batch[1][0] if isinstance(batch[1], (list, tuple)) else batch[1])
|
| 314 |
+
if y_parts:
|
| 315 |
+
import torch
|
| 316 |
+
y_actual_np = torch.cat(y_parts).cpu().numpy()
|
| 317 |
+
|
| 318 |
pred_np = pred.cpu().numpy() if hasattr(pred, "cpu") else np.asarray(pred)
|
| 319 |
_validate_quantile_prediction_shape(pred_np, cfg)
|
| 320 |
+
if return_y:
|
| 321 |
+
return pred_np, y_actual_np
|
| 322 |
return pred_np
|
| 323 |
|
| 324 |
|
|
|
|
| 867 |
fit_weekly_interval_scale,
|
| 868 |
)
|
| 869 |
|
| 870 |
+
val_pred_np, val_actual_path = _predict_quantiles_to_np(
|
| 871 |
+
evaluation_model, val_dl, cfg, return_y=True
|
| 872 |
+
)
|
| 873 |
+
if val_actual_path is not None and len(val_actual_path) > 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
direction_calibration = fit_direction_sign_calibration(
|
| 875 |
val_actual_path,
|
| 876 |
val_pred_np,
|
|
|
|
| 990 |
apply_weekly_sign_correction_np,
|
| 991 |
)
|
| 992 |
|
| 993 |
+
# Single-pass prediction and target extraction (eliminates dataloader double-iteration)
|
| 994 |
+
pred_np, y_actual_path = _predict_quantiles_to_np(
|
| 995 |
+
evaluation_model, test_dl, cfg, return_y=True
|
| 996 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 997 |
ensemble_size = 1
|
| 998 |
logger.info("Promoted checkpoint evaluation: 1 model")
|
| 999 |
|
|
|
|
| 1219 |
monotonic_quantiles_np,
|
| 1220 |
)
|
| 1221 |
|
| 1222 |
+
# Single-pass prediction and target extraction to avoid DataLoader double iteration
|
| 1223 |
+
pred_np, y_actual_path = _predict_quantiles_to_np(
|
| 1224 |
+
model, val_dl, cfg, return_y=True
|
| 1225 |
+
)
|
| 1226 |
+
if y_actual_path is None or len(y_actual_path) == 0:
|
| 1227 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1228 |
pred_np = pred_np * int(direction_sign_multiplier)
|
| 1229 |
pred_np = apply_weekly_sign_correction_np(
|
| 1230 |
pred_np,
|
rust_accel/src/lib.rs
CHANGED
|
@@ -325,6 +325,85 @@ fn decode_entity_str(entity: &str) -> Option<char> {
|
|
| 325 |
"yen" => Some('¥'),
|
| 326 |
"euro" => Some('€'),
|
| 327 |
"deg" => Some('°'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
s if s.starts_with("#x") || s.starts_with("#X") => {
|
| 329 |
u32::from_str_radix(&s[2..], 16).ok().and_then(char::from_u32)
|
| 330 |
}
|
|
|
|
| 325 |
"yen" => Some('¥'),
|
| 326 |
"euro" => Some('€'),
|
| 327 |
"deg" => Some('°'),
|
| 328 |
+
"plusmn" => Some('±'),
|
| 329 |
+
"times" => Some('×'),
|
| 330 |
+
"divide" => Some('÷'),
|
| 331 |
+
"micro" => Some('µ'),
|
| 332 |
+
"para" => Some('¶'),
|
| 333 |
+
"middot" => Some('·'),
|
| 334 |
+
"frac14" => Some('¼'),
|
| 335 |
+
"frac12" => Some('½'),
|
| 336 |
+
"frac34" => Some('¾'),
|
| 337 |
+
"sup1" => Some('¹'),
|
| 338 |
+
"sup2" => Some('²'),
|
| 339 |
+
"sup3" => Some('³'),
|
| 340 |
+
"sect" => Some('§'),
|
| 341 |
+
"laquo" => Some('«'),
|
| 342 |
+
"raquo" => Some('»'),
|
| 343 |
+
"iquest" => Some('¿'),
|
| 344 |
+
"iexcl" => Some('¡'),
|
| 345 |
+
"Agrave" => Some('À'),
|
| 346 |
+
"Aacute" => Some('Á'),
|
| 347 |
+
"Acirc" => Some('Â'),
|
| 348 |
+
"Atilde" => Some('Ã'),
|
| 349 |
+
"Auml" => Some('Ä'),
|
| 350 |
+
"Aring" => Some('Å'),
|
| 351 |
+
"AElig" => Some('Æ'),
|
| 352 |
+
"Ccedil" => Some('Ç'),
|
| 353 |
+
"Egrave" => Some('È'),
|
| 354 |
+
"Eacute" => Some('É'),
|
| 355 |
+
"Ecirc" => Some('Ê'),
|
| 356 |
+
"Euml" => Some('Ë'),
|
| 357 |
+
"Igrave" => Some('Ì'),
|
| 358 |
+
"Iacute" => Some('Í'),
|
| 359 |
+
"Icirc" => Some('Î'),
|
| 360 |
+
"Iuml" => Some('Ï'),
|
| 361 |
+
"ETH" => Some('Ð'),
|
| 362 |
+
"Ntilde" => Some('Ñ'),
|
| 363 |
+
"Ograve" => Some('Ò'),
|
| 364 |
+
"Oacute" => Some('Ó'),
|
| 365 |
+
"Ocirc" => Some('Ô'),
|
| 366 |
+
"Otilde" => Some('Õ'),
|
| 367 |
+
"Ouml" => Some('Ö'),
|
| 368 |
+
"Oslash" => Some('Ø'),
|
| 369 |
+
"Ugrave" => Some('Ù'),
|
| 370 |
+
"Uacute" => Some('Ú'),
|
| 371 |
+
"Ucirc" => Some('Û'),
|
| 372 |
+
"Uuml" => Some('Ü'),
|
| 373 |
+
"Yacute" => Some('Ý'),
|
| 374 |
+
"THORN" => Some('Þ'),
|
| 375 |
+
"szlig" => Some('ß'),
|
| 376 |
+
"agrave" => Some('à'),
|
| 377 |
+
"aacute" => Some('á'),
|
| 378 |
+
"acirc" => Some('â'),
|
| 379 |
+
"atilde" => Some('ã'),
|
| 380 |
+
"auml" => Some('ä'),
|
| 381 |
+
"aring" => Some('å'),
|
| 382 |
+
"aelig" => Some('æ'),
|
| 383 |
+
"ccedil" => Some('ç'),
|
| 384 |
+
"egrave" => Some('è'),
|
| 385 |
+
"eacute" => Some('é'),
|
| 386 |
+
"ecirc" => Some('ê'),
|
| 387 |
+
"euml" => Some('ë'),
|
| 388 |
+
"igrave" => Some('ì'),
|
| 389 |
+
"iacute" => Some('í'),
|
| 390 |
+
"icirc" => Some('î'),
|
| 391 |
+
"iuml" => Some('ï'),
|
| 392 |
+
"eth" => Some('ð'),
|
| 393 |
+
"ntilde" => Some('ñ'),
|
| 394 |
+
"ograve" => Some('ò'),
|
| 395 |
+
"oacute" => Some('ó'),
|
| 396 |
+
"ocirc" => Some('ô'),
|
| 397 |
+
"otilde" => Some('õ'),
|
| 398 |
+
"ouml" => Some('ö'),
|
| 399 |
+
"oslash" => Some('ø'),
|
| 400 |
+
"ugrave" => Some('ù'),
|
| 401 |
+
"uacute" => Some('ú'),
|
| 402 |
+
"ucirc" => Some('û'),
|
| 403 |
+
"uuml" => Some('ü'),
|
| 404 |
+
"yacute" => Some('ý'),
|
| 405 |
+
"thorn" => Some('þ'),
|
| 406 |
+
"yuml" => Some('ÿ'),
|
| 407 |
s if s.starts_with("#x") || s.starts_with("#X") => {
|
| 408 |
u32::from_str_radix(&s[2..], 16).ok().and_then(char::from_u32)
|
| 409 |
}
|
worker/tasks.py
CHANGED
|
@@ -891,6 +891,8 @@ async def _execute_pipeline_stages_v2(
|
|
| 891 |
if isinstance(prediction_block, dict)
|
| 892 |
else None
|
| 893 |
),
|
|
|
|
|
|
|
| 894 |
}
|
| 895 |
update_run_metrics(session, run_id, tft_snapshot_generated=True)
|
| 896 |
session.commit()
|
|
|
|
| 891 |
if isinstance(prediction_block, dict)
|
| 892 |
else None
|
| 893 |
),
|
| 894 |
+
"is_stale": bool(tft_report.get("is_stale", False)),
|
| 895 |
+
"baseline_staleness_days": tft_report.get("baseline_staleness_days", 0),
|
| 896 |
}
|
| 897 |
update_run_metrics(session, run_id, tft_snapshot_generated=True)
|
| 898 |
session.commit()
|