SPB Git

spb/findata

Public

public landing: anonymous overview metrics (/api/v1/public/overview) and docs; data pages stay behind sign-in

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 2, 2026) parent b31ccfb Browse files

6 changed files +283 −8

modified apps/web/src/app/page.tsx +15 −4
@@ -8,9 +8,20 @@ import { Banner, Card, ErrorState, KpiTile, PageHeader, SeverityBadge, Skeleton,
8 import { DataTable } from "@/components/DataTable"; 8 import { DataTable } from "@/components/DataTable";
9 import { Job } from "@/lib/api"; 9 import { Job } from "@/lib/api";
10 import { useAuth } from "@/lib/auth"; 10 import { useAuth } from "@/lib/auth";
11+import { PublicOverview } from "@/components/PublicOverview";
11 12
12 export default function OverviewPage() { 13 export default function OverviewPage() {
13− const { isOwner } = useAuth(); 14+ const { isOwner, me } = useAuth();
15+ const signedIn = !!me && me.mfa_passed;
16+ return (
17+ <>
18+ <PublicOverview signedIn={signedIn} />
19+ {signedIn && <OperatorOverview isOwner={isOwner} />}
20+ </>
21+ );
22+}
23+
24+function OperatorOverview({ isOwner }: { isOwner: boolean }) {
14 const { data, error, loading, reload } = useAsync((signal) => api.get<HealthSummary>("/health/summary", undefined, signal), []); 25 const { data, error, loading, reload } = useAsync((signal) => api.get<HealthSummary>("/health/summary", undefined, signal), []);
15 useInterval(reload, 60_000); 26 useInterval(reload, 60_000);
16 27
@@ -24,9 +35,9 @@ export default function OverviewPage() {
24 const freeTone = lake ? (lake.free_gb < lake.hard_min_free_gb ? "crit" : lake.free_gb < lake.hard_min_free_gb * 2 ? "warn" : undefined) : undefined; 35 const freeTone = lake ? (lake.free_gb < lake.hard_min_free_gb ? "crit" : lake.free_gb < lake.hard_min_free_gb * 2 ? "warn" : undefined) : undefined;
25 36
26 return ( 37 return (
27− <> 38+ <div className="mt-6">
28 <PageHeader 39 <PageHeader
29− title="Overview" 40+ title="Operations"
30 subtitle={h ? <>Health checked {fmtRelative(h.checked_at)} · auto-refresh every 60 s</> : "Lakehouse status at a glance"} 41 subtitle={h ? <>Health checked {fmtRelative(h.checked_at)} · auto-refresh every 60 s</> : "Lakehouse status at a glance"}
31 actions={ 42 actions={
32 <> 43 <>
@@ -151,6 +162,6 @@ export default function OverviewPage() {
151 </div> 162 </div>
152 </> 163 </>
153 )} 164 )}
154− </> 165+ </div>
155 ); 166 );
156 } 167 }
added apps/web/src/components/PublicOverview.tsx +158 −0
@@ -0,0 +1,158 @@
1+"use client";
2+import Link from "next/link";
3+import { RefreshCw } from "lucide-react";
4+import { api } from "@/lib/api";
5+import { useAsync, useInterval } from "@/lib/hooks";
6+import { fmtBytes, fmtCompact, fmtGB, fmtInt, fmtRelative } from "@/lib/format";
7+import { Card, ErrorState, KpiTile, PageHeader, Skeleton, StatusBadge } from "@/components/ui";
8+import { DataTable } from "@/components/DataTable";
9+
10+export interface PublicOverviewData {
11+ generated_at: string;
12+ status: string | null;
13+ totals: { files: number; rows: number; bytes: number; datasets_active: number; dataset_versions: number; raw_archives: number; raw_bytes: number };
14+ families: { family: string; datasets: number; rows: number; bytes: number }[];
15+ datasets: { id: string; name: string; kind: string; asset_class: string | null; interval: string | null; adjustment: string | null; status: string; current_version: number; row_count: number | null; bytes: number | null; file_count: number | null }[];
16+ coverage: { dataset_id: string; symbols: number; first_session: string | null; last_session: string | null; bars: number | null }[];
17+ options: { underlyings: number | null; first_day: string | null; last_day: string | null; rows: number | null };
18+ fundamentals: { entities: number; facts: number; first_period: string | null; last_period: string | null; earnings_events: number };
19+ instruments: { n: number; active: number; delisted: number };
20+ freshness: { vendor_last_update: string | null; last_successful_job: string | null; jobs_24h: Record<string, number> };
21+ storage: { hot_free_gb: number | null; cold_free_gb: number | null; curated_gb: number | null; raw_gb: number | null };
22+ backup: { last: string | null; verified: boolean | null };
23+ license: string;
24+}
25+
26+const FAMILY_LABEL: Record<string, string> = {
27+ "market_bars.stock": "US stocks",
28+ "market_bars.etf": "US ETFs",
29+ "options.eod": "Options (EOD chains)",
30+ "market_bars.futures": "Futures (continuous)",
31+ "market_bars.futures_contracts": "Futures (contracts)",
32+ "market_bars.fx": "FX",
33+ "market_bars.crypto": "Crypto",
34+ "market_bars.index": "Indices",
35+};
36+
37+export function PublicOverview({ signedIn }: { signedIn: boolean }) {
38+ const { data, error, loading, reload } = useAsync((signal) => api.get<PublicOverviewData>("/public/overview", undefined, signal), []);
39+ useInterval(reload, 120_000);
40+ const stocks = data?.coverage.find((c) => c.dataset_id === "market_bars.stock.1m.raw");
41+ const etfs = data?.coverage.find((c) => c.dataset_id === "market_bars.etf.1m.raw");
42+ const indices = data?.coverage.find((c) => c.dataset_id === "market_bars.index.1m.raw");
43+ const jobs = data?.freshness.jobs_24h ?? {};
44+
45+ return (
46+ <>
47+ <PageHeader
48+ title="FinData OS — the lake at a glance"
49+ subtitle={data ? <>Catalog figures refreshed {fmtRelative(data.generated_at)} · public metrics only, data access requires sign-in</> : "Research-grade market data lakehouse (FirstRate Data, private use)"}
50+ actions={
51+ <>
52+ {data?.status && <StatusBadge status={data.status} label={data.status === "ok" ? "all systems ok" : data.status} />}
53+ <button className="btn btn-sm" onClick={reload} disabled={loading}>
54+ <RefreshCw className={loading ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} /> Refresh
55+ </button>
56+ {!signedIn && (
57+ <Link href="/login/" className="btn btn-primary btn-sm">
58+ Sign in
59+ </Link>
60+ )}
61+ </>
62+ }
63+ />
64+
65+ {error && <ErrorState error={error} retry={reload} />}
66+ {!data && !error && <Skeleton rows={6} />}
67+
68+ {data && (
69+ <>
70+ <div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
71+ <KpiTile label="Rows in the lake" value={fmtCompact(data.totals.rows)} sub={fmtInt(data.totals.rows)} />
72+ <KpiTile label="Curated Parquet" value={fmtBytes(data.totals.bytes, 0)} sub={`${fmtInt(data.totals.files)} files · ${fmtInt(data.totals.datasets_active)} active datasets`} href={signedIn ? "/datasets/" : undefined} />
73+ <KpiTile label="Stocks (1-min)" value={fmtInt(stocks?.symbols)} sub={stocks ? `${stocks.first_session} → ${stocks.last_session}` : "—"} href={signedIn ? "/symbols/" : undefined} />
74+ <KpiTile label="ETFs (1-min)" value={fmtInt(etfs?.symbols)} sub={etfs ? `${etfs.first_session} → ${etfs.last_session}` : "—"} />
75+ <KpiTile label="Option underlyings" value={fmtInt(data.options.underlyings)} sub={data.options.first_day ? `${fmtCompact(data.options.rows)} rows · ${data.options.first_day} → ${data.options.last_day}` : "—"} />
76+ <KpiTile label="Fundamentals" value={fmtInt(data.fundamentals.entities)} sub={`${fmtCompact(data.fundamentals.facts)} PIT facts · ${fmtCompact(data.fundamentals.earnings_events)} earnings`} href={signedIn ? "/fundamentals/" : undefined} />
77+ <KpiTile label="Indices (1-min)" value={fmtInt(indices?.symbols)} sub={indices ? `${indices.first_session} → ${indices.last_session}` : "—"} />
78+ <KpiTile label="Instruments" value={fmtInt(data.instruments.n)} sub={`${fmtInt(data.instruments.active)} active · ${fmtInt(data.instruments.delisted)} delisted`} />
79+ <KpiTile label="Vendor last update" value={data.freshness.vendor_last_update ?? "—"} sub={data.freshness.last_successful_job ? `last job ${fmtRelative(data.freshness.last_successful_job)}` : undefined} />
80+ <KpiTile label="Jobs (24 h)" value={`${fmtInt(jobs.succeeded ?? 0)} ok`} sub={`${fmtInt(jobs.queued ?? 0)} queued · ${fmtInt(jobs.running ?? 0)} running · ${fmtInt(jobs.dead ?? 0)} dead`} tone={jobs.dead ? "crit" : undefined} />
81+ <KpiTile label="Raw archives kept" value={fmtBytes(data.totals.raw_bytes, 0)} sub={`${fmtInt(data.totals.raw_archives)} vendor archives (sha256 recorded)`} />
82+ <KpiTile
83+ label="Free space"
84+ value={data.storage.hot_free_gb !== null ? fmtGB(data.storage.hot_free_gb, 0) : "—"}
85+ sub={data.storage.cold_free_gb !== null ? `internal · external ${fmtGB(data.storage.cold_free_gb, 0)}` : "internal SSD"}
86+ />
87+ </div>
88+
89+ <div className="grid gap-4 lg:grid-cols-[1fr_minmax(280px,0.7fr)]">
90+ <Card title="Data families">
91+ <DataTable
92+ rows={data.families}
93+ rowKey={(r) => r.family}
94+ mobile="scroll"
95+ columns={[
96+ { key: "family", header: "Family", render: (r) => <span className="font-medium">{FAMILY_LABEL[r.family] ?? r.family}</span> },
97+ { key: "datasets", header: "Datasets", render: (r) => <span className="tnum">{fmtInt(r.datasets)}</span> },
98+ { key: "rows", header: "Rows", render: (r) => <span className="tnum">{fmtInt(r.rows)}</span> },
99+ { key: "bytes", header: "Size", render: (r) => <span className="tnum">{fmtBytes(r.bytes, 1)}</span> },
100+ ]}
101+ />
102+ </Card>
103+ <Card title="Platform">
104+ <ul className="flex flex-col gap-2 text-sm">
105+ <li className="flex items-center justify-between gap-2">
106+ <span>Status</span>
107+ <StatusBadge status={data.status} label={data.status ?? "unknown"} />
108+ </li>
109+ <li className="flex items-center justify-between gap-2">
110+ <span>Dataset versions published</span>
111+ <span className="tnum">{fmtInt(data.totals.dataset_versions)}</span>
112+ </li>
113+ <li className="flex items-center justify-between gap-2">
114+ <span>Fundamentals periods</span>
115+ <span className="tnum text-xs">{data.fundamentals.first_period ?? "—"} → {data.fundamentals.last_period ?? "—"}</span>
116+ </li>
117+ <li className="flex items-center justify-between gap-2">
118+ <span>Last backup</span>
119+ <span className="text-right text-xs">
120+ {data.backup.last ? (
121+ <>
122+ <StatusBadge status={data.backup.verified ? "verified" : "warning"} label={data.backup.verified ? "verified" : "unverified"} />{" "}
123+ <span className="text-ink-2">{fmtRelative(data.backup.last)}</span>
124+ </>
125+ ) : (
126+ <StatusBadge status="warning" label="none yet" />
127+ )}
128+ </span>
129+ </li>
130+ </ul>
131+ <p className="mt-4 text-xs text-ink-2">{data.license}</p>
132+ <p className="mt-2 text-xs text-ink-2">
133+ API reference: <a className="text-accent-ink hover:underline" href="/api/docs">/api/docs</a> · <Link className="text-accent-ink hover:underline" href="/docs/">Documentation</Link>
134+ </p>
135+ </Card>
136+ </div>
137+
138+ <Card title="Datasets" className="mt-4">
139+ <DataTable
140+ rows={data.datasets.filter((d) => d.status === "active" && (d.row_count ?? 0) > 0)}
141+ rowKey={(r) => r.id}
142+ mobile="scroll"
143+ columns={[
144+ { key: "id", header: "Dataset", mono: true, render: (r) => (signedIn ? <Link href={`/datasets/detail/?id=${encodeURIComponent(r.id)}`} className="text-accent-ink hover:underline">{r.id}</Link> : r.id) },
145+ { key: "asset_class", header: "Asset", hideSm: true, render: (r) => r.asset_class ?? "—" },
146+ { key: "interval", header: "Interval", hideSm: true, render: (r) => r.interval ?? "—" },
147+ { key: "adjustment", header: "Adjustment", hideSm: true, render: (r) => r.adjustment ?? "—" },
148+ { key: "row_count", header: "Rows", render: (r) => <span className="tnum">{fmtInt(r.row_count)}</span> },
149+ { key: "bytes", header: "Size", render: (r) => <span className="tnum">{fmtBytes(r.bytes, 1)}</span> },
150+ { key: "current_version", header: "Version", hideSm: true, render: (r) => <span className="tnum">v{r.current_version}</span> },
151+ ]}
152+ />
153+ </Card>
154+ </>
155+ )}
156+ </>
157+ );
158+}
modified apps/web/src/components/Shell.tsx +23 −0
@@ -28,6 +28,29 @@ export function Shell({ children }: { children: ReactNode }) {
28 28
29 if (isLogin) return <main className="min-h-dvh">{children}</main>; 29 if (isLogin) return <main className="min-h-dvh">{children}</main>;
30 30
31+ const isPublicPage = pathname === "/" || pathname === "/docs/";
32+ if (!loading && (!me || !me.mfa_passed) && isPublicPage) {
33+ return (
34+ <div className="min-h-dvh">
35+ <header className="sticky top-0 z-30 flex items-center justify-between border-b border-hairline bg-surface-1/95 px-3 py-2 backdrop-blur sm:px-5">
36+ <Link href="/" className="flex items-center gap-2">
37+ <Logo />
38+ <span className="text-sm font-semibold">FinData OS</span>
39+ </Link>
40+ <nav className="flex items-center gap-1" aria-label="Public">
41+ <Link href="/" className={clsx("btn btn-ghost btn-sm", pathname === "/" && "text-accent-ink")}>Overview</Link>
42+ <Link href="/docs/" className={clsx("btn btn-ghost btn-sm", pathname === "/docs/" && "text-accent-ink")}>Docs</Link>
43+ <a href="/api/docs" className="btn btn-ghost btn-sm">API</a>
44+ <Link href="/login/" className="btn btn-primary btn-sm">Sign in</Link>
45+ </nav>
46+ </header>
47+ <main className="min-w-0 px-3 pb-10 pt-3 sm:px-5 sm:pt-4 lg:px-8 lg:pt-6">
48+ <div className="mx-auto max-w-[1400px]">{children}</div>
49+ </main>
50+ </div>
51+ );
52+ }
53+
31 if (loading || !me || !me.mfa_passed) { 54 if (loading || !me || !me.mfa_passed) {
32 return ( 55 return (
33 <main className="flex min-h-dvh items-center justify-center p-6"> 56 <main className="flex min-h-dvh items-center justify-center p-6">
modified apps/web/src/lib/auth.tsx +4 −3
@@ -14,7 +14,7 @@ interface AuthState {
14 14
15 const Ctx = createContext<AuthState | null>(null); 15 const Ctx = createContext<AuthState | null>(null);
16 16
17−const PUBLIC_PATHS = ["/login"]; 17+const PUBLIC_PATHS = ["/login", "/", "/docs"]; // "/" and "/docs" show public metrics; data pages need a session
18 18
19 function isPublic(path: string | null): boolean { 19 function isPublic(path: string | null): boolean {
20 if (!path) return false; 20 if (!path) return false;
@@ -56,12 +56,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
56 useEffect(() => { 56 useEffect(() => {
57 if (loading) return; 57 if (loading) return;
58 const pub = isPublic(pathname); 58 const pub = isPublic(pathname);
59+ const onLogin = (pathname ?? "").replace(/\/+$/, "") === "/login";
59 if (!me && !pub) { 60 if (!me && !pub) {
60 const next = pathname && pathname !== "/" ? `?next=${encodeURIComponent(pathname)}` : ""; 61 const next = pathname && pathname !== "/" ? `?next=${encodeURIComponent(pathname)}` : "";
61 router.replace(`/login/${next}`); 62 router.replace(`/login/${next}`);
62− } else if (me && !me.mfa_passed && !pub) { 63+ } else if (me && !me.mfa_passed && !onLogin) {
63 router.replace("/login/"); 64 router.replace("/login/");
64− } else if (me && me.mfa_passed && pub) { 65+ } else if (me && me.mfa_passed && onLogin) {
65 const next = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("next") : null; 66 const next = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("next") : null;
66 router.replace(next && next.startsWith("/") ? next : "/"); 67 router.replace(next && next.startsWith("/") ? next : "/");
67 } 68 }
modified services/api/src/findata_api/main.py +4 −1
@@ -28,6 +28,7 @@ from findata_api.routers import (
28 health, 28 health,
29 instruments, 29 instruments,
30 market, 30 market,
31+ public,
31 quality, 32 quality,
32 query, 33 query,
33 ) 34 )
@@ -96,6 +97,8 @@ class RateLimit(BaseHTTPMiddleware):
96 if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"): 97 if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"):
97 auth_h = request.headers.get("authorization", "") 98 auth_h = request.headers.get("authorization", "")
98 key = auth_h[-24:] if auth_h else (request.cookies.get(A.SESSION_COOKIE, "")[-24:] or (A.client_ip(request) or "anon")) 99 key = auth_h[-24:] if auth_h else (request.cookies.get(A.SESSION_COOKIE, "")[-24:] or (A.client_ip(request) or "anon"))
100+ if request.url.path.startswith(f"{API_PREFIX}/public/"):
101+ key = f"public:{A.client_ip(request) or 'anon'}"
99 ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute) 102 ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute)
100 if not ok: 103 if not ok:
101 return JSONResponse({"error": {"code": "rate_limited", "message": "Too many requests", "request_id": getattr(request.state, "request_id", None)}}, 104 return JSONResponse({"error": {"code": "rate_limited", "message": "Too many requests", "request_id": getattr(request.state, "request_id", None)}},
@@ -132,7 +135,7 @@ def create_app() -> FastAPI:
132 return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500) 135 return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500)
133 136
134 for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router, 137 for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router,
135− coverage.router, quality.router, health.router, admin.router, assistant.router): 138+ coverage.router, quality.router, health.router, admin.router, assistant.router, public.router):
136 app.include_router(r, prefix=API_PREFIX) 139 app.include_router(r, prefix=API_PREFIX)
137 140
138 @app.get("/metrics", include_in_schema=False) 141 @app.get("/metrics", include_in_schema=False)
added services/api/src/findata_api/routers/public.py +79 −0
@@ -0,0 +1,79 @@
1+"""Anonymous overview metrics for the public landing page. No data rows, no paths, no secrets — only catalog-level
2+figures (what exists, how much, how fresh). Everything else stays behind authentication (FirstRate license:
3+raw bars/tables are private)."""
4+from __future__ import annotations
5+
6+import threading
7+import time
8+from datetime import UTC, datetime
9+from typing import Any
10+
11+from fastapi import APIRouter
12+from findata_core.db import connection
13+
14+from findata_api.health import cached_evaluate
15+
16+router = APIRouter(prefix="/public", tags=["public"])
17+_cache: dict[str, Any] = {"at": 0.0, "value": None}
18+_lock = threading.Lock()
19+
20+
21+def _compute() -> dict[str, Any]:
22+ with connection() as conn:
23+ totals = conn.execute("SELECT count(*) files, coalesce(sum(row_count),0) rows, coalesce(sum(bytes),0) bytes FROM lake_files WHERE version_removed IS NULL").fetchone()
24+ fam = conn.execute("""
25+ SELECT split_part(dataset_id,'.',1)||'.'||split_part(dataset_id,'.',2) AS family, count(DISTINCT dataset_id) datasets,
26+ sum(row_count) rows, sum(bytes) bytes
27+ FROM lake_files WHERE version_removed IS NULL GROUP BY 1 ORDER BY sum(bytes) DESC""").fetchall()
28+ ds = conn.execute("""
29+ SELECT d.id, d.name, d.kind, d.asset_class, d.interval, d.adjustment, d.status, d.license_class, d.current_version,
30+ v.row_count, v.bytes, v.file_count, v.created_at AS version_created_at
31+ FROM datasets d LEFT JOIN dataset_versions v ON v.dataset_id=d.id AND v.version=d.current_version ORDER BY d.id""").fetchall()
32+ cov = conn.execute("""
33+ SELECT dataset_id, count(DISTINCT symbol) symbols, min(session_date) first_session, max(session_date) last_session, sum(bar_count) bars
34+ FROM coverage WHERE dataset_id IN ('market_bars.stock.1m.raw','market_bars.etf.1m.raw','market_bars.index.1m.raw') GROUP BY 1""").fetchall()
35+ opt = conn.execute("SELECT count(DISTINCT symbol_min) underlyings, min(date_min) first_day, max(date_max) last_day, sum(row_count) rows FROM lake_files WHERE dataset_id='options.eod' AND version_removed IS NULL").fetchone()
36+ fund = conn.execute("SELECT count(DISTINCT entity_id) entities, count(*) facts, min(fiscal_period_end) first_period, max(fiscal_period_end) last_period FROM fundamentals_facts WHERE superseded_at IS NULL").fetchone()
37+ earn = conn.execute("SELECT count(*) n FROM earnings_events").fetchone()
38+ inst = conn.execute("SELECT count(*) n, count(*) FILTER (WHERE status='active') active, count(*) FILTER (WHERE status='delisted') delisted FROM instruments").fetchone()
39+ raw = conn.execute("SELECT count(*) n, coalesce(sum(bytes),0) bytes FROM raw_archives WHERE path IS NOT NULL").fetchone()
40+ jobs = conn.execute("SELECT status, count(*) n FROM ingest_jobs WHERE created_at > now() - interval '24 hours' GROUP BY 1").fetchall()
41+ lastjob = conn.execute("SELECT max(finished_at) t FROM ingest_jobs WHERE status='succeeded'").fetchone()
42+ ck = conn.execute("SELECT value FROM checkpoints WHERE scope='dataset:market_bars.stock.1m.raw' AND key='last_vendor_update:day'").fetchone()
43+ versions = conn.execute("SELECT count(*) n FROM dataset_versions").fetchone()
44+ backup = conn.execute("SELECT updated_at, value->>'verified' AS verified FROM settings_kv WHERE key='last_backup'").fetchone()
45+ h = cached_evaluate()
46+ lake = h.get("components", {}).get("lake", {}) if isinstance(h, dict) else {}
47+ return {
48+ "generated_at": datetime.now(UTC).isoformat(),
49+ "status": h.get("status"),
50+ "totals": {"files": int(totals["files"]), "rows": int(totals["rows"]), "bytes": int(totals["bytes"]),
51+ "datasets_active": sum(1 for d in ds if d["status"] == "active"), "dataset_versions": int(versions["n"]),
52+ "raw_archives": int(raw["n"]), "raw_bytes": int(raw["bytes"])},
53+ "families": [{"family": f["family"], "datasets": f["datasets"], "rows": int(f["rows"]), "bytes": int(f["bytes"])} for f in fam],
54+ "datasets": [{k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in dict(d).items()} for d in ds],
55+ "coverage": [{k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in dict(c).items()} for c in cov],
56+ "options": {k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in dict(opt).items()},
57+ "fundamentals": {**{k: (v.isoformat() if hasattr(v, "isoformat") else v) for k, v in dict(fund).items()}, "earnings_events": int(earn["n"])},
58+ "instruments": dict(inst),
59+ "freshness": {"vendor_last_update": (ck["value"] or {}).get("vendor_last_update") if ck else None,
60+ "last_successful_job": lastjob["t"].isoformat() if lastjob and lastjob["t"] else None,
61+ "jobs_24h": {j["status"]: j["n"] for j in jobs}},
62+ "storage": {"hot_free_gb": lake.get("free_gb"), "cold_free_gb": lake.get("cold_free_gb"), "curated_gb": lake.get("curated_gb"),
63+ "raw_gb": lake.get("raw_gb")},
64+ "backup": {"last": backup["updated_at"].isoformat() if backup else None, "verified": (backup["verified"] == "true") if backup else None},
65+ "license": "FirstRate Data — private research use; raw bars and tables are not redistributed. Sign in for data access.",
66+ }
67+
68+
69+@router.get("/overview")
70+def overview():
71+ now = time.monotonic()
72+ if _cache["value"] is not None and now - _cache["at"] < 120:
73+ return _cache["value"]
74+ with _lock:
75+ if _cache["value"] is not None and time.monotonic() - _cache["at"] < 120:
76+ return _cache["value"]
77+ v = _compute()
78+ _cache.update(at=time.monotonic(), value=v)
79+ return v
80
Menu

Theme