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

Sync from GitHub (tests passed)

Browse files
app/ai_engine.py CHANGED
@@ -1696,18 +1696,22 @@ def score_unscored_processed_articles(
1696
  llm_results_by_id: dict[int, dict] = {}
1697
  llm_candidates: list[dict] = []
1698
 
1699
- # Per-model rate-limit tracking (keyed by UTC date). A specific model
1700
- # is considered exhausted for today if its entry equals today's date.
1701
  # LLM scoring is skipped for the chunk only when BOTH fast and reliable
1702
  # models have been flagged today — otherwise we still attempt (fallback
1703
  # chain inside `score_batch_with_llm_v2` handles partial exhaustion).
 
1704
  today_utc = datetime.now(timezone.utc).date().isoformat()
1705
- rate_limited_by_model: dict[str, str] = getattr(
1706
- score_unscored_processed_articles, "_rate_limited_by_model", {}
1707
- ) or {}
1708
- fast_exhausted = rate_limited_by_model.get(fast_model) == today_utc
1709
- reliable_exhausted = rate_limited_by_model.get(reliable_model) == today_utc
1710
- both_exhausted = fast_exhausted and reliable_exhausted
 
 
 
1711
 
1712
  if get_configured_api_keys(settings) and llm_budget_remaining > 0 and not both_exhausted:
1713
  eligible_items = [item for item in chunk_items if item["id"] in llm_eligible_ids]
@@ -1757,14 +1761,14 @@ def score_unscored_processed_articles(
1757
  # warning when both are flagged.
1758
  updated = False
1759
  if llm_bundle.get("rate_limited_fast"):
1760
- rate_limited_by_model[fast_model] = today_utc
1761
  updated = True
1762
  logger.warning(
1763
  "V2 fast model %s hit daily rate limit; will be skipped for rest of UTC day %s.",
1764
  fast_model, today_utc,
1765
  )
1766
  if llm_bundle.get("rate_limited_reliable"):
1767
- rate_limited_by_model[reliable_model] = today_utc
1768
  updated = True
1769
  logger.warning(
1770
  "V2 reliable model %s hit daily rate limit; will be skipped for rest of UTC day %s.",
@@ -2522,10 +2526,12 @@ def _promote_xgb_artifact(
2522
  "feature_count": len(features),
2523
  "data_window_fingerprint": data_window_fingerprint,
2524
  }
 
 
2525
  candidate = ModelArtifact(
2526
  symbol=symbol,
2527
  version=version,
2528
- status="candidate",
2529
  model_blob=blob,
2530
  sha256=digest,
2531
  features_json=json.dumps(features, ensure_ascii=True),
@@ -2538,9 +2544,9 @@ def _promote_xgb_artifact(
2538
  session.flush()
2539
  session.query(ModelArtifact).filter(
2540
  ModelArtifact.symbol == symbol,
2541
- ModelArtifact.status == "active",
2542
- ).update({"status": "archived"}, synchronize_session=False)
2543
- candidate.status = "active"
2544
  candidate.promoted_at = datetime.now(timezone.utc)
2545
  session.flush()
2546
  return version
@@ -2548,12 +2554,17 @@ def _promote_xgb_artifact(
2548
 
2549
  def load_active_model_bundle(target_symbol: str = "HG=F") -> tuple[Optional[xgb.Booster], dict]:
2550
  """Load model and metadata from the same active row to prevent split-brain."""
 
 
2551
  settings = get_settings()
2552
  try:
2553
  with SessionLocal() as session:
2554
  artifact = (
2555
  session.query(ModelArtifact)
2556
- .filter(ModelArtifact.symbol == target_symbol, ModelArtifact.status == "active")
 
 
 
2557
  .order_by(ModelArtifact.promoted_at.desc(), ModelArtifact.id.desc())
2558
  .first()
2559
  )
 
1696
  llm_results_by_id: dict[int, dict] = {}
1697
  llm_candidates: list[dict] = []
1698
 
1699
+ # Per-model rate-limit tracking (keyed by UTC date) via DailyModelQuotaTracker.
1700
+ # A specific model is considered exhausted for today if its entry equals today's date.
1701
  # LLM scoring is skipped for the chunk only when BOTH fast and reliable
1702
  # models have been flagged today — otherwise we still attempt (fallback
1703
  # chain inside `score_batch_with_llm_v2` handles partial exhaustion).
1704
+ from app.state_machines import DailyModelQuotaTracker
1705
  today_utc = datetime.now(timezone.utc).date().isoformat()
1706
+ existing_tracker = getattr(
1707
+ score_unscored_processed_articles, "_rate_limited_by_model", None
1708
+ )
1709
+ rate_limited_by_model = DailyModelQuotaTracker(existing_tracker or {})
1710
+ fast_exhausted = rate_limited_by_model.is_exhausted_today(fast_model, today_utc)
1711
+ reliable_exhausted = rate_limited_by_model.is_exhausted_today(reliable_model, today_utc)
1712
+ both_exhausted = rate_limited_by_model.are_both_exhausted(
1713
+ fast_model, reliable_model, today_utc
1714
+ )
1715
 
1716
  if get_configured_api_keys(settings) and llm_budget_remaining > 0 and not both_exhausted:
1717
  eligible_items = [item for item in chunk_items if item["id"] in llm_eligible_ids]
 
1761
  # warning when both are flagged.
1762
  updated = False
1763
  if llm_bundle.get("rate_limited_fast"):
1764
+ rate_limited_by_model.mark_exhausted(fast_model, today_utc)
1765
  updated = True
1766
  logger.warning(
1767
  "V2 fast model %s hit daily rate limit; will be skipped for rest of UTC day %s.",
1768
  fast_model, today_utc,
1769
  )
1770
  if llm_bundle.get("rate_limited_reliable"):
1771
+ rate_limited_by_model.mark_exhausted(reliable_model, today_utc)
1772
  updated = True
1773
  logger.warning(
1774
  "V2 reliable model %s hit daily rate limit; will be skipped for rest of UTC day %s.",
 
2526
  "feature_count": len(features),
2527
  "data_window_fingerprint": data_window_fingerprint,
2528
  }
2529
+ from app.state_machines import XGBArtifactStatus
2530
+
2531
  candidate = ModelArtifact(
2532
  symbol=symbol,
2533
  version=version,
2534
+ status=XGBArtifactStatus.CANDIDATE.value,
2535
  model_blob=blob,
2536
  sha256=digest,
2537
  features_json=json.dumps(features, ensure_ascii=True),
 
2544
  session.flush()
2545
  session.query(ModelArtifact).filter(
2546
  ModelArtifact.symbol == symbol,
2547
+ ModelArtifact.status == XGBArtifactStatus.ACTIVE.value,
2548
+ ).update({"status": XGBArtifactStatus.ARCHIVED.value}, synchronize_session=False)
2549
+ candidate.status = XGBArtifactStatus.ACTIVE.value
2550
  candidate.promoted_at = datetime.now(timezone.utc)
2551
  session.flush()
2552
  return version
 
2554
 
2555
  def load_active_model_bundle(target_symbol: str = "HG=F") -> tuple[Optional[xgb.Booster], dict]:
2556
  """Load model and metadata from the same active row to prevent split-brain."""
2557
+ from app.state_machines import XGBArtifactStatus
2558
+
2559
  settings = get_settings()
2560
  try:
2561
  with SessionLocal() as session:
2562
  artifact = (
2563
  session.query(ModelArtifact)
2564
+ .filter(
2565
+ ModelArtifact.symbol == target_symbol,
2566
+ ModelArtifact.status == XGBArtifactStatus.ACTIVE.value,
2567
+ )
2568
  .order_by(ModelArtifact.promoted_at.desc(), ModelArtifact.id.desc())
2569
  .first()
2570
  )
app/commentary.py CHANGED
@@ -1197,11 +1197,12 @@ An object containing daily in-character companion dialogue for Cuprum ("Külçe
1197
  else:
1198
  raise
1199
 
 
1200
  logger.info("IFM commentary generated successfully (%s chars)", len(commentary))
1201
  return CommentaryGenerationResult(
1202
  commentary,
1203
  _resolve_stance(stance, deterministic_stance),
1204
- "ifm_k2",
1205
  settings.ifm_model,
1206
  None,
1207
  [],
@@ -1215,10 +1216,16 @@ An object containing daily in-character companion dialogue for Cuprum ("Külçe
1215
  )
1216
 
1217
  # 2. Try OpenRouter as secondary provider
 
1218
  if not get_configured_api_keys(settings):
1219
  logger.warning("No LLM API keys configured (IFM or OpenRouter), using template commentary fallback")
1220
  return CommentaryGenerationResult(
1221
- fallback_commentary, deterministic_stance, "deterministic_fallback", None, "auth", []
 
 
 
 
 
1222
  )
1223
 
1224
  base_request_kwargs = {
@@ -1272,7 +1279,7 @@ An object containing daily in-character companion dialogue for Cuprum ("Külçe
1272
  return CommentaryGenerationResult(
1273
  commentary,
1274
  _resolve_stance(stance, deterministic_stance),
1275
- "llm",
1276
  actual_model,
1277
  None,
1278
  attempts,
@@ -1293,7 +1300,7 @@ An object containing daily in-character companion dialogue for Cuprum ("Külçe
1293
  return CommentaryGenerationResult(
1294
  commentary,
1295
  _resolve_stance(stance, deterministic_stance),
1296
- "llm_repaired",
1297
  actual_model,
1298
  None,
1299
  attempts,
@@ -1304,7 +1311,7 @@ An object containing daily in-character companion dialogue for Cuprum ("Külçe
1304
  return CommentaryGenerationResult(
1305
  fallback_commentary,
1306
  deterministic_stance,
1307
- "deterministic_fallback",
1308
  None,
1309
  getattr(exc, "category", "parse_invalid"),
1310
  getattr(exc, "attempts", None) or attempts,
 
1197
  else:
1198
  raise
1199
 
1200
+ from app.state_machines import CommentaryGenerationMode
1201
  logger.info("IFM commentary generated successfully (%s chars)", len(commentary))
1202
  return CommentaryGenerationResult(
1203
  commentary,
1204
  _resolve_stance(stance, deterministic_stance),
1205
+ CommentaryGenerationMode.IFM_K2.value,
1206
  settings.ifm_model,
1207
  None,
1208
  [],
 
1216
  )
1217
 
1218
  # 2. Try OpenRouter as secondary provider
1219
+ from app.state_machines import CommentaryGenerationMode
1220
  if not get_configured_api_keys(settings):
1221
  logger.warning("No LLM API keys configured (IFM or OpenRouter), using template commentary fallback")
1222
  return CommentaryGenerationResult(
1223
+ fallback_commentary,
1224
+ deterministic_stance,
1225
+ CommentaryGenerationMode.DETERMINISTIC_FALLBACK.value,
1226
+ None,
1227
+ "auth",
1228
+ [],
1229
  )
1230
 
1231
  base_request_kwargs = {
 
1279
  return CommentaryGenerationResult(
1280
  commentary,
1281
  _resolve_stance(stance, deterministic_stance),
1282
+ CommentaryGenerationMode.LLM.value,
1283
  actual_model,
1284
  None,
1285
  attempts,
 
1300
  return CommentaryGenerationResult(
1301
  commentary,
1302
  _resolve_stance(stance, deterministic_stance),
1303
+ CommentaryGenerationMode.LLM_REPAIRED.value,
1304
  actual_model,
1305
  None,
1306
  attempts,
 
1311
  return CommentaryGenerationResult(
1312
  fallback_commentary,
1313
  deterministic_stance,
1314
+ CommentaryGenerationMode.DETERMINISTIC_FALLBACK.value,
1315
  None,
1316
  getattr(exc, "category", "parse_invalid"),
1317
  getattr(exc, "attempts", None) or attempts,
app/heatmap.py CHANGED
@@ -695,13 +695,14 @@ def refresh_market_heatmap() -> None:
695
  logger.warning("Heatmap metadata batch failed: %s", metadata_batch_error)
696
  metadata_symbols = set()
697
  provider_stats["info_skipped"] = len(symbols)
698
- metadata_circuit_broken = False
 
699
  for symbol in symbols:
700
  info: dict = {}
701
  try:
702
  history_quote = quote_by_symbol.get(symbol, {})
703
- should_fetch_info = symbol in metadata_symbols and not metadata_circuit_broken
704
- if symbol in metadata_symbols and metadata_circuit_broken:
705
  provider_stats["metadata_deferred"] += 1
706
  provider_stats["info_skipped"] += 1
707
  if should_fetch_info:
@@ -710,7 +711,7 @@ def refresh_market_heatmap() -> None:
710
  info, auth_blocked = _fetch_ticker_info_safely(ticker)
711
  if auth_blocked:
712
  provider_stats["info_error"] += 1
713
- metadata_circuit_broken = True
714
  logger.warning(
715
  "Heatmap metadata quoteSummary blocked by Yahoo (401/crumb/rate-limit) on %s; "
716
  "circuit-breaking remaining metadata batch",
@@ -721,7 +722,9 @@ def refresh_market_heatmap() -> None:
721
  except Exception as info_error:
722
  provider_stats["info_error"] += 1
723
  if _is_quote_summary_auth_error(info_error) or _is_rate_limit_error(info_error):
724
- metadata_circuit_broken = True
 
 
725
  logger.debug("Heatmap metadata failed for %s: %s", symbol, info_error)
726
  info_price = _finite_float(info.get("regularMarketPrice"), info.get("currentPrice"))
727
  if info and info_price is None:
 
695
  logger.warning("Heatmap metadata batch failed: %s", metadata_batch_error)
696
  metadata_symbols = set()
697
  provider_stats["info_skipped"] = len(symbols)
698
+ from app.state_machines import HeatmapMetadataCircuitBreaker
699
+ metadata_circuit = HeatmapMetadataCircuitBreaker()
700
  for symbol in symbols:
701
  info: dict = {}
702
  try:
703
  history_quote = quote_by_symbol.get(symbol, {})
704
+ should_fetch_info = symbol in metadata_symbols and not metadata_circuit.is_open
705
+ if symbol in metadata_symbols and metadata_circuit.is_open:
706
  provider_stats["metadata_deferred"] += 1
707
  provider_stats["info_skipped"] += 1
708
  if should_fetch_info:
 
711
  info, auth_blocked = _fetch_ticker_info_safely(ticker)
712
  if auth_blocked:
713
  provider_stats["info_error"] += 1
714
+ metadata_circuit.trip_rate_limit("quoteSummary_auth_blocked")
715
  logger.warning(
716
  "Heatmap metadata quoteSummary blocked by Yahoo (401/crumb/rate-limit) on %s; "
717
  "circuit-breaking remaining metadata batch",
 
722
  except Exception as info_error:
723
  provider_stats["info_error"] += 1
724
  if _is_quote_summary_auth_error(info_error) or _is_rate_limit_error(info_error):
725
+ metadata_circuit.trip_rate_limit(str(info_error))
726
+ else:
727
+ metadata_circuit.record_provider_error(str(info_error))
728
  logger.debug("Heatmap metadata failed for %s: %s", symbol, info_error)
729
  info_price = _finite_float(info.get("regularMarketPrice"), info.get("currentPrice"))
730
  if info and info_price is None:
app/inference.py CHANGED
@@ -122,8 +122,13 @@ def _align_features_to_model(df: pd.DataFrame, expected_features: list[str]) ->
122
  return df.reindex(columns=expected_features, fill_value=0.0)
123
 
124
 
 
 
125
  _YFINANCE_INFO_BLOCKED_UNTIL: float = 0.0
126
  _YFINANCE_INFO_COOLDOWN_SECONDS: float = 900.0
 
 
 
127
 
128
 
129
  def _extract_yfinance_live_price(ticker: object) -> Optional[float]:
@@ -134,11 +139,13 @@ def _extract_yfinance_live_price(ticker: object) -> Optional[float]:
134
  from app.heatmap import _fetch_ticker_info_safely
135
 
136
  now = time.monotonic()
137
- if now >= _YFINANCE_INFO_BLOCKED_UNTIL:
 
 
138
  try:
139
  info, auth_blocked = _fetch_ticker_info_safely(ticker)
140
  if auth_blocked:
141
- _YFINANCE_INFO_BLOCKED_UNTIL = now + _YFINANCE_INFO_COOLDOWN_SECONDS
142
  elif isinstance(info, dict):
143
  candidate = info.get("regularMarketPrice") or info.get("currentPrice")
144
  valid = finite_positive_price(candidate)
 
122
  return df.reindex(columns=expected_features, fill_value=0.0)
123
 
124
 
125
+ from app.state_machines import YFinanceCrumbCircuitBreaker
126
+
127
  _YFINANCE_INFO_BLOCKED_UNTIL: float = 0.0
128
  _YFINANCE_INFO_COOLDOWN_SECONDS: float = 900.0
129
+ _YFINANCE_CRUMB_CIRCUIT = YFinanceCrumbCircuitBreaker(
130
+ cooldown_seconds=_YFINANCE_INFO_COOLDOWN_SECONDS
131
+ )
132
 
133
 
134
  def _extract_yfinance_live_price(ticker: object) -> Optional[float]:
 
139
  from app.heatmap import _fetch_ticker_info_safely
140
 
141
  now = time.monotonic()
142
+ _YFINANCE_CRUMB_CIRCUIT.blocked_until = _YFINANCE_INFO_BLOCKED_UNTIL
143
+ _YFINANCE_CRUMB_CIRCUIT.cooldown_seconds = _YFINANCE_INFO_COOLDOWN_SECONDS
144
+ if not _YFINANCE_CRUMB_CIRCUIT.is_open(now):
145
  try:
146
  info, auth_blocked = _fetch_ticker_info_safely(ticker)
147
  if auth_blocked:
148
+ _YFINANCE_INFO_BLOCKED_UNTIL = _YFINANCE_CRUMB_CIRCUIT.trip(now)
149
  elif isinstance(info, dict):
150
  candidate = info.get("regularMarketPrice") or info.get("currentPrice")
151
  valid = finite_positive_price(candidate)
app/main.py CHANGED
@@ -190,11 +190,12 @@ async def get_analysis(
190
 
191
  if snapshot is None:
192
  # No snapshot at all - return minimal response for UI compatibility
 
193
  logger.warning(f"No snapshot found for {symbol}")
194
  return {
195
  "symbol": symbol,
196
- "quality_state": "missing",
197
- "model_state": "offline",
198
  "current_price": 0.0,
199
  "predicted_return": 0.0,
200
  "predicted_price": 0.0,
@@ -212,26 +213,26 @@ async def get_analysis(
212
  "message": "No analysis available. Pipeline may not have run yet.",
213
  }
214
 
215
- # Calculate snapshot age
 
216
  now = datetime.now(timezone.utc)
217
  generated_at = snapshot.generated_at
218
  if generated_at.tzinfo is None:
219
  generated_at = generated_at.replace(tzinfo=timezone.utc)
220
-
221
- age_hours = (now - generated_at).total_seconds() / 3600
222
-
223
- # Determine quality state
224
- if age_hours > STALE_THRESHOLD_HOURS:
225
- quality_state = "stale"
226
- else:
227
- quality_state = "ok"
228
 
229
  # Build response from snapshot
230
  report = snapshot.report_json.copy() if snapshot.report_json else {}
231
 
232
  # Add/override metadata
233
  report["quality_state"] = quality_state
234
- report["model_state"] = "ok" if quality_state == "ok" else "degraded"
235
  report["snapshot_age_hours"] = round(age_hours, 1)
236
  report["generated_at"] = generated_at.isoformat()
237
 
@@ -486,19 +487,10 @@ async def health_check():
486
  .first()
487
  )
488
  if latest_run is not None:
 
489
  ended = latest_run.run_completed_at or latest_run.run_started_at
490
  last_pipeline_run_at = _iso(ended)
491
- # Map internal run.status → external pipeline_status.
492
- # running → running
493
- # success → ok
494
- # failed → failed
495
- raw_status = (latest_run.status or "").lower()
496
- if raw_status == "success":
497
- last_pipeline_status = "ok"
498
- elif raw_status in {"running", "failed"}:
499
- last_pipeline_status = raw_status
500
- else:
501
- last_pipeline_status = raw_status or None
502
 
503
  # --- XGBoost snapshot age ---------------------------------------
504
  latest_snapshot = (
@@ -757,17 +749,18 @@ def _render_heatmap_http_response(
757
  ) -> Response:
758
  """Serialize one snapshot once and attach cache/performance diagnostics."""
759
  from app.heatmap import flatten_heatmap_leaves, hierarchy_for_view
 
760
 
761
  now = datetime.now(timezone.utc)
762
  hierarchy_started = time.perf_counter()
763
  payload = hierarchy_for_view(persisted, view)
764
  hierarchy_ms = (time.perf_counter() - hierarchy_started) * 1000
765
  rendered_payload_count = len(flatten_heatmap_leaves(payload))
766
- cache_state = (
767
- "empty" if payload_count == 0 else
768
- "refreshing" if refresh_in_progress else
769
- "stale" if is_stale else "fresh"
770
- )
771
  if cached_at and cached_at.tzinfo is None:
772
  cached_at = cached_at.replace(tzinfo=timezone.utc)
773
  if expires_at and expires_at.tzinfo is None:
@@ -853,32 +846,35 @@ async def get_market_heatmap(
853
  session.add(cache)
854
  session.flush()
855
 
 
 
856
  persisted = cache.payload_json if isinstance(cache.payload_json, dict) else {}
857
  persisted_leaves = flatten_heatmap_leaves(persisted)
858
  payload_count = len(persisted_leaves)
859
  needs_enrichment = any(not leaf.get("instrumentType") for leaf in persisted_leaves)
860
- refresh_in_progress = cache.refresh_started_at is not None
861
- if refresh_in_progress:
862
- refresh_started = cache.refresh_started_at
863
- if refresh_started.tzinfo is None:
864
- refresh_started = refresh_started.replace(tzinfo=timezone.utc)
865
- if (now - refresh_started).total_seconds() > 180:
866
- logger.warning("Heatmap refresh appears stuck; clearing in-flight marker")
867
- cache.refresh_started_at = None
868
- refresh_in_progress = False
 
 
869
 
870
  expires_at = cache.expires_at
871
  if expires_at and expires_at.tzinfo is None:
872
  expires_at = expires_at.replace(tzinfo=timezone.utc)
873
- retry_backoff = bool(cache.refresh_error) and bool(expires_at) and now < expires_at
874
- is_stale = not expires_at or now >= expires_at or needs_enrichment or bool(cache.refresh_error)
875
- if (payload_count == 0 or is_stale) and not refresh_in_progress and not retry_backoff:
876
  # Persist before enqueueing so concurrent empty-cache requests do
877
  # not each schedule a provider refresh.
878
  cache.refresh_started_at = now
879
  session.commit()
880
  background_tasks.add_task(refresh_market_heatmap)
881
- refresh_in_progress = True
882
  elif session.dirty or session.new:
883
  session.commit()
884
  db_ms = (time.perf_counter() - db_started) * 1000
@@ -1037,7 +1033,10 @@ async def websocket_live_price(
1037
  import websockets
1038
  import yfinance as yf
1039
 
 
 
1040
  await websocket.accept()
 
1041
  canonical_symbol = canonicalize_instrument_symbol(symbol)
1042
  yahoo_symbol = resolve_provider_symbol(canonical_symbol, "yahoo_websocket")
1043
  yahoo_ws_url = "wss://streamer.finance.yahoo.com/?version=2"
@@ -1073,6 +1072,7 @@ async def websocket_live_price(
1073
 
1074
  price = await asyncio.to_thread(read_price)
1075
  if price is not None:
 
1076
  await send_payload(
1077
  {
1078
  "price": round(float(price), 4),
@@ -1099,9 +1099,10 @@ async def websocket_live_price(
1099
  close_timeout=5,
1100
  ) as yahoo_ws:
1101
  await yahoo_ws.send(json.dumps({"subscribe": [yahoo_symbol]}))
 
1102
  await send_payload(
1103
  {
1104
- "status": "subscribed",
1105
  "source": "yahoo_finance_websocket",
1106
  "error": None,
1107
  }
@@ -1121,6 +1122,7 @@ async def websocket_live_price(
1121
  if price is None:
1122
  continue
1123
 
 
1124
  await send_payload(
1125
  {
1126
  "price": price,
@@ -1142,11 +1144,12 @@ async def websocket_live_price(
1142
  except WebSocketDisconnect:
1143
  raise
1144
  except Exception as e:
 
1145
  logger.warning("Yahoo websocket stream interrupted for %s: %s", yahoo_symbol, e)
1146
  try:
1147
  await send_payload(
1148
  {
1149
- "status": "reconnecting",
1150
  "source": "yahoo_finance_websocket",
1151
  "error": "stream reconnecting",
1152
  }
@@ -1341,8 +1344,8 @@ def _tft_snapshot_invalid_reasons(
1341
  design (there is nothing newer to compare against).
1342
  """
1343
  from app.models import TFTModelMetadata
 
1344
 
1345
- reasons: list[str] = []
1346
  gen_at = _as_utc(snapshot.generated_at)
1347
 
1348
  # Only a quality-gate-passed model should invalidate the snapshot. A
@@ -1358,11 +1361,6 @@ def _tft_snapshot_invalid_reasons(
1358
  .first()
1359
  )
1360
  trained_at = _as_utc(model_meta.trained_at) if model_meta else None
1361
- if trained_at and gen_at and trained_at > gen_at:
1362
- reasons.append(
1363
- "model trained after snapshot "
1364
- f"(trained_at={trained_at.isoformat()} > snapshot_at={gen_at.isoformat()})"
1365
- )
1366
 
1367
  payload = snapshot.payload_json if isinstance(snapshot.payload_json, dict) else {}
1368
  prediction = (
@@ -1377,25 +1375,15 @@ def _tft_snapshot_invalid_reasons(
1377
  ref_date = _parse_ref_date(reference_price_date)
1378
  if latest_price_date is None:
1379
  latest_price_date = _latest_price_bar_date(session, symbol)
1380
- if ref_date is None:
1381
- reasons.append("snapshot reference_price_date missing")
1382
- elif latest_price_date is not None and ref_date != latest_price_date:
1383
- reasons.append(
1384
- "snapshot is not anchored to latest PriceBar "
1385
- f"(snapshot={ref_date.isoformat()}, "
1386
- f"latest_price_bar={latest_price_date.isoformat()})"
1387
- )
1388
- elif latest_price_date is None:
1389
- # Defensive fallback for installations where PriceBar is temporarily
1390
- # unavailable. Normally the exact market-bar comparison above is
1391
- # authoritative and naturally handles weekends and exchange holidays.
1392
- staleness_days = (datetime.now(timezone.utc).date() - ref_date).days
1393
- if staleness_days >= 3:
1394
- reasons.append(
1395
- f"reference_price_date stale ({reference_price_date}, {staleness_days}d)"
1396
- )
1397
 
1398
- return reasons
 
 
 
 
 
 
 
1399
 
1400
 
1401
  @app.get(
@@ -1690,6 +1678,11 @@ async def trigger_pipeline(
1690
 
1691
  try:
1692
  from adapters.queue.jobs import enqueue_pipeline_job
 
 
 
 
 
1693
  import uuid
1694
 
1695
  now = datetime.now(timezone.utc)
@@ -1697,25 +1690,32 @@ async def trigger_pipeline(
1697
  orphan_cutoff = now - timedelta(minutes=get_settings().pipeline_orphan_timeout_minutes)
1698
  with SessionLocal() as session:
1699
  orphans = session.query(PipelineRunMetrics).filter(
1700
- PipelineRunMetrics.status.in_(("queued", "running")),
 
 
 
1701
  PipelineRunMetrics.run_started_at < orphan_cutoff,
1702
  ).all()
1703
  for orphan in orphans:
1704
- orphan.status = "failed"
1705
- orphan.quality_state = "failed"
1706
- orphan.error_message = "worker_interrupted"
1707
- orphan.run_completed_at = now
1708
- session.add(
1709
- PipelineRunMetrics(
1710
- run_id=run_id,
1711
- run_started_at=now,
1712
- enqueued_at=now,
1713
- trigger_source=trigger_source,
1714
- train_model_requested=train_model,
1715
- status="queued",
1716
- quality_state="queued",
1717
  )
 
 
 
 
 
 
1718
  )
 
 
 
 
 
 
1719
  session.commit()
1720
 
1721
  result = await enqueue_pipeline_job(
@@ -1743,13 +1743,16 @@ async def trigger_pipeline(
1743
  except Exception as e:
1744
  logger.error(f"Failed to enqueue pipeline job: {e}")
1745
  if "run_id" in locals():
 
1746
  with SessionLocal() as session:
1747
  queued = session.query(PipelineRunMetrics).filter(PipelineRunMetrics.run_id == run_id).first()
1748
  if queued is not None:
1749
- queued.status = "failed"
1750
- queued.quality_state = "failed"
1751
- queued.error_message = f"enqueue_failed: {str(e)[:800]}"
1752
- queued.run_completed_at = datetime.now(timezone.utc)
 
 
1753
  session.commit()
1754
  raise HTTPException(
1755
  status_code=503,
@@ -1765,6 +1768,12 @@ async def get_pipeline_run_status(
1765
  run_id: str,
1766
  _auth: None = Depends(verify_pipeline_secret),
1767
  ):
 
 
 
 
 
 
1768
  with SessionLocal() as session:
1769
  record = session.query(PipelineRunMetrics).filter(PipelineRunMetrics.run_id == run_id).first()
1770
  if record is None:
@@ -1778,7 +1787,7 @@ async def get_pipeline_run_status(
1778
  # for the worker's startup/lock-acquisition window and only fail it
1779
  # when PostgreSQL confirms that no worker owns the global lock.
1780
  if (
1781
- record.status == "running"
1782
  and record.worker_started_at is not None
1783
  and get_db_type() == "postgresql"
1784
  ):
@@ -1802,10 +1811,12 @@ async def get_pipeline_run_status(
1802
 
1803
  if not lock_held:
1804
  completed_at = datetime.now(timezone.utc)
1805
- record.status = "failed"
1806
- record.quality_state = "failed"
1807
- record.error_message = "worker_lost_lock_before_finalizing"
1808
- record.run_completed_at = completed_at
 
 
1809
  record.duration_seconds = run_age.total_seconds()
1810
  session.commit()
1811
  logger.error(
@@ -2251,15 +2262,16 @@ async def get_sentiment_summary(
2251
  )
2252
 
2253
  # ---- 3) Pick the freshest possible index ----
 
2254
  index_val: float = 0.0
2255
- source = "none"
2256
  avg_confidence: Optional[float] = None
2257
 
2258
  if v2_rows:
2259
  latest_v2 = v2_rows[-1]
2260
  index_val = float(latest_v2.sentiment_index or 0.0)
2261
  avg_confidence = float(latest_v2.avg_confidence or 0.0) if latest_v2.avg_confidence is not None else None
2262
- source = "daily_v2"
2263
  elif component_rows and component_rows.n and component_rows.n > 0:
2264
  # No daily aggregate yet — fall back to rolling per-article avg
2265
  llm = float(component_rows.avg_llm or 0.0)
@@ -2267,7 +2279,7 @@ async def get_sentiment_summary(
2267
  rule = float(component_rows.avg_rule or 0.0)
2268
  index_val = 0.5 * llm + 0.3 * fb + 0.2 * rule
2269
  avg_confidence = float(component_rows.avg_conf or 0.0)
2270
- source = "rolling_v2"
2271
  else:
2272
  # Last-ditch fallback: legacy DailySentiment
2273
  legacy = (
@@ -2277,7 +2289,7 @@ async def get_sentiment_summary(
2277
  )
2278
  if legacy is not None:
2279
  index_val = float(legacy.sentiment_index or 0.0)
2280
- source = "legacy_v1"
2281
 
2282
  # ---- 4) Build trend series for sparkline ----
2283
  trend_7d = [
 
190
 
191
  if snapshot is None:
192
  # No snapshot at all - return minimal response for UI compatibility
193
+ from app.state_machines import ModelServingState, SnapshotQualityState
194
  logger.warning(f"No snapshot found for {symbol}")
195
  return {
196
  "symbol": symbol,
197
+ "quality_state": SnapshotQualityState.MISSING.value,
198
+ "model_state": ModelServingState.OFFLINE.value,
199
  "current_price": 0.0,
200
  "predicted_return": 0.0,
201
  "predicted_price": 0.0,
 
213
  "message": "No analysis available. Pipeline may not have run yet.",
214
  }
215
 
216
+ # Calculate snapshot age and state via explicit state machine
217
+ from app.state_machines import evaluate_xgb_snapshot_state
218
  now = datetime.now(timezone.utc)
219
  generated_at = snapshot.generated_at
220
  if generated_at.tzinfo is None:
221
  generated_at = generated_at.replace(tzinfo=timezone.utc)
222
+ snapshot_eval = evaluate_xgb_snapshot_state(
223
+ generated_at=generated_at,
224
+ now=now,
225
+ stale_threshold_hours=STALE_THRESHOLD_HOURS,
226
+ )
227
+ age_hours = snapshot_eval.age_hours
228
+ quality_state = snapshot_eval.quality_state
 
229
 
230
  # Build response from snapshot
231
  report = snapshot.report_json.copy() if snapshot.report_json else {}
232
 
233
  # Add/override metadata
234
  report["quality_state"] = quality_state
235
+ report["model_state"] = snapshot_eval.model_state
236
  report["snapshot_age_hours"] = round(age_hours, 1)
237
  report["generated_at"] = generated_at.isoformat()
238
 
 
487
  .first()
488
  )
489
  if latest_run is not None:
490
+ from app.state_machines import map_pipeline_status_to_external
491
  ended = latest_run.run_completed_at or latest_run.run_started_at
492
  last_pipeline_run_at = _iso(ended)
493
+ last_pipeline_status = map_pipeline_status_to_external(latest_run.status)
 
 
 
 
 
 
 
 
 
 
494
 
495
  # --- XGBoost snapshot age ---------------------------------------
496
  latest_snapshot = (
 
749
  ) -> Response:
750
  """Serialize one snapshot once and attach cache/performance diagnostics."""
751
  from app.heatmap import flatten_heatmap_leaves, hierarchy_for_view
752
+ from app.state_machines import resolve_heatmap_external_state
753
 
754
  now = datetime.now(timezone.utc)
755
  hierarchy_started = time.perf_counter()
756
  payload = hierarchy_for_view(persisted, view)
757
  hierarchy_ms = (time.perf_counter() - hierarchy_started) * 1000
758
  rendered_payload_count = len(flatten_heatmap_leaves(payload))
759
+ cache_state = resolve_heatmap_external_state(
760
+ payload_count=payload_count,
761
+ is_stale=is_stale,
762
+ refresh_in_progress=refresh_in_progress,
763
+ ).value
764
  if cached_at and cached_at.tzinfo is None:
765
  cached_at = cached_at.replace(tzinfo=timezone.utc)
766
  if expires_at and expires_at.tzinfo is None:
 
846
  session.add(cache)
847
  session.flush()
848
 
849
+ from app.state_machines import evaluate_heatmap_cache_state
850
+
851
  persisted = cache.payload_json if isinstance(cache.payload_json, dict) else {}
852
  persisted_leaves = flatten_heatmap_leaves(persisted)
853
  payload_count = len(persisted_leaves)
854
  needs_enrichment = any(not leaf.get("instrumentType") for leaf in persisted_leaves)
855
+ decision = evaluate_heatmap_cache_state(
856
+ payload_count=payload_count,
857
+ needs_enrichment=needs_enrichment,
858
+ expires_at=cache.expires_at,
859
+ refresh_started_at=cache.refresh_started_at,
860
+ refresh_error=cache.refresh_error,
861
+ now=now,
862
+ )
863
+ if decision.stuck_lock_expired:
864
+ logger.warning("Heatmap refresh appears stuck; clearing in-flight marker")
865
+ cache.refresh_started_at = None
866
 
867
  expires_at = cache.expires_at
868
  if expires_at and expires_at.tzinfo is None:
869
  expires_at = expires_at.replace(tzinfo=timezone.utc)
870
+ is_stale = decision.is_stale
871
+ refresh_in_progress = decision.refresh_in_progress
872
+ if decision.should_trigger_refresh:
873
  # Persist before enqueueing so concurrent empty-cache requests do
874
  # not each schedule a provider refresh.
875
  cache.refresh_started_at = now
876
  session.commit()
877
  background_tasks.add_task(refresh_market_heatmap)
 
878
  elif session.dirty or session.new:
879
  session.commit()
880
  db_ms = (time.perf_counter() - db_started) * 1000
 
1033
  import websockets
1034
  import yfinance as yf
1035
 
1036
+ from app.state_machines import LivePriceWsPhase, LivePriceWsStateMachine
1037
+
1038
  await websocket.accept()
1039
+ ws_machine = LivePriceWsStateMachine()
1040
  canonical_symbol = canonicalize_instrument_symbol(symbol)
1041
  yahoo_symbol = resolve_provider_symbol(canonical_symbol, "yahoo_websocket")
1042
  yahoo_ws_url = "wss://streamer.finance.yahoo.com/?version=2"
 
1072
 
1073
  price = await asyncio.to_thread(read_price)
1074
  if price is not None:
1075
+ ws_machine.transition(LivePriceWsPhase.INITIAL_SNAPSHOT)
1076
  await send_payload(
1077
  {
1078
  "price": round(float(price), 4),
 
1099
  close_timeout=5,
1100
  ) as yahoo_ws:
1101
  await yahoo_ws.send(json.dumps({"subscribe": [yahoo_symbol]}))
1102
+ ws_machine.transition(LivePriceWsPhase.SUBSCRIBED)
1103
  await send_payload(
1104
  {
1105
+ "status": LivePriceWsPhase.SUBSCRIBED.value,
1106
  "source": "yahoo_finance_websocket",
1107
  "error": None,
1108
  }
 
1122
  if price is None:
1123
  continue
1124
 
1125
+ ws_machine.transition(LivePriceWsPhase.STREAMING)
1126
  await send_payload(
1127
  {
1128
  "price": price,
 
1144
  except WebSocketDisconnect:
1145
  raise
1146
  except Exception as e:
1147
+ ws_machine.transition(LivePriceWsPhase.RECONNECTING)
1148
  logger.warning("Yahoo websocket stream interrupted for %s: %s", yahoo_symbol, e)
1149
  try:
1150
  await send_payload(
1151
  {
1152
+ "status": LivePriceWsPhase.RECONNECTING.value,
1153
  "source": "yahoo_finance_websocket",
1154
  "error": "stream reconnecting",
1155
  }
 
1344
  design (there is nothing newer to compare against).
1345
  """
1346
  from app.models import TFTModelMetadata
1347
+ from app.state_machines import evaluate_tft_snapshot_freshness
1348
 
 
1349
  gen_at = _as_utc(snapshot.generated_at)
1350
 
1351
  # Only a quality-gate-passed model should invalidate the snapshot. A
 
1361
  .first()
1362
  )
1363
  trained_at = _as_utc(model_meta.trained_at) if model_meta else None
 
 
 
 
 
1364
 
1365
  payload = snapshot.payload_json if isinstance(snapshot.payload_json, dict) else {}
1366
  prediction = (
 
1375
  ref_date = _parse_ref_date(reference_price_date)
1376
  if latest_price_date is None:
1377
  latest_price_date = _latest_price_bar_date(session, symbol)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1378
 
1379
+ return evaluate_tft_snapshot_freshness(
1380
+ snapshot_generated_at=gen_at,
1381
+ promoted_model_trained_at=trained_at,
1382
+ reference_price_date_raw=reference_price_date,
1383
+ parsed_ref_date=ref_date,
1384
+ latest_price_date=latest_price_date,
1385
+ today_utc=datetime.now(timezone.utc).date(),
1386
+ )
1387
 
1388
 
1389
  @app.get(
 
1678
 
1679
  try:
1680
  from adapters.queue.jobs import enqueue_pipeline_job
1681
+ from app.state_machines import (
1682
+ PipelineRunEvent,
1683
+ PipelineRunStatus,
1684
+ transition_pipeline_run,
1685
+ )
1686
  import uuid
1687
 
1688
  now = datetime.now(timezone.utc)
 
1690
  orphan_cutoff = now - timedelta(minutes=get_settings().pipeline_orphan_timeout_minutes)
1691
  with SessionLocal() as session:
1692
  orphans = session.query(PipelineRunMetrics).filter(
1693
+ PipelineRunMetrics.status.in_((
1694
+ PipelineRunStatus.QUEUED.value,
1695
+ PipelineRunStatus.RUNNING.value,
1696
+ )),
1697
  PipelineRunMetrics.run_started_at < orphan_cutoff,
1698
  ).all()
1699
  for orphan in orphans:
1700
+ transition_pipeline_run(
1701
+ orphan,
1702
+ PipelineRunEvent.EXPIRE_ORPHAN,
1703
+ now=now,
1704
+ error_message="worker_interrupted",
 
 
 
 
 
 
 
 
1705
  )
1706
+ queued_metrics = PipelineRunMetrics(
1707
+ run_id=run_id,
1708
+ run_started_at=now,
1709
+ enqueued_at=now,
1710
+ trigger_source=trigger_source,
1711
+ train_model_requested=train_model,
1712
  )
1713
+ transition_pipeline_run(
1714
+ queued_metrics,
1715
+ PipelineRunEvent.ENQUEUE,
1716
+ now=now,
1717
+ )
1718
+ session.add(queued_metrics)
1719
  session.commit()
1720
 
1721
  result = await enqueue_pipeline_job(
 
1743
  except Exception as e:
1744
  logger.error(f"Failed to enqueue pipeline job: {e}")
1745
  if "run_id" in locals():
1746
+ from app.state_machines import PipelineRunEvent, transition_pipeline_run
1747
  with SessionLocal() as session:
1748
  queued = session.query(PipelineRunMetrics).filter(PipelineRunMetrics.run_id == run_id).first()
1749
  if queued is not None:
1750
+ transition_pipeline_run(
1751
+ queued,
1752
+ PipelineRunEvent.ENQUEUE_FAILED,
1753
+ now=datetime.now(timezone.utc),
1754
+ error_message=f"enqueue_failed: {str(e)[:800]}",
1755
+ )
1756
  session.commit()
1757
  raise HTTPException(
1758
  status_code=503,
 
1768
  run_id: str,
1769
  _auth: None = Depends(verify_pipeline_secret),
1770
  ):
1771
+ from app.state_machines import (
1772
+ PipelineRunEvent,
1773
+ PipelineRunStatus,
1774
+ transition_pipeline_run,
1775
+ )
1776
+
1777
  with SessionLocal() as session:
1778
  record = session.query(PipelineRunMetrics).filter(PipelineRunMetrics.run_id == run_id).first()
1779
  if record is None:
 
1787
  # for the worker's startup/lock-acquisition window and only fail it
1788
  # when PostgreSQL confirms that no worker owns the global lock.
1789
  if (
1790
+ record.status == PipelineRunStatus.RUNNING.value
1791
  and record.worker_started_at is not None
1792
  and get_db_type() == "postgresql"
1793
  ):
 
1811
 
1812
  if not lock_held:
1813
  completed_at = datetime.now(timezone.utc)
1814
+ transition_pipeline_run(
1815
+ record,
1816
+ PipelineRunEvent.LOST_LOCK,
1817
+ now=completed_at,
1818
+ error_message="worker_lost_lock_before_finalizing",
1819
+ )
1820
  record.duration_seconds = run_age.total_seconds()
1821
  session.commit()
1822
  logger.error(
 
2262
  )
2263
 
2264
  # ---- 3) Pick the freshest possible index ----
2265
+ from app.state_machines import SentimentSummarySource
2266
  index_val: float = 0.0
2267
+ source = SentimentSummarySource.NONE.value
2268
  avg_confidence: Optional[float] = None
2269
 
2270
  if v2_rows:
2271
  latest_v2 = v2_rows[-1]
2272
  index_val = float(latest_v2.sentiment_index or 0.0)
2273
  avg_confidence = float(latest_v2.avg_confidence or 0.0) if latest_v2.avg_confidence is not None else None
2274
+ source = SentimentSummarySource.DAILY_V2.value
2275
  elif component_rows and component_rows.n and component_rows.n > 0:
2276
  # No daily aggregate yet — fall back to rolling per-article avg
2277
  llm = float(component_rows.avg_llm or 0.0)
 
2279
  rule = float(component_rows.avg_rule or 0.0)
2280
  index_val = 0.5 * llm + 0.3 * fb + 0.2 * rule
2281
  avg_confidence = float(component_rows.avg_conf or 0.0)
2282
+ source = SentimentSummarySource.ROLLING_V2.value
2283
  else:
2284
  # Last-ditch fallback: legacy DailySentiment
2285
  legacy = (
 
2289
  )
2290
  if legacy is not None:
2291
  index_val = float(legacy.sentiment_index or 0.0)
2292
+ source = SentimentSummarySource.LEGACY_V1.value
2293
 
2294
  # ---- 4) Build trend series for sparkline ----
2295
  trend_7d = [
app/openrouter_client.py CHANGED
@@ -333,6 +333,11 @@ async def create_chat_completion(
333
  key_slot,
334
  )
335
 
 
 
 
 
 
336
  status = response.status_code if response is not None else None
337
  no_upstreams = response is not None and _is_no_upstreams_available(response.status_code, response.text[:1000])
338
  retryable = last_error is not None and last_error.category in retryable_categories and not no_upstreams
@@ -343,6 +348,13 @@ async def create_chat_completion(
343
  and last_error.category == "timeout"
344
  and len(models) > 1
345
  )
 
 
 
 
 
 
 
346
  will_retry = (
347
  retryable
348
  and not skip_same_model_timeout_retry
@@ -350,12 +362,20 @@ async def create_chat_completion(
350
  and not (
351
  last_error
352
  and last_error.category == "rate_limit"
353
- and key_index < len(configured_keys) - 1
354
  )
355
  and attempt_index < max(0, int(max_retries))
356
  )
 
 
 
 
 
 
 
 
357
  attempts.append(
358
- _attempt(candidate, attempt_index + 1, last_error.category if last_error else "network", status, will_retry)
359
  | {"key_slot": key_slot}
360
  | response_details
361
  )
@@ -364,16 +384,16 @@ async def create_chat_completion(
364
  raise last_error
365
  if daily_quota_exhausted:
366
  break
367
- if last_error and last_error.category in key_rotation_categories and key_index < len(configured_keys) - 1:
368
  logger.warning(
369
  "OpenRouter key quota/spend limit reached; advancing to backup key model=%s category=%s key_slot=%s",
370
  candidate,
371
- last_error.category,
372
  key_slot,
373
  )
374
  rotate_key = True
375
  break
376
- if will_retry:
377
  retry_after = _parse_retry_after_seconds(response) if response is not None else None
378
  delay = min(retry_after if retry_after is not None else (2 ** attempt_index) + random.uniform(0.0, 0.5), 30.0)
379
  remaining = max(deadline - time.monotonic(), 0.0)
 
333
  key_slot,
334
  )
335
 
336
+ from app.state_machines import (
337
+ TransportAction,
338
+ decide_openrouter_transport_action,
339
+ )
340
+
341
  status = response.status_code if response is not None else None
342
  no_upstreams = response is not None and _is_no_upstreams_available(response.status_code, response.text[:1000])
343
  retryable = last_error is not None and last_error.category in retryable_categories and not no_upstreams
 
348
  and last_error.category == "timeout"
349
  and len(models) > 1
350
  )
351
+ has_more_keys = key_index < len(configured_keys) - 1
352
+ should_rotate_key = bool(
353
+ last_error is not None
354
+ and last_error.category in key_rotation_categories
355
+ and has_more_keys
356
+ and not daily_quota_exhausted
357
+ )
358
  will_retry = (
359
  retryable
360
  and not skip_same_model_timeout_retry
 
362
  and not (
363
  last_error
364
  and last_error.category == "rate_limit"
365
+ and has_more_keys
366
  )
367
  and attempt_index < max(0, int(max_retries))
368
  )
369
+ action = decide_openrouter_transport_action(
370
+ category=last_error.category if last_error else "network",
371
+ rotate_key=should_rotate_key,
372
+ no_upstreams=no_upstreams,
373
+ skip_same_model_timeout_retry=skip_same_model_timeout_retry,
374
+ has_more_attempts=will_retry,
375
+ has_more_keys=has_more_keys,
376
+ )
377
  attempts.append(
378
+ _attempt(candidate, attempt_index + 1, last_error.category if last_error else "network", status, action == TransportAction.RETRY_SAME_KEY)
379
  | {"key_slot": key_slot}
380
  | response_details
381
  )
 
384
  raise last_error
385
  if daily_quota_exhausted:
386
  break
387
+ if action == TransportAction.ROTATE_KEY:
388
  logger.warning(
389
  "OpenRouter key quota/spend limit reached; advancing to backup key model=%s category=%s key_slot=%s",
390
  candidate,
391
+ last_error.category if last_error else "unknown",
392
  key_slot,
393
  )
394
  rotate_key = True
395
  break
396
+ if action == TransportAction.RETRY_SAME_KEY:
397
  retry_after = _parse_retry_after_seconds(response) if response is not None else None
398
  delay = min(retry_after if retry_after is not None else (2 ** attempt_index) + random.uniform(0.0, 0.5), 30.0)
399
  remaining = max(deadline - time.monotonic(), 0.0)
app/state_machines.py ADDED
@@ -0,0 +1,693 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Explicit finite state machines for CopperMind backend lifecycles and protocols.
3
+
4
+ Centralizes state definitions, transition tables, and pure transition reducers
5
+ that previously lived as implicit combinations of string literals, nullable
6
+ timestamps, boolean flags, and module-level globals across the backend.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from datetime import date, datetime, timedelta, timezone
13
+ from enum import StrEnum
14
+ from typing import Any, Optional
15
+
16
+
17
+ # =============================================================================
18
+ # 1. Pipeline Run Lifecycle & Health Projection State Machine
19
+ # =============================================================================
20
+
21
+
22
+ class PipelineRunStatus(StrEnum):
23
+ QUEUED = "queued"
24
+ RUNNING = "running"
25
+ SKIPPED_LOCKED = "skipped_locked"
26
+ SUCCESS = "success"
27
+ DEGRADED = "degraded"
28
+ FAILED = "failed"
29
+
30
+
31
+ class PipelineQualityState(StrEnum):
32
+ QUEUED = "queued"
33
+ OK = "ok"
34
+ STALE = "stale"
35
+ DEGRADED = "degraded"
36
+ SKIPPED = "skipped"
37
+ FAILED = "failed"
38
+
39
+
40
+ class PipelineRunEvent(StrEnum):
41
+ ENQUEUE = "enqueue"
42
+ ENQUEUE_FAILED = "enqueue_failed"
43
+ WORKER_START = "worker_start"
44
+ SKIP_LOCKED = "skip_locked"
45
+ COMPLETE_SUCCESS = "complete_success"
46
+ COMPLETE_DEGRADED = "complete_degraded"
47
+ COMPLETE_FAILED = "complete_failed"
48
+ EXPIRE_ORPHAN = "expire_orphan"
49
+ LOST_LOCK = "lost_lock"
50
+
51
+
52
+ VALID_PIPELINE_TRANSITIONS: dict[
53
+ tuple[Optional[PipelineRunStatus], PipelineRunEvent],
54
+ tuple[PipelineRunStatus, PipelineQualityState],
55
+ ] = {
56
+ (None, PipelineRunEvent.ENQUEUE): (
57
+ PipelineRunStatus.QUEUED,
58
+ PipelineQualityState.QUEUED,
59
+ ),
60
+ (PipelineRunStatus.QUEUED, PipelineRunEvent.ENQUEUE_FAILED): (
61
+ PipelineRunStatus.FAILED,
62
+ PipelineQualityState.FAILED,
63
+ ),
64
+ (None, PipelineRunEvent.WORKER_START): (
65
+ PipelineRunStatus.RUNNING,
66
+ PipelineQualityState.QUEUED,
67
+ ),
68
+ (PipelineRunStatus.QUEUED, PipelineRunEvent.WORKER_START): (
69
+ PipelineRunStatus.RUNNING,
70
+ PipelineQualityState.QUEUED,
71
+ ),
72
+ (PipelineRunStatus.RUNNING, PipelineRunEvent.WORKER_START): (
73
+ PipelineRunStatus.RUNNING,
74
+ PipelineQualityState.QUEUED,
75
+ ),
76
+ (PipelineRunStatus.QUEUED, PipelineRunEvent.SKIP_LOCKED): (
77
+ PipelineRunStatus.SKIPPED_LOCKED,
78
+ PipelineQualityState.SKIPPED,
79
+ ),
80
+ (PipelineRunStatus.RUNNING, PipelineRunEvent.SKIP_LOCKED): (
81
+ PipelineRunStatus.SKIPPED_LOCKED,
82
+ PipelineQualityState.SKIPPED,
83
+ ),
84
+ (PipelineRunStatus.RUNNING, PipelineRunEvent.COMPLETE_SUCCESS): (
85
+ PipelineRunStatus.SUCCESS,
86
+ PipelineQualityState.OK,
87
+ ),
88
+ (PipelineRunStatus.RUNNING, PipelineRunEvent.COMPLETE_DEGRADED): (
89
+ PipelineRunStatus.DEGRADED,
90
+ PipelineQualityState.DEGRADED,
91
+ ),
92
+ (PipelineRunStatus.QUEUED, PipelineRunEvent.COMPLETE_FAILED): (
93
+ PipelineRunStatus.FAILED,
94
+ PipelineQualityState.FAILED,
95
+ ),
96
+ (PipelineRunStatus.RUNNING, PipelineRunEvent.COMPLETE_FAILED): (
97
+ PipelineRunStatus.FAILED,
98
+ PipelineQualityState.FAILED,
99
+ ),
100
+ (PipelineRunStatus.QUEUED, PipelineRunEvent.EXPIRE_ORPHAN): (
101
+ PipelineRunStatus.FAILED,
102
+ PipelineQualityState.FAILED,
103
+ ),
104
+ (PipelineRunStatus.RUNNING, PipelineRunEvent.EXPIRE_ORPHAN): (
105
+ PipelineRunStatus.FAILED,
106
+ PipelineQualityState.FAILED,
107
+ ),
108
+ (PipelineRunStatus.RUNNING, PipelineRunEvent.LOST_LOCK): (
109
+ PipelineRunStatus.FAILED,
110
+ PipelineQualityState.FAILED,
111
+ ),
112
+ }
113
+
114
+
115
+ TERMINAL_PIPELINE_STATUSES: frozenset[PipelineRunStatus] = frozenset(
116
+ {
117
+ PipelineRunStatus.SKIPPED_LOCKED,
118
+ PipelineRunStatus.SUCCESS,
119
+ PipelineRunStatus.DEGRADED,
120
+ PipelineRunStatus.FAILED,
121
+ }
122
+ )
123
+
124
+
125
+ def transition_pipeline_run(
126
+ metrics: Any,
127
+ event: PipelineRunEvent,
128
+ *,
129
+ now: Optional[datetime] = None,
130
+ error_message: Optional[str] = None,
131
+ quality_state_override: Optional[str] = None,
132
+ job_id: Optional[str] = None,
133
+ ) -> tuple[PipelineRunStatus, PipelineQualityState]:
134
+ """
135
+ Validate and apply an explicit state transition onto a PipelineRunMetrics row.
136
+ """
137
+ ts = now or datetime.now(timezone.utc)
138
+ raw_current = getattr(metrics, "status", None)
139
+ current_status: Optional[PipelineRunStatus] = None
140
+ if raw_current is not None:
141
+ try:
142
+ current_status = PipelineRunStatus(str(raw_current))
143
+ except ValueError:
144
+ current_status = None
145
+
146
+ transition_key = (current_status, event)
147
+ if transition_key not in VALID_PIPELINE_TRANSITIONS:
148
+ fallback_map: dict[PipelineRunEvent, tuple[PipelineRunStatus, PipelineQualityState]] = {
149
+ PipelineRunEvent.ENQUEUE: (PipelineRunStatus.QUEUED, PipelineQualityState.QUEUED),
150
+ PipelineRunEvent.ENQUEUE_FAILED: (PipelineRunStatus.FAILED, PipelineQualityState.FAILED),
151
+ PipelineRunEvent.WORKER_START: (PipelineRunStatus.RUNNING, PipelineQualityState.QUEUED),
152
+ PipelineRunEvent.SKIP_LOCKED: (PipelineRunStatus.SKIPPED_LOCKED, PipelineQualityState.SKIPPED),
153
+ PipelineRunEvent.COMPLETE_SUCCESS: (PipelineRunStatus.SUCCESS, PipelineQualityState.OK),
154
+ PipelineRunEvent.COMPLETE_DEGRADED: (PipelineRunStatus.DEGRADED, PipelineQualityState.DEGRADED),
155
+ PipelineRunEvent.COMPLETE_FAILED: (PipelineRunStatus.FAILED, PipelineQualityState.FAILED),
156
+ PipelineRunEvent.EXPIRE_ORPHAN: (PipelineRunStatus.FAILED, PipelineQualityState.FAILED),
157
+ PipelineRunEvent.LOST_LOCK: (PipelineRunStatus.FAILED, PipelineQualityState.FAILED),
158
+ }
159
+ next_status, next_quality = fallback_map[event]
160
+ else:
161
+ next_status, next_quality = VALID_PIPELINE_TRANSITIONS[transition_key]
162
+
163
+ if quality_state_override is not None:
164
+ try:
165
+ next_quality = PipelineQualityState(str(quality_state_override))
166
+ except ValueError:
167
+ pass
168
+
169
+ metrics.status = next_status.value
170
+ metrics.quality_state = next_quality.value
171
+
172
+ if job_id is not None:
173
+ metrics.job_id = job_id
174
+
175
+ if event == PipelineRunEvent.ENQUEUE:
176
+ if getattr(metrics, "enqueued_at", None) is None:
177
+ metrics.enqueued_at = ts
178
+ if getattr(metrics, "run_started_at", None) is None:
179
+ metrics.run_started_at = ts
180
+ elif event == PipelineRunEvent.WORKER_START:
181
+ metrics.run_started_at = ts
182
+ metrics.worker_started_at = ts
183
+ elif next_status in TERMINAL_PIPELINE_STATUSES:
184
+ metrics.run_completed_at = ts
185
+ started_at = getattr(metrics, "run_started_at", None)
186
+ if started_at is not None:
187
+ norm_start = started_at if started_at.tzinfo is not None else started_at.replace(tzinfo=timezone.utc)
188
+ metrics.duration_seconds = (ts - norm_start).total_seconds()
189
+
190
+ if error_message is not None:
191
+ metrics.error_message = error_message
192
+
193
+ return next_status, next_quality
194
+
195
+
196
+ def map_pipeline_status_to_external(raw_status: Optional[str]) -> Optional[str]:
197
+ """
198
+ Map internal PipelineRunMetrics.status to external health `pipeline_status`.
199
+ """
200
+ normalized = (raw_status or "").strip().lower()
201
+ if not normalized:
202
+ return None
203
+ if normalized == PipelineRunStatus.SUCCESS.value:
204
+ return PipelineQualityState.OK.value
205
+ return normalized
206
+
207
+
208
+ # =============================================================================
209
+ # 2. Market Heatmap Cache SWR & Metadata Circuit Breaker State Machines
210
+ # =============================================================================
211
+
212
+
213
+ class HeatmapExternalState(StrEnum):
214
+ EMPTY = "empty"
215
+ REFRESHING = "refreshing"
216
+ FRESH = "fresh"
217
+ STALE = "stale"
218
+
219
+
220
+ class HeatmapCachePhase(StrEnum):
221
+ EMPTY = "empty"
222
+ REFRESHING_EMPTY = "refreshing_empty"
223
+ FRESH = "fresh"
224
+ STALE_REVALIDATING = "stale_revalidating"
225
+ STALE_REFRESHING = "stale_refreshing"
226
+ RETRY_BACKOFF = "retry_backoff"
227
+
228
+
229
+ @dataclass(frozen=True)
230
+ class HeatmapCacheDecision:
231
+ external_state: HeatmapExternalState
232
+ phase: HeatmapCachePhase
233
+ is_stale: bool
234
+ stuck_lock_expired: bool
235
+ refresh_in_progress: bool
236
+ retry_backoff: bool
237
+ should_trigger_refresh: bool
238
+
239
+
240
+ def resolve_heatmap_external_state(
241
+ *,
242
+ payload_count: int,
243
+ is_stale: bool,
244
+ refresh_in_progress: bool,
245
+ ) -> HeatmapExternalState:
246
+ """
247
+ Resolve the external `cache_state` / `X-Heatmap-Cache` value from SWR flags.
248
+ """
249
+ if payload_count == 0:
250
+ return HeatmapExternalState.EMPTY
251
+ if refresh_in_progress:
252
+ return HeatmapExternalState.REFRESHING
253
+ if is_stale:
254
+ return HeatmapExternalState.STALE
255
+ return HeatmapExternalState.FRESH
256
+
257
+
258
+ def evaluate_heatmap_cache_state(
259
+ *,
260
+ payload_count: int,
261
+ needs_enrichment: bool = False,
262
+ expires_at: Optional[datetime],
263
+ refresh_started_at: Optional[datetime],
264
+ refresh_error: Optional[str],
265
+ now: datetime,
266
+ stuck_timeout_seconds: float = 180.0,
267
+ ) -> HeatmapCacheDecision:
268
+ """
269
+ Pure state machine reducer for HeatmapCache Stale-While-Revalidate (SWR) lifecycle.
270
+ """
271
+ norm_expires = expires_at
272
+ if norm_expires is not None and norm_expires.tzinfo is None:
273
+ norm_expires = norm_expires.replace(tzinfo=timezone.utc)
274
+
275
+ norm_started = refresh_started_at
276
+ if norm_started is not None and norm_started.tzinfo is None:
277
+ norm_started = norm_started.replace(tzinfo=timezone.utc)
278
+
279
+ stuck_lock_expired = bool(
280
+ norm_started is not None
281
+ and (now - norm_started).total_seconds() > stuck_timeout_seconds
282
+ )
283
+ refresh_in_progress = bool(norm_started is not None and not stuck_lock_expired)
284
+ retry_backoff = bool(
285
+ refresh_error and norm_expires is not None and now < norm_expires
286
+ )
287
+ is_stale = bool(
288
+ not norm_expires
289
+ or now >= norm_expires
290
+ or needs_enrichment
291
+ or bool(refresh_error)
292
+ )
293
+ should_trigger_refresh = bool(
294
+ (payload_count == 0 or is_stale)
295
+ and not refresh_in_progress
296
+ and not retry_backoff
297
+ )
298
+ effective_refresh_in_progress = refresh_in_progress or should_trigger_refresh
299
+
300
+ external_state = resolve_heatmap_external_state(
301
+ payload_count=payload_count,
302
+ is_stale=is_stale,
303
+ refresh_in_progress=effective_refresh_in_progress,
304
+ )
305
+
306
+ if payload_count == 0:
307
+ phase = (
308
+ HeatmapCachePhase.REFRESHING_EMPTY
309
+ if effective_refresh_in_progress
310
+ else (
311
+ HeatmapCachePhase.RETRY_BACKOFF
312
+ if retry_backoff
313
+ else HeatmapCachePhase.EMPTY
314
+ )
315
+ )
316
+ elif retry_backoff:
317
+ phase = HeatmapCachePhase.RETRY_BACKOFF
318
+ elif effective_refresh_in_progress:
319
+ phase = HeatmapCachePhase.STALE_REFRESHING
320
+ elif is_stale:
321
+ phase = HeatmapCachePhase.STALE_REVALIDATING
322
+ else:
323
+ phase = HeatmapCachePhase.FRESH
324
+
325
+ return HeatmapCacheDecision(
326
+ external_state=external_state,
327
+ phase=phase,
328
+ is_stale=is_stale,
329
+ stuck_lock_expired=stuck_lock_expired,
330
+ refresh_in_progress=effective_refresh_in_progress,
331
+ retry_backoff=retry_backoff,
332
+ should_trigger_refresh=should_trigger_refresh,
333
+ )
334
+
335
+
336
+ class HeatmapMetadataCircuitState(StrEnum):
337
+ CLOSED = "closed"
338
+ OPEN_RATE_LIMITED = "open_rate_limited"
339
+ OPEN_PROVIDER_ERROR = "open_provider_error"
340
+
341
+
342
+ @dataclass
343
+ class HeatmapMetadataCircuitBreaker:
344
+ """
345
+ Explicit circuit breaker for per-ticker yfinance metadata enrichment inside heatmap refreshes.
346
+ """
347
+
348
+ state: HeatmapMetadataCircuitState = HeatmapMetadataCircuitState.CLOSED
349
+ error_message: Optional[str] = None
350
+
351
+ @property
352
+ def is_open(self) -> bool:
353
+ return self.state == HeatmapMetadataCircuitState.OPEN_RATE_LIMITED
354
+
355
+ @property
356
+ def is_rate_limited(self) -> bool:
357
+ return self.state == HeatmapMetadataCircuitState.OPEN_RATE_LIMITED
358
+
359
+ def trip_rate_limit(self, message: str) -> None:
360
+ self.state = HeatmapMetadataCircuitState.OPEN_RATE_LIMITED
361
+ if not self.error_message:
362
+ self.error_message = message
363
+
364
+ def record_provider_error(self, message: str) -> None:
365
+ if self.state == HeatmapMetadataCircuitState.CLOSED:
366
+ self.state = HeatmapMetadataCircuitState.OPEN_PROVIDER_ERROR
367
+ if not self.error_message:
368
+ self.error_message = message
369
+
370
+ def select_backoff(
371
+ self,
372
+ *,
373
+ rate_limit_backoff: timedelta,
374
+ provider_backoff: timedelta,
375
+ ) -> timedelta:
376
+ if self.state == HeatmapMetadataCircuitState.OPEN_RATE_LIMITED:
377
+ return rate_limit_backoff
378
+ return provider_backoff
379
+
380
+
381
+ # =============================================================================
382
+ # 3. Dual-Model Artifact Promotion & TFT Predictor Loader State Machine
383
+ # =============================================================================
384
+
385
+
386
+ class XGBArtifactStatus(StrEnum):
387
+ CANDIDATE = "candidate"
388
+ ACTIVE = "active"
389
+ ARCHIVED = "archived"
390
+
391
+
392
+ class TFTCalibrationStatus(StrEnum):
393
+ FIT = "fit"
394
+ SKIPPED_OVERCOVERED = "skipped_interval_already_overcovered"
395
+ FIT_WIDTH_FLOOR = "fit_width_floor"
396
+
397
+
398
+ class TFTPredictorLifecycleState(StrEnum):
399
+ UNINITIALIZED = "uninitialized"
400
+ ARTIFACTS_READY = "artifacts_ready"
401
+ MODEL_LOADED = "model_loaded"
402
+ DEGRADED = "degraded"
403
+
404
+
405
+ @dataclass
406
+ class TFTPredictorLifecycleMachine:
407
+ """
408
+ Explicit lifecycle state machine for TFTPredictor lazy artifact discovery and loading.
409
+ """
410
+
411
+ state: TFTPredictorLifecycleState = TFTPredictorLifecycleState.UNINITIALIZED
412
+ hub_checked: bool = False
413
+ metadata_checked: bool = False
414
+ artifact_issue: Optional[str] = None
415
+
416
+ def mark_artifacts_ready(self) -> None:
417
+ self.artifact_issue = None
418
+ if self.state != TFTPredictorLifecycleState.MODEL_LOADED:
419
+ self.state = TFTPredictorLifecycleState.ARTIFACTS_READY
420
+
421
+ def mark_model_loaded(self) -> None:
422
+ self.artifact_issue = None
423
+ self.state = TFTPredictorLifecycleState.MODEL_LOADED
424
+
425
+ def mark_degraded(self, issue: str) -> None:
426
+ self.artifact_issue = issue
427
+ self.state = TFTPredictorLifecycleState.DEGRADED
428
+
429
+ def reset_for_reload(self) -> None:
430
+ self.state = TFTPredictorLifecycleState.UNINITIALIZED
431
+ self.artifact_issue = None
432
+
433
+
434
+ # =============================================================================
435
+ # 4. Snapshot Freshness, Cross-Table Validity & Forecast Health State Machine
436
+ # =============================================================================
437
+
438
+
439
+ class SnapshotQualityState(StrEnum):
440
+ OK = "ok"
441
+ STALE = "stale"
442
+ DEGRADED = "degraded"
443
+ MISSING = "missing"
444
+
445
+
446
+ class ModelServingState(StrEnum):
447
+ OK = "ok"
448
+ DEGRADED = "degraded"
449
+ OFFLINE = "offline"
450
+ RETRAIN_REQUIRED = "retrain_required"
451
+
452
+
453
+ @dataclass(frozen=True)
454
+ class XGBSnapshotEvaluation:
455
+ is_stale: bool
456
+ age_hours: float
457
+ quality_state: str
458
+ model_state: str
459
+
460
+
461
+ def evaluate_xgb_snapshot_state(
462
+ *,
463
+ generated_at: datetime,
464
+ now: datetime,
465
+ stale_threshold_hours: float = 36.0,
466
+ ) -> XGBSnapshotEvaluation:
467
+ """
468
+ Pure state machine reducer for XGBoost AnalysisSnapshot freshness and serving state.
469
+ """
470
+ gen_ts = generated_at
471
+ if gen_ts.tzinfo is None:
472
+ gen_ts = gen_ts.replace(tzinfo=timezone.utc)
473
+ age_hours = (now - gen_ts).total_seconds() / 3600.0
474
+ is_stale = age_hours > stale_threshold_hours
475
+
476
+ if is_stale:
477
+ quality_state = SnapshotQualityState.STALE.value
478
+ model_state = ModelServingState.DEGRADED.value
479
+ else:
480
+ quality_state = SnapshotQualityState.OK.value
481
+ model_state = ModelServingState.OK.value
482
+
483
+ return XGBSnapshotEvaluation(
484
+ is_stale=is_stale,
485
+ age_hours=age_hours,
486
+ quality_state=quality_state,
487
+ model_state=model_state,
488
+ )
489
+
490
+
491
+ def evaluate_tft_snapshot_freshness(
492
+ *,
493
+ snapshot_generated_at: Optional[datetime],
494
+ promoted_model_trained_at: Optional[datetime],
495
+ reference_price_date_raw: Optional[str],
496
+ parsed_ref_date: Optional[date],
497
+ latest_price_date: Optional[date],
498
+ today_utc: date,
499
+ max_unanchored_staleness_days: int = 3,
500
+ ) -> list[str]:
501
+ """
502
+ Pure cross-table freshness evaluator for `TFTPredictionSnapshot`.
503
+ """
504
+ reasons: list[str] = []
505
+ if (
506
+ promoted_model_trained_at is not None
507
+ and snapshot_generated_at is not None
508
+ and promoted_model_trained_at > snapshot_generated_at
509
+ ):
510
+ reasons.append(
511
+ "model trained after snapshot "
512
+ f"(trained_at={promoted_model_trained_at.isoformat()} > snapshot_at={snapshot_generated_at.isoformat()})"
513
+ )
514
+
515
+ if parsed_ref_date is None:
516
+ reasons.append("snapshot reference_price_date missing")
517
+ elif latest_price_date is not None and parsed_ref_date != latest_price_date:
518
+ reasons.append(
519
+ "snapshot is not anchored to latest PriceBar "
520
+ f"(snapshot={parsed_ref_date.isoformat()}, "
521
+ f"latest_price_bar={latest_price_date.isoformat()})"
522
+ )
523
+ elif latest_price_date is None:
524
+ staleness_days = (today_utc - parsed_ref_date).days
525
+ if staleness_days >= max_unanchored_staleness_days:
526
+ reasons.append(
527
+ f"reference_price_date stale ({reference_price_date_raw}, {staleness_days}d)"
528
+ )
529
+ return reasons
530
+
531
+
532
+ # =============================================================================
533
+ # 5. OpenRouter Transport, V2 Escalation & Commentary Fallback State Machines
534
+ # =============================================================================
535
+
536
+
537
+ class TransportAction(StrEnum):
538
+ RETRY_SAME_KEY = "retry_same_key"
539
+ ROTATE_KEY = "rotate_key"
540
+ ADVANCE_MODEL = "advance_model"
541
+ FAIL_CHAIN = "fail_chain"
542
+
543
+
544
+ def decide_openrouter_transport_action(
545
+ *,
546
+ category: str,
547
+ rotate_key: bool,
548
+ no_upstreams: bool,
549
+ skip_same_model_timeout_retry: bool,
550
+ has_more_attempts: bool,
551
+ has_more_keys: bool,
552
+ ) -> TransportAction:
553
+ """
554
+ Pure transition reducer for the OpenRouter multi-model, multi-key retry loop.
555
+ """
556
+ if category == "bad_request":
557
+ return TransportAction.ADVANCE_MODEL
558
+ if no_upstreams:
559
+ return TransportAction.ADVANCE_MODEL
560
+ if rotate_key:
561
+ return (
562
+ TransportAction.ROTATE_KEY
563
+ if has_more_keys
564
+ else TransportAction.ADVANCE_MODEL
565
+ )
566
+ if skip_same_model_timeout_retry:
567
+ return TransportAction.ADVANCE_MODEL
568
+ if has_more_attempts:
569
+ return TransportAction.RETRY_SAME_KEY
570
+ if has_more_keys and category == "auth":
571
+ return TransportAction.ROTATE_KEY
572
+ return TransportAction.ADVANCE_MODEL
573
+
574
+
575
+ class V2DecodeStage(StrEnum):
576
+ PRIMARY = "primary"
577
+ LENGTH_RETRY = "length_retry"
578
+ PARSE_RETRY = "parse_retry"
579
+ JSON_REPAIR = "json_repair"
580
+ RESOLVED = "resolved"
581
+ UNRESOLVED = "unresolved"
582
+
583
+
584
+ class V2ArticleRoute(StrEnum):
585
+ FAST_ACCEPTED = "fast_accepted"
586
+ ESCALATE_MISSING = "escalate_missing"
587
+ ESCALATE_CONFLICT = "escalate_conflict"
588
+ RELIABLE_ACCEPTED = "reliable_accepted"
589
+ FINBERT_FALLBACK = "finbert_fallback"
590
+
591
+
592
+ class DailyModelQuotaTracker(dict):
593
+ """
594
+ Explicit per-model UTC daily quota state machine (AVAILABLE vs EXHAUSTED_TODAY).
595
+ Inherits from `dict` (`{model_name: utc_date_iso}`) so legacy callers/tests that
596
+ inspect `_rate_limited_by_model` as a mapping continue to work unchanged.
597
+ """
598
+
599
+ def is_exhausted_today(self, model_name: str, today_utc: str) -> bool:
600
+ return self.get(model_name) == today_utc
601
+
602
+ def are_both_exhausted(
603
+ self, fast_model: str, reliable_model: str, today_utc: str
604
+ ) -> bool:
605
+ return self.is_exhausted_today(
606
+ fast_model, today_utc
607
+ ) and self.is_exhausted_today(reliable_model, today_utc)
608
+
609
+ def mark_exhausted(self, model_name: str, today_utc: str) -> None:
610
+ if model_name:
611
+ self[model_name] = today_utc
612
+
613
+
614
+ class CommentaryGenerationMode(StrEnum):
615
+ IFM_K2 = "ifm_k2"
616
+ LLM = "llm"
617
+ LLM_REPAIRED = "llm_repaired"
618
+ DETERMINISTIC_FALLBACK = "deterministic_fallback"
619
+ UNAVAILABLE = "unavailable"
620
+
621
+
622
+ class SentimentSummarySource(StrEnum):
623
+ DAILY_V2 = "daily_v2"
624
+ ROLLING_V2 = "rolling_v2"
625
+ LEGACY_V1 = "legacy_v1"
626
+ NONE = "none"
627
+
628
+
629
+ # =============================================================================
630
+ # 6. Live Price Provider Circuit Breaker & WebSocket Lifecycle State Machines
631
+ # =============================================================================
632
+
633
+
634
+ class CrumbCircuitState(StrEnum):
635
+ CLOSED = "closed"
636
+ OPEN = "open"
637
+
638
+
639
+ @dataclass
640
+ class YFinanceCrumbCircuitBreaker:
641
+ """
642
+ Explicit circuit breaker guarding `ticker.info` when Yahoo returns 401 / Invalid Crumb.
643
+ """
644
+
645
+ blocked_until: float = 0.0
646
+ cooldown_seconds: float = 900.0
647
+
648
+ def state(self, now_monotonic: float) -> CrumbCircuitState:
649
+ return (
650
+ CrumbCircuitState.OPEN
651
+ if now_monotonic < self.blocked_until
652
+ else CrumbCircuitState.CLOSED
653
+ )
654
+
655
+ def is_open(self, now_monotonic: float) -> bool:
656
+ return self.state(now_monotonic) == CrumbCircuitState.OPEN
657
+
658
+ def trip(self, now_monotonic: float) -> float:
659
+ self.blocked_until = now_monotonic + self.cooldown_seconds
660
+ return self.blocked_until
661
+
662
+ def reset(self) -> None:
663
+ self.blocked_until = 0.0
664
+
665
+
666
+ class LivePriceWsPhase(StrEnum):
667
+ CONNECTED = "connected"
668
+ INITIAL_SNAPSHOT = "initial_snapshot"
669
+ SUBSCRIBED = "subscribed"
670
+ STREAMING = "streaming"
671
+ RECONNECTING = "reconnecting"
672
+ DISCONNECTED = "disconnected"
673
+
674
+
675
+ @dataclass
676
+ class LivePriceWsStateMachine:
677
+ """
678
+ Explicit state machine for `/ws/live-price` connection lifecycle.
679
+ """
680
+
681
+ phase: LivePriceWsPhase = LivePriceWsPhase.CONNECTED
682
+ reconnect_attempts: int = 0
683
+
684
+ def transition(self, next_phase: LivePriceWsPhase) -> LivePriceWsPhase:
685
+ if next_phase == LivePriceWsPhase.RECONNECTING:
686
+ self.reconnect_attempts += 1
687
+ elif next_phase in (
688
+ LivePriceWsPhase.SUBSCRIBED,
689
+ LivePriceWsPhase.STREAMING,
690
+ ):
691
+ self.reconnect_attempts = 0
692
+ self.phase = next_phase
693
+ return self.phase
deep_learning/inference/predictor.py CHANGED
@@ -134,12 +134,13 @@ class TFTPredictor:
134
  checkpoint_path: Optional[str] = None,
135
  cfg: Optional[TFTASROConfig] = None,
136
  ):
 
 
137
  self.cfg = cfg or get_tft_config()
138
  self._checkpoint_path = checkpoint_path or self.cfg.training.best_model_path
139
  self._model = None
140
  self._pca = None
141
- self._hub_checked = False
142
- self._metadata_checked = False
143
  self._direction_sign_multiplier = 1
144
  self._daily_sign_multiplier = 1
145
  self._weekly_sign_threshold = 0.0
@@ -147,6 +148,26 @@ class TFTPredictor:
147
  self._weekly_interval_scale = 1.0
148
  self._weekly_interval_conditioning: dict = {}
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  def _ensure_local_artifacts(self) -> None:
151
  """Download checkpoint from HF Hub if not present locally."""
152
  if self._hub_checked:
@@ -157,6 +178,7 @@ class TFTPredictor:
157
  from deep_learning.models.hub import validate_tft_artifact_set
158
 
159
  if validate_tft_artifact_set(Path(self._checkpoint_path).parent):
 
160
  return
161
  except Exception as exc:
162
  logger.debug("TFT artifact validation skipped before Hub download: %s", exc)
@@ -177,11 +199,14 @@ class TFTPredictor:
177
  repo_id=self.cfg.training.hf_model_repo,
178
  )
179
  if downloaded:
 
180
  logger.info("TFT artifact set downloaded/refreshed from HF Hub")
181
  else:
 
182
  logger.warning("TFT artifact set not available on HF Hub")
183
  return
184
  except Exception as exc:
 
185
  logger.warning("HF Hub download attempt failed: %s", exc)
186
 
187
  @property
@@ -189,12 +214,14 @@ class TFTPredictor:
189
  if self._model is None:
190
  self._ensure_local_artifacts()
191
  if not Path(self._checkpoint_path).exists():
 
192
  raise FileNotFoundError(
193
  f"TFT checkpoint not found: {self._checkpoint_path}"
194
  )
195
  self._validate_checkpoint_contract()
196
  from deep_learning.models.tft_copper import load_tft_model
197
  self._model = load_tft_model(self._checkpoint_path)
 
198
  return self._model
199
 
200
  def _validate_checkpoint_contract(self) -> None:
 
134
  checkpoint_path: Optional[str] = None,
135
  cfg: Optional[TFTASROConfig] = None,
136
  ):
137
+ from app.state_machines import TFTPredictorLifecycleMachine
138
+
139
  self.cfg = cfg or get_tft_config()
140
  self._checkpoint_path = checkpoint_path or self.cfg.training.best_model_path
141
  self._model = None
142
  self._pca = None
143
+ self._lifecycle = TFTPredictorLifecycleMachine()
 
144
  self._direction_sign_multiplier = 1
145
  self._daily_sign_multiplier = 1
146
  self._weekly_sign_threshold = 0.0
 
148
  self._weekly_interval_scale = 1.0
149
  self._weekly_interval_conditioning: dict = {}
150
 
151
+ @property
152
+ def _hub_checked(self) -> bool:
153
+ return self._lifecycle.hub_checked
154
+
155
+ @_hub_checked.setter
156
+ def _hub_checked(self, value: bool) -> None:
157
+ self._lifecycle.hub_checked = bool(value)
158
+
159
+ @property
160
+ def _metadata_checked(self) -> bool:
161
+ return self._lifecycle.metadata_checked
162
+
163
+ @_metadata_checked.setter
164
+ def _metadata_checked(self, value: bool) -> None:
165
+ self._lifecycle.metadata_checked = bool(value)
166
+
167
+ @property
168
+ def lifecycle_state(self) -> str:
169
+ return self._lifecycle.state.value
170
+
171
  def _ensure_local_artifacts(self) -> None:
172
  """Download checkpoint from HF Hub if not present locally."""
173
  if self._hub_checked:
 
178
  from deep_learning.models.hub import validate_tft_artifact_set
179
 
180
  if validate_tft_artifact_set(Path(self._checkpoint_path).parent):
181
+ self._lifecycle.mark_artifacts_ready()
182
  return
183
  except Exception as exc:
184
  logger.debug("TFT artifact validation skipped before Hub download: %s", exc)
 
199
  repo_id=self.cfg.training.hf_model_repo,
200
  )
201
  if downloaded:
202
+ self._lifecycle.mark_artifacts_ready()
203
  logger.info("TFT artifact set downloaded/refreshed from HF Hub")
204
  else:
205
+ self._lifecycle.mark_degraded("hub_artifacts_unavailable")
206
  logger.warning("TFT artifact set not available on HF Hub")
207
  return
208
  except Exception as exc:
209
+ self._lifecycle.mark_degraded(str(exc))
210
  logger.warning("HF Hub download attempt failed: %s", exc)
211
 
212
  @property
 
214
  if self._model is None:
215
  self._ensure_local_artifacts()
216
  if not Path(self._checkpoint_path).exists():
217
+ self._lifecycle.mark_degraded("checkpoint_missing")
218
  raise FileNotFoundError(
219
  f"TFT checkpoint not found: {self._checkpoint_path}"
220
  )
221
  self._validate_checkpoint_contract()
222
  from deep_learning.models.tft_copper import load_tft_model
223
  self._model = load_tft_model(self._checkpoint_path)
224
+ self._lifecycle.mark_model_loaded()
225
  return self._model
226
 
227
  def _validate_checkpoint_contract(self) -> None:
deep_learning/training/trainer.py CHANGED
@@ -1344,10 +1344,12 @@ def _write_conformal_calibration_artifact(
1344
  (target_min_pi80_width - validation_pi80_width) / 2.0,
1345
  )
1346
 
1347
- calibration_status = "fit"
 
 
1348
  if validation_pi80_coverage >= 0.90:
1349
  conformal_adj = 0.0
1350
- calibration_status = "skipped_interval_already_overcovered"
1351
  else:
1352
  conformal_adj = rolling_conformal_adjustment(
1353
  weekly_actual,
@@ -1357,7 +1359,7 @@ def _write_conformal_calibration_artifact(
1357
  )
1358
  global_adj = max(float(conformal_adj), float(width_floor_adjustment))
1359
  if width_floor_adjustment > conformal_adj and width_floor_adjustment > 0.0:
1360
- calibration_status = "fit_width_floor"
1361
  calibrated_lower, calibrated_upper = apply_conformal_interval(
1362
  raw_lower,
1363
  raw_upper,
 
1344
  (target_min_pi80_width - validation_pi80_width) / 2.0,
1345
  )
1346
 
1347
+ from app.state_machines import TFTCalibrationStatus
1348
+
1349
+ calibration_status = TFTCalibrationStatus.FIT.value
1350
  if validation_pi80_coverage >= 0.90:
1351
  conformal_adj = 0.0
1352
+ calibration_status = TFTCalibrationStatus.SKIPPED_OVERCOVERED.value
1353
  else:
1354
  conformal_adj = rolling_conformal_adjustment(
1355
  weekly_actual,
 
1359
  )
1360
  global_adj = max(float(conformal_adj), float(width_floor_adjustment))
1361
  if width_floor_adjustment > conformal_adj and width_floor_adjustment > 0.0:
1362
+ calibration_status = TFTCalibrationStatus.FIT_WIDTH_FLOOR.value
1363
  calibrated_lower, calibrated_upper = apply_conformal_interval(
1364
  raw_lower,
1365
  raw_upper,
worker/tasks.py CHANGED
@@ -58,13 +58,17 @@ def create_run_metrics(
58
  started_at: datetime,
59
  ) -> PipelineRunMetrics:
60
  """Create initial pipeline_run_metrics record."""
 
 
61
  metrics = session.query(PipelineRunMetrics).filter(PipelineRunMetrics.run_id == run_id).first()
62
  if metrics is None:
63
  metrics = PipelineRunMetrics(run_id=run_id, run_started_at=started_at)
64
  session.add(metrics)
65
- metrics.run_started_at = started_at
66
- metrics.worker_started_at = started_at
67
- metrics.status = "running"
 
 
68
  session.flush()
69
  return metrics
70
 
@@ -94,6 +98,12 @@ def finalize_run_metrics(
94
  error_message: Optional[str] = None,
95
  ) -> None:
96
  """Finalize run metrics with completion status."""
 
 
 
 
 
 
97
  completed_at = datetime.now(timezone.utc)
98
 
99
  metrics = session.query(PipelineRunMetrics).filter(
@@ -101,13 +111,20 @@ def finalize_run_metrics(
101
  ).first()
102
 
103
  if metrics:
104
- metrics.run_completed_at = completed_at
105
- metrics.status = status
106
- metrics.quality_state = quality_state
107
- if metrics.run_started_at:
108
- metrics.duration_seconds = (completed_at - metrics.run_started_at).total_seconds()
109
- if error_message:
110
- metrics.error_message = error_message
 
 
 
 
 
 
 
111
  session.flush()
112
 
113
 
@@ -1070,20 +1087,31 @@ async def startup(ctx: dict) -> None:
1070
  """Called when worker starts."""
1071
  logger.info("Worker starting up...")
1072
  init_db()
 
 
 
 
 
 
1073
  now = datetime.now(timezone.utc)
1074
  cutoff = now - timedelta(
1075
  minutes=get_settings().pipeline_orphan_timeout_minutes
1076
  )
1077
  with SessionLocal() as session:
1078
  orphans = session.query(PipelineRunMetrics).filter(
1079
- PipelineRunMetrics.status.in_(("queued", "running")),
 
 
 
1080
  PipelineRunMetrics.run_started_at < cutoff,
1081
  ).all()
1082
  for orphan in orphans:
1083
- orphan.status = "failed"
1084
- orphan.quality_state = "failed"
1085
- orphan.error_message = "worker_interrupted"
1086
- orphan.run_completed_at = now
 
 
1087
  if orphans:
1088
  session.commit()
1089
  logger.warning("Closed %s orphaned pipeline run(s)", len(orphans))
 
58
  started_at: datetime,
59
  ) -> PipelineRunMetrics:
60
  """Create initial pipeline_run_metrics record."""
61
+ from app.state_machines import PipelineRunEvent, transition_pipeline_run
62
+
63
  metrics = session.query(PipelineRunMetrics).filter(PipelineRunMetrics.run_id == run_id).first()
64
  if metrics is None:
65
  metrics = PipelineRunMetrics(run_id=run_id, run_started_at=started_at)
66
  session.add(metrics)
67
+ transition_pipeline_run(
68
+ metrics,
69
+ PipelineRunEvent.WORKER_START,
70
+ now=started_at,
71
+ )
72
  session.flush()
73
  return metrics
74
 
 
98
  error_message: Optional[str] = None,
99
  ) -> None:
100
  """Finalize run metrics with completion status."""
101
+ from app.state_machines import (
102
+ PipelineRunEvent,
103
+ PipelineRunStatus,
104
+ transition_pipeline_run,
105
+ )
106
+
107
  completed_at = datetime.now(timezone.utc)
108
 
109
  metrics = session.query(PipelineRunMetrics).filter(
 
111
  ).first()
112
 
113
  if metrics:
114
+ event_by_status = {
115
+ PipelineRunStatus.SKIPPED_LOCKED.value: PipelineRunEvent.SKIP_LOCKED,
116
+ PipelineRunStatus.SUCCESS.value: PipelineRunEvent.COMPLETE_SUCCESS,
117
+ PipelineRunStatus.DEGRADED.value: PipelineRunEvent.COMPLETE_DEGRADED,
118
+ PipelineRunStatus.FAILED.value: PipelineRunEvent.COMPLETE_FAILED,
119
+ }
120
+ event = event_by_status.get(status, PipelineRunEvent.COMPLETE_FAILED)
121
+ transition_pipeline_run(
122
+ metrics,
123
+ event,
124
+ now=completed_at,
125
+ error_message=error_message,
126
+ quality_state_override=quality_state,
127
+ )
128
  session.flush()
129
 
130
 
 
1087
  """Called when worker starts."""
1088
  logger.info("Worker starting up...")
1089
  init_db()
1090
+ from app.state_machines import (
1091
+ PipelineRunEvent,
1092
+ PipelineRunStatus,
1093
+ transition_pipeline_run,
1094
+ )
1095
+
1096
  now = datetime.now(timezone.utc)
1097
  cutoff = now - timedelta(
1098
  minutes=get_settings().pipeline_orphan_timeout_minutes
1099
  )
1100
  with SessionLocal() as session:
1101
  orphans = session.query(PipelineRunMetrics).filter(
1102
+ PipelineRunMetrics.status.in_((
1103
+ PipelineRunStatus.QUEUED.value,
1104
+ PipelineRunStatus.RUNNING.value,
1105
+ )),
1106
  PipelineRunMetrics.run_started_at < cutoff,
1107
  ).all()
1108
  for orphan in orphans:
1109
+ transition_pipeline_run(
1110
+ orphan,
1111
+ PipelineRunEvent.EXPIRE_ORPHAN,
1112
+ now=now,
1113
+ error_message="worker_interrupted",
1114
+ )
1115
  if orphans:
1116
  session.commit()
1117
  logger.warning("Closed %s orphaned pipeline run(s)", len(orphans))