ifieryarrows commited on
Commit
ef498bb
·
verified ·
1 Parent(s): 93cc691

Sync from GitHub (tests passed)

Browse files
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::bigint = :classid
111
- AND objid::bigint = :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
- return False
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
- return _rust_ext.clean_text_rs(text)
 
 
 
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: int = 30):
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: Seconds to wait between retries
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
- logger.warning(f"{symbol}: Rate limited, waiting {retry_delay}s before retry {attempt + 2}/{max_retries}")
478
- time.sleep(retry_delay)
 
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
- def _fetch_single(params):
544
- sym, s_date, e_date, f_mode = params
545
- logger.info(f"Fetching prices for {sym} ({f_mode})...")
546
- try:
547
- df = fetch_symbol_with_retry(sym, s_date, e_date)
548
- return sym, df, f_mode, None
549
- except Exception as exc:
550
- return sym, None, f_mode, exc
551
-
552
- max_workers = min(4, max(1, len(symbol_params)))
553
- if max_workers > 1:
554
- with ThreadPoolExecutor(max_workers=max_workers) as executor:
555
- fetched_results = list(executor.map(_fetch_single, symbol_params))
556
  else:
557
- fetched_results = [_fetch_single(p) for p in symbol_params]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- with SessionLocal() as session:
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 = session.query(func.count(NewsArticle.id)).scalar()
444
- price_count = session.query(func.count(PriceBar.id)).scalar()
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 = is_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. By default reads the latest TFT snapshot "
1426
- "produced by the daily pipeline worker while it matches the newest "
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
- 404: {"description": "TFT model not available"},
1434
- 500: {"description": "Prediction failed"},
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
- only while it is anchored to the newest available PriceBar. When a new
1447
- market bar arrives, transparently generate a new rolling five-day
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 not in {"snapshot", "live"}:
 
 
 
 
 
1455
  raise HTTPException(
1456
  status_code=400,
1457
  detail="source must be one of: snapshot, live",
1458
  )
1459
 
1460
- latest_price_date: Optional[date] = None
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
- logger.info(
1502
- "TFT snapshot bypassed for %s; using live inference (%s)",
1503
- symbol,
1504
- "; ".join(fallback_reasons),
1505
- )
1506
- except Exception as exc:
1507
- logger.warning(
1508
- "TFT snapshot read failed, falling back to live inference: %s",
1509
- exc,
 
 
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
- # A cancelled request must not cancel inference shared with other callers.
1566
- return await asyncio.shield(task)
1567
-
1568
- try:
1569
- return await _get_or_start_live_tft()
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
- except ImportError as exc:
1577
- raise HTTPException(
1578
- status_code=404,
1579
- detail=f"TFT-ASRO module not available: {exc}",
 
 
 
 
 
 
1580
  )
1581
- except HTTPException:
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
- features["futures_curve_slope"] = (spread_20_60 / ma_60.replace(0, np.nan)).rolling(5, min_periods=1).mean()
 
 
 
 
 
 
 
 
 
 
 
 
 
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"] = not is_stale
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 "stale",
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
- pred_tensor = model.predict(fold_val_dl, mode="quantiles")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- y_actual_parts = []
772
- for batch in fold_val_dl:
773
- y_actual_parts.append(
774
- batch[1][0] if isinstance(batch[1], (list, tuple)) else batch[1]
775
- )
776
- y_actual_path = torch.cat(y_actual_parts).cpu().numpy()
 
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(mdl, dataloader, cfg: TFTASROConfig) -> np.ndarray:
288
- pred = mdl.predict(dataloader, mode="quantiles")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- val_actual_parts = []
840
- for batch in val_dl:
841
- val_actual_parts.append(
842
- batch[1][0] if isinstance(batch[1], (list, tuple)) else batch[1]
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
- # Collect actual values (same regardless of which model predicts)
969
- y_actual_parts = []
970
- for batch in test_dl:
971
- y_actual_parts.append(
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
- y_parts = []
1202
- for batch in val_dl:
1203
- y_parts.append(batch[1][0] if isinstance(batch[1], (list, tuple)) else batch[1])
1204
- if not y_parts:
 
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()