Spaces:
Running
Running
Sync from GitHub (tests passed)
Browse files- app/ai_engine.py +26 -15
- app/commentary.py +12 -5
- app/heatmap.py +8 -5
- app/inference.py +9 -2
- app/main.py +106 -94
- app/openrouter_client.py +25 -5
- app/state_machines.py +693 -0
- deep_learning/inference/predictor.py +29 -2
- deep_learning/training/trainer.py +5 -3
- worker/tasks.py +43 -15
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)
|
| 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 |
-
|
| 1706 |
-
score_unscored_processed_articles, "_rate_limited_by_model",
|
| 1707 |
-
)
|
| 1708 |
-
|
| 1709 |
-
|
| 1710 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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=
|
| 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 ==
|
| 2542 |
-
).update({"status":
|
| 2543 |
-
candidate.status =
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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
|
| 704 |
-
if symbol in metadata_symbols and
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 138 |
try:
|
| 139 |
info, auth_blocked = _fetch_ticker_info_safely(ticker)
|
| 140 |
if auth_blocked:
|
| 141 |
-
_YFINANCE_INFO_BLOCKED_UNTIL = now
|
| 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":
|
| 197 |
-
"model_state":
|
| 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 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 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"] =
|
| 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 |
-
|
| 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 |
-
|
| 768 |
-
|
| 769 |
-
|
| 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 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 874 |
-
|
| 875 |
-
if
|
| 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":
|
| 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":
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_((
|
|
|
|
|
|
|
|
|
|
| 1701 |
PipelineRunMetrics.run_started_at < orphan_cutoff,
|
| 1702 |
).all()
|
| 1703 |
for orphan in orphans:
|
| 1704 |
-
|
| 1705 |
-
|
| 1706 |
-
|
| 1707 |
-
|
| 1708 |
-
|
| 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 |
-
|
| 1750 |
-
|
| 1751 |
-
|
| 1752 |
-
|
|
|
|
|
|
|
| 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 ==
|
| 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 |
-
|
| 1806 |
-
|
| 1807 |
-
|
| 1808 |
-
|
|
|
|
|
|
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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
|
| 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,
|
| 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
|
| 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
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
| 1348 |
if validation_pi80_coverage >= 0.90:
|
| 1349 |
conformal_adj = 0.0
|
| 1350 |
-
calibration_status =
|
| 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 =
|
| 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 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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_((
|
|
|
|
|
|
|
|
|
|
| 1080 |
PipelineRunMetrics.run_started_at < cutoff,
|
| 1081 |
).all()
|
| 1082 |
for orphan in orphans:
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
|
|
|
|
|
|
| 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))
|