CoolFace
Apppublic

thinkingEverytime/QuantOracle

sourceHugging Faceupdated 6mo agoView on Hugging Face
1likes
check_data_freshness.py363 linesDownload Raw Back to scripts
1#!/usr/bin/env python32"""Validate freshness + schema of published QuantOracle artifacts.3 4This checker is intended for CI and can run safely on a schedule.5It validates:6  - intraday quotes snapshot7  - EOD latest snapshot metadata8  - news intel daily snapshot9"""10 11from __future__ import annotations12 13import argparse14import json15import os16from dataclasses import dataclass17from datetime import UTC, datetime, time18from pathlib import PurePosixPath19from typing import Any20 21import pytz22import requests23 24 25def _has(v: str | None) -> bool:26    return bool((v or "").strip())27 28 29def _safe_num(v: Any) -> float:30    try:31        return float(v)32    except Exception:33        return 0.034 35 36def _public_url(path: str) -> str | None:37    base = (os.getenv("SUPABASE_URL") or "").strip().rstrip("/")38    bucket = (os.getenv("SUPABASE_BUCKET") or "").strip()39    if not base or not bucket:40        return None41    safe = str(PurePosixPath(path.lstrip("/")))42    return f"{base}/storage/v1/object/public/{bucket}/{safe}"43 44 45def _quotes_url() -> str | None:46    direct = (os.getenv("QUANTORACLE_SUPABASE_QUOTES_URL") or "").strip()47    if direct:48        return direct49    prefix = (os.getenv("QUANTORACLE_EOD_PREFIX") or "eod/nifty50").strip("/")50    return _public_url(f"{prefix}/quotes.json")51 52 53def _eod_latest_url() -> str | None:54    direct = (os.getenv("QUANTORACLE_SUPABASE_EOD_LATEST_URL") or "").strip()55    if direct:56        return direct57    prefix = (os.getenv("QUANTORACLE_EOD_PREFIX") or "eod/nifty50").strip("/")58    return _public_url(f"{prefix}/latest.json")59 60 61def _news_intel_url() -> str | None:62    direct = (os.getenv("QUANTORACLE_NEWS_INTEL_URL") or "").strip()63    if direct:64        return direct65    prefix = (os.getenv("QUANTORACLE_NEWS_PREFIX") or "news/intel").strip("/")66    return _public_url(f"{prefix}/latest.json")67 68 69def _parse_ts(raw: Any) -> datetime | None:70    if not isinstance(raw, str) or not raw.strip():71        return None72    s = raw.strip()73    if s.endswith("Z"):74        s = s[:-1] + "+00:00"75    # Support +0530 variant.76    if len(s) >= 5 and (s[-5] in "+-") and s[-3] != ":":77        s = s[:-2] + ":" + s[-2:]78    try:79        dt = datetime.fromisoformat(s)80    except Exception:81        return None82    if dt.tzinfo is None:83        return dt.replace(tzinfo=UTC)84    return dt.astimezone(UTC)85 86 87def _age_minutes(ts: datetime | None) -> float | None:88    if not ts:89        return None90    return (datetime.now(UTC) - ts).total_seconds() / 60.091 92 93def _is_nse_runtime() -> bool:94    now_ist = datetime.now(pytz.timezone("Asia/Kolkata"))95    if now_ist.weekday() >= 5:96        return False97    t = now_ist.time()98    return time(9, 0) <= t <= time(16, 15)99 100 101@dataclass102class CheckResult:103    name: str104    url: str | None105    ok: bool106    required: bool107    reason: str108    as_of_utc: str | None109    age_minutes: float | None110    status: int | None111 112    def to_dict(self) -> dict[str, Any]:113        return {114            "name": self.name,115            "url": self.url,116            "ok": self.ok,117            "required": self.required,118            "reason": self.reason,119            "as_of_utc": self.as_of_utc,120            "age_minutes": None121            if self.age_minutes is None122            else round(self.age_minutes, 2),123            "status": self.status,124        }125 126 127def _fetch_json(url: str) -> tuple[int, dict[str, Any] | None, str | None]:128    try:129        r = requests.get(url, timeout=15)130        if r.status_code != 200:131            return r.status_code, None, f"HTTP {r.status_code}"132        data = r.json()133        if not isinstance(data, dict):134            return r.status_code, None, "Response is not a JSON object"135        return r.status_code, data, None136    except Exception as e:137        return None, None, str(e)138 139 140def _check_quotes(max_age_minutes: int) -> CheckResult:141    url = _quotes_url()142    required = _is_nse_runtime()143    if not url:144        return CheckResult(145            name="quotes",146            url=None,147            ok=not required,148            required=required,149            reason="Missing quotes URL configuration",150            as_of_utc=None,151            age_minutes=None,152            status=None,153        )154 155    status, data, error = _fetch_json(url)156    if data is None:157        return CheckResult(158            name="quotes",159            url=url,160            ok=not required,161            required=required,162            reason=error or "quotes fetch failed",163            as_of_utc=None,164            age_minutes=None,165            status=status,166        )167 168    as_of = _parse_ts(data.get("as_of_utc")) or _parse_ts(data.get("as_of_ist"))169    age = _age_minutes(as_of)170    quotes = data.get("quotes")171    count = int(data.get("count") or 0)172    shape_ok = isinstance(quotes, dict) and count >= 1173    age_ok = (age is not None) and (age <= max_age_minutes)174 175    ok = shape_ok and (age_ok or (not required))176    reason = "ok"177    if not shape_ok:178        reason = "quotes schema invalid"179    elif required and not age_ok:180        reason = f"quotes stale: age={round(age or 0, 2)}m limit={max_age_minutes}m"181    elif not required and not age_ok:182        reason = "outside NSE hours; stale tolerated"183 184    return CheckResult(185        name="quotes",186        url=url,187        ok=ok,188        required=required,189        reason=reason,190        as_of_utc=as_of.isoformat().replace("+00:00", "Z") if as_of else None,191        age_minutes=age,192        status=status,193    )194 195 196def _check_eod(max_age_minutes: int) -> CheckResult:197    url = _eod_latest_url()198    required = True199    if not url:200        return CheckResult(201            name="eod",202            url=None,203            ok=False,204            required=required,205            reason="Missing EOD latest URL configuration",206            as_of_utc=None,207            age_minutes=None,208            status=None,209        )210 211    status, data, error = _fetch_json(url)212    if data is None:213        return CheckResult(214            name="eod",215            url=url,216            ok=False,217            required=required,218            reason=error or "eod fetch failed",219            as_of_utc=None,220            age_minutes=None,221            status=status,222        )223 224    as_of = _parse_ts(data.get("generated_at_utc")) or _parse_ts(data.get("as_of_utc"))225    age = _age_minutes(as_of)226    shape_ok = all(227        [228            isinstance(data.get("as_of_date"), str),229            isinstance(data.get("universe"), str),230            isinstance(data.get("model_id"), str),231            isinstance(data.get("model_version"), str),232        ]233    )234    age_ok = (age is not None) and (age <= max_age_minutes)235 236    reason = "ok"237    if not shape_ok:238        reason = "eod schema invalid"239    elif not age_ok:240        reason = f"eod stale: age={round(age or 0, 2)}m limit={max_age_minutes}m"241 242    return CheckResult(243        name="eod",244        url=url,245        ok=shape_ok and age_ok,246        required=required,247        reason=reason,248        as_of_utc=as_of.isoformat().replace("+00:00", "Z") if as_of else None,249        age_minutes=age,250        status=status,251    )252 253 254def _check_news(max_age_minutes: int) -> CheckResult:255    url = _news_intel_url()256    required = True257    if not url:258        return CheckResult(259            name="news_intel",260            url=None,261            ok=False,262            required=required,263            reason="Missing news intel URL configuration",264            as_of_utc=None,265            age_minutes=None,266            status=None,267        )268 269    status, data, error = _fetch_json(url)270    if data is None:271        return CheckResult(272            name="news_intel",273            url=url,274            ok=False,275            required=required,276            reason=error or "news intel fetch failed",277            as_of_utc=None,278            age_minutes=None,279            status=status,280        )281 282    as_of = _parse_ts(data.get("as_of_utc")) or _parse_ts(data.get("generated_at_utc"))283    age = _age_minutes(as_of)284 285    items = data.get("items")286    shape_ok = isinstance(items, list) and len(items) > 0287    if shape_ok:288        for item in items[:10]:289            if not isinstance(item, dict):290                shape_ok = False291                break292            if not all(293                [294                    isinstance(item.get("headline"), str),295                    isinstance(item.get("source"), str),296                    isinstance(item.get("impact"), dict),297                    isinstance(item.get("source_tier"), str),298                ]299            ):300                shape_ok = False301                break302 303    age_ok = (age is not None) and (age <= max_age_minutes)304    reason = "ok"305    if not shape_ok:306        reason = "news intel schema invalid"307    elif not age_ok:308        reason = f"news intel stale: age={round(age or 0, 2)}m limit={max_age_minutes}m"309 310    return CheckResult(311        name="news_intel",312        url=url,313        ok=shape_ok and age_ok,314        required=required,315        reason=reason,316        as_of_utc=as_of.isoformat().replace("+00:00", "Z") if as_of else None,317        age_minutes=age,318        status=status,319    )320 321 322def main() -> int:323    ap = argparse.ArgumentParser()324    ap.add_argument("--quotes-max-age-minutes", type=int, default=180)325    ap.add_argument("--eod-max-age-minutes", type=int, default=4320)326    ap.add_argument("--news-max-age-minutes", type=int, default=2160)327    ap.add_argument("--strict", action="store_true")328    args = ap.parse_args()329 330    checks = [331        _check_quotes(args.quotes_max_age_minutes),332        _check_eod(args.eod_max_age_minutes),333        _check_news(args.news_max_age_minutes),334    ]335 336    summary = {337        "all_ok": all(c.ok for c in checks),338        "required_ok": all((c.ok or (not c.required)) for c in checks),339        "nse_runtime": _is_nse_runtime(),340        "config": {341            "supabase_url": _has(os.getenv("SUPABASE_URL")),342            "supabase_bucket": _has(os.getenv("SUPABASE_BUCKET")),343            "quotes_url": _quotes_url(),344            "eod_latest_url": _eod_latest_url(),345            "news_intel_url": _news_intel_url(),346        },347    }348 349    out = {350        "as_of_utc": datetime.now(UTC).isoformat().replace("+00:00", "Z"),351        "checks": {c.name: c.to_dict() for c in checks},352        "summary": summary,353    }354    print(json.dumps(out, indent=2))355 356    if args.strict and not summary["required_ok"]:357        return 1358    return 0359 360 361if __name__ == "__main__":362    raise SystemExit(main())363