diff --git a/extension/manifest.json b/extension/manifest.json index 24e8a211..f5ae5612 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "PolyWeather Side Panel", "description": "Weather side panel for Polymarket.", - "version": "0.1.8", + "version": "0.1.9", "icons": { "16": "icon-16.png", "32": "icon-32.png", diff --git a/src/database/runtime_state.py b/src/database/runtime_state.py index 41b1c8de..d9eeafff 100644 --- a/src/database/runtime_state.py +++ b/src/database/runtime_state.py @@ -360,6 +360,39 @@ class TruthRecordRepository: pass return payload + def load_city(self, city: str) -> Dict[str, Dict[str, Any]]: + out: Dict[str, Dict[str, Any]] = {} + with self.db.connect() as conn: + rows = conn.execute( + """ + SELECT target_date, actual_high, settlement_source, settlement_station_code, + settlement_station_label, truth_version, updated_by, updated_at, + source_payload_json, is_final + FROM truth_records_store + WHERE city = ? + ORDER BY target_date + """, + (city,), + ).fetchall() + for row in rows: + payload: Dict[str, Any] = { + "actual_high": float(row["actual_high"]), + "settlement_source": row["settlement_source"], + "settlement_station_code": row["settlement_station_code"], + "settlement_station_label": row["settlement_station_label"], + "truth_version": row["truth_version"], + "updated_by": row["updated_by"], + "truth_updated_at": float(row["updated_at"]), + "is_final": bool(row["is_final"]), + } + if row["source_payload_json"]: + try: + payload["source_payload"] = json.loads(row["source_payload_json"]) + except Exception: + pass + out[str(row["target_date"])] = payload + return out + def upsert_truth( self, *, @@ -789,6 +822,25 @@ class TrainingFeatureRecordRepository: except Exception: return None + def load_city(self, city: str) -> Dict[str, Dict[str, Any]]: + out: Dict[str, Dict[str, Any]] = {} + with self.db.connect() as conn: + rows = conn.execute( + """ + SELECT target_date, payload_json + FROM training_feature_records_store + WHERE city = ? + ORDER BY target_date + """, + (city,), + ).fetchall() + for row in rows: + try: + out[str(row["target_date"])] = json.loads(row["payload_json"]) + except Exception: + continue + return out + class OpenMeteoCacheRepository: def __init__(self, db: Optional[RuntimeStateDB] = None): diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index 722c594b..07b6f9b4 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -3,7 +3,7 @@ from fastapi.testclient import TestClient from web.app import app import web.routes as routes -from src.database.runtime_state import TruthRecordRepository +from src.database.runtime_state import TruthRecordRepository, TrainingFeatureRecordRepository client = TestClient(app) @@ -200,3 +200,47 @@ def test_ops_truth_history_returns_filtered_rows(monkeypatch): assert payload["filters"]["city"] == "taipei" assert payload["items"][0]["city"] == "taipei" assert payload["items"][0]["settlement_station_code"] == "RCSS" + + +def test_city_history_is_read_only_and_uses_sqlite_truth_and_features(monkeypatch): + monkeypatch.setattr(routes, "_assert_entitlement", lambda request: None) + + def _fail_bootstrap(*args, **kwargs): + raise AssertionError("bootstrap should not be called by /api/history") + + monkeypatch.setattr(routes, "bootstrap_recent_daily_history_if_missing", _fail_bootstrap, raising=False) + + truth_repo = TruthRecordRepository() + truth_repo.upsert_truth( + city="ankara", + target_date="2026-04-02", + actual_high=16.0, + settlement_source="metar", + settlement_station_code="LTAC", + settlement_station_label="Ankara Esenboga Airport", + truth_version="v1", + updated_by="test", + source_payload={"sample": True}, + is_final=True, + ) + feature_repo = TrainingFeatureRecordRepository() + feature_repo.upsert_record( + "ankara", + "2026-04-02", + { + "deb_prediction": 16.4, + "mu": 16.2, + "forecasts": {"Open-Meteo": 15.8, "ECMWF": 16.1}, + }, + ) + + response = client.get("/api/history/ankara") + + assert response.status_code == 200 + payload = response.json() + assert payload["history"] + row = next(item for item in payload["history"] if item["date"] == "2026-04-02") + assert row["actual"] == 16.0 + assert row["deb"] == 16.4 + assert row["mu"] == 16.2 + assert row["forecasts"]["Open-Meteo"] == 15.8 diff --git a/web/routes.py b/web/routes.py index ec894219..b27fb97e 100644 --- a/web/routes.py +++ b/web/routes.py @@ -8,11 +8,9 @@ from fastapi import APIRouter, HTTPException, Request from fastapi.responses import PlainTextResponse from loguru import logger -from src.analysis.deb_algorithm import ( - bootstrap_recent_daily_history_if_missing, - load_history, -) +from src.analysis.deb_algorithm import load_history from src.analysis.probability_snapshot_archive import load_snapshot_rows_for_day +from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRecordRepository from src.analysis.settlement_rounding import apply_city_settlement from src.data_collection.city_registry import ALIASES from src.database.runtime_state import TruthRecordRepository @@ -56,6 +54,8 @@ router = APIRouter() _DEB_RECENT_LOOKBACK = 7 _DEB_RECENT_MIN_SAMPLES = 3 +_truth_record_repo = TruthRecordRepository() +_training_feature_repo = TrainingFeatureRecordRepository() TRACKABLE_ANALYTICS_EVENTS = { "signup_completed", @@ -312,16 +312,34 @@ async def city_detail(request: Request, name: str, force_refresh: bool = False): async def city_history(request: Request, name: str): _assert_entitlement(request) city = _normalize_city_or_404(name) + source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower() + truth_rows = _truth_record_repo.load_city(city) + feature_rows = _training_feature_repo.load_city(city) - bootstrap_recent_daily_history_if_missing(city, lookback_days=14) + if not truth_rows and not feature_rows: + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + history_file = os.path.join(project_root, "data", "daily_records.json") + data = load_history(history_file) + city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {} + else: + all_dates = sorted(set(truth_rows.keys()) | set(feature_rows.keys())) + city_data = {} + for day in all_dates: + record: dict[str, object] = {} + truth = truth_rows.get(day) or {} + features = feature_rows.get(day) or {} + if truth.get("actual_high") is not None: + record["actual_high"] = truth.get("actual_high") + if isinstance(features, dict): + if features.get("deb_prediction") is not None: + record["deb_prediction"] = features.get("deb_prediction") + if features.get("mu") is not None: + record["mu"] = features.get("mu") + if isinstance(features.get("forecasts"), dict): + record["forecasts"] = features.get("forecasts") + city_data[day] = record - project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - history_file = os.path.join(project_root, "data", "daily_records.json") - data = load_history(history_file) - - city_data = data.get(city, {}) if isinstance(data.get(city, {}), dict) else {} if not city_data: - source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower() return { "history": [], "settlement_source": source, @@ -369,7 +387,6 @@ async def city_history(request: Request, name: str): } ) - source = str(CITIES.get(city, {}).get("settlement_source") or "metar").strip().lower() return { "history": out, "settlement_source": source,