SPB Git

spb/findata

Public

AI assistant (config-gated, validated SQL only), EXPLAIN in sandbox

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

18 changed files +1,239 −12

added apps/web/README.md +43 −0
@@ -0,0 +1,43 @@
1 +# FinData OS — web console
2 +
3 +Private operator console for the FinData OS lakehouse. Next.js 15 (App Router) + TypeScript + Tailwind CSS v4, built as a **static export** and served by the Python API — no Node process in production.
4 +
5 +## Develop
6 +
7 +```bash
8 +cd apps/web
9 +pnpm install
10 +pnpm dev # http://localhost:3000 — /api/* is proxied to http://127.0.0.1:8600 (next.config.ts rewrites, dev only)
11 +pnpm typecheck # tsc --noEmit
12 +```
13 +
14 +Start the API locally (`uv run uvicorn findata_api.main:app --port 8600`) before using the dev server; the console authenticates with the API's cookie session and sends `X-CSRF-Token` on every non-GET request.
15 +
16 +## Build & serve
17 +
18 +```bash
19 +pnpm build # → apps/web/out/ (index.html + one directory per route, trailing slashes)
20 +```
21 +
22 +`services/api/src/findata_api/main.py` mounts `apps/web/out` (or `FINDATA_WEB_DIST`) with `StaticFiles(html=True)`, so `/datasets/` serves `out/datasets/index.html`, and the console is same-origin with `/api/v1`. The CSP is `default-src 'self'` (inline styles allowed, inline scripts not); the bundle contains no inline scripts, external fonts or CDNs.
23 +
24 +Detail pages use query strings instead of dynamic segments (static export cannot pre-render unknown ids): `/datasets/detail/?id=…`, `/symbols/detail/?symbol=…`.
25 +
26 +## Layout
27 +
28 +```
29 +src/lib/api.ts fetch wrapper (same-origin cookies, CSRF header, error normalisation) + API types
30 +src/lib/auth.tsx /auth/me context, redirects to /login/ (401) or the MFA step
31 +src/lib/hooks.ts useAsync, useDebounced, useQueryParam, useInterval, useLocalStorage
32 +src/lib/format.ts bytes, thin-space numbers, YYYY-MM-DD HH:mm:ssZ, CSV
33 +src/components/ Shell (sidebar ≥1024px, bottom tabs + drawer on mobile), DataTable (cards <640px), charts, dialogs, toast
34 +src/app/<route>/page.tsx one client page per route
35 +```
36 +
37 +Routes: `/` overview · `/login/` · `/datasets/` (+`detail/`) · `/symbols/` (+`detail/`) · `/coverage/` · `/bars/` · `/ingestion/` · `/quality/` · `/storage/` · `/keys/` · `/playground/` · `/exports/` · `/fundamentals/` · `/docs/` · `/audit/` · `/settings/`.
38 +
39 +Owner-only pages (ingestion, storage, keys, audit, environment settings) are hidden from the navigation for other roles and show a notice if opened directly; the API enforces the `admin` scope regardless.
40 +
41 +## Design
42 +
43 +Brand-neutral palette validated with the `dataviz` skill (categorical blue/orange/aqua, sequential blue ramp for the coverage heatmap, fixed status colours with icon + label), light and dark via `prefers-color-scheme`, system font stack. Charts: `recharts` (one axis per chart, 2 px lines, ≤24 px bars, hover tooltips) and plain SVG for the heatmap.
modified apps/web/package.json +1 −1
@@ -5,7 +5,7 @@
5 5 "description": "FinData OS — private web console (static export served by the Python API)",
6 6 "scripts": {
7 7 "dev": "next dev -p 3000",
8 − "build": "next build",
8 + "build": "next build && node scripts/externalize-inline-scripts.mjs",
9 9 "typecheck": "tsc --noEmit",
10 10 "lint": "next lint"
11 11 },
added apps/web/scripts/externalize-inline-scripts.mjs +66 −0
@@ -0,0 +1,66 @@
1 +#!/usr/bin/env node
2 +/**
3 + * Post-build step for the static export.
4 + *
5 + * The API serves the console with `Content-Security-Policy: script-src 'self'` (no 'unsafe-inline').
6 + * Next.js App Router emits the React Server Components payload as inline
7 + * `<script>self.__next_f.push([...])</script>` tags, which such a CSP blocks — hydration would never run.
8 + *
9 + * This script rewrites every `out/**\/*.html`: the inline scripts (all of them are `__next_f` pushes) are
10 + * concatenated, in document order, into one external file `out/_next/static/flight/<hash>.js`, and a single
11 + * blocking `<script src=…>` is inserted where the first inline script was. Pushing the flight data from an
12 + * external file is order-independent for Next's client runtime (it buffers pushes until the app chunk takes
13 + * over and closes the stream on DOMContentLoaded, which a blocking script always precedes).
14 + *
15 + * Fails loudly if an inline script that is not a `__next_f` push is found, so the CSP contract stays explicit.
16 + */
17 +import { createHash } from "node:crypto";
18 +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
19 +import { dirname, join, relative } from "node:path";
20 +import { fileURLToPath } from "node:url";
21 +
22 +const root = join(dirname(fileURLToPath(import.meta.url)), "..");
23 +const out = join(root, "out");
24 +const flightDir = join(out, "_next", "static", "flight");
25 +
26 +function walk(dir, acc = []) {
27 + for (const name of readdirSync(dir)) {
28 + const p = join(dir, name);
29 + const st = statSync(p);
30 + if (st.isDirectory()) {
31 + if (name === "_next") continue;
32 + walk(p, acc);
33 + } else if (name.endsWith(".html")) acc.push(p);
34 + }
35 + return acc;
36 +}
37 +
38 +const INLINE = /<script>([\s\S]*?)<\/script>/g;
39 +let files = 0, scripts = 0, bytes = 0;
40 +mkdirSync(flightDir, { recursive: true });
41 +
42 +for (const file of walk(out)) {
43 + const html = readFileSync(file, "utf8");
44 + const chunks = [];
45 + let first = -1;
46 + for (const m of html.matchAll(INLINE)) {
47 + const body = m[1];
48 + if (!/^\s*\(?self\.__next_f/.test(body)) {
49 + throw new Error(`${relative(root, file)}: unexpected inline <script> (not a __next_f push):\n${body.slice(0, 200)}`);
50 + }
51 + if (first < 0) first = m.index;
52 + chunks.push(body.trim().replace(/;?$/, ";"));
53 + }
54 + if (!chunks.length) continue;
55 + const js = chunks.join("\n") + "\n";
56 + const hash = createHash("sha256").update(js).digest("hex").slice(0, 16);
57 + const name = `${hash}.js`;
58 + writeFileSync(join(flightDir, name), js);
59 + let i = 0;
60 + const rewritten = html.replace(INLINE, () => (i++ === 0 ? `<script src="/_next/static/flight/${name}"></script>` : ""));
61 + writeFileSync(file, rewritten);
62 + files++;
63 + scripts += chunks.length;
64 + bytes += js.length;
65 +}
66 +console.log(`externalize-inline-scripts: ${files} html files, ${scripts} inline scripts moved to _next/static/flight (${(bytes / 1024).toFixed(1)} kB)`);
added apps/web/src/app/audit/page.tsx +85 −0
@@ -0,0 +1,85 @@
1 +"use client";
2 +import { useState } from "react";
3 +import { RefreshCw } from "lucide-react";
4 +import { api, type AuditRow } from "@/lib/api";
5 +import { useAsync, useDebounced } from "@/lib/hooks";
6 +import { useAuth } from "@/lib/auth";
7 +import { fmtInt, fmtTs } from "@/lib/format";
8 +import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";
9 +import { DataTable } from "@/components/DataTable";
10 +import { JsonView } from "@/components/JsonView";
11 +
12 +const ACTIONS = ["", "auth.", "auth.login", "auth.mfa", "admin.", "admin.enqueue", "admin.key_create", "admin.key_revoke", "export.", "query.sql"];
13 +const HIDDEN = new Set(["id", "at", "action", "actor", "principal", "target", "outcome", "ip", "request_id", "detail", "user_agent"]);
14 +
15 +export default function AuditPage() {
16 + const { isOwner } = useAuth();
17 + const [action, setAction] = useState("");
18 + const [limit, setLimit] = useState(200);
19 + const da = useDebounced(action.trim(), 300);
20 + const { data, error, loading, reload } = useAsync((s) => api.get<{ data: AuditRow[] }>("/admin/audit", { action: da || undefined, limit }, s), [da, limit], { enabled: isOwner });
21 +
22 + if (!isOwner) return <Banner tone="warn">The audit log is restricted to the owner.</Banner>;
23 +
24 + return (
25 + <>
26 + <PageHeader
27 + title="Audit log"
28 + subtitle="Every authentication event, admin action, export and SQL query, with request ids."
29 + actions={
30 + <>
31 + <Field label="Action prefix" className="w-48">
32 + <input className="input py-1 font-mono text-xs" list="audit-actions" value={action} onChange={(e) => setAction(e.target.value)} placeholder="admin." />
33 + <datalist id="audit-actions">
34 + {ACTIONS.filter(Boolean).map((a) => (
35 + <option key={a} value={a} />
36 + ))}
37 + </datalist>
38 + </Field>
39 + <Field label="Limit" className="w-24">
40 + <select className="input py-1 text-xs" value={limit} onChange={(e) => setLimit(Number(e.target.value))}>
41 + {[100, 200, 500, 1000, 2000].map((n) => (
42 + <option key={n} value={n}>
43 + {n}
44 + </option>
45 + ))}
46 + </select>
47 + </Field>
48 + <button className="btn btn-sm self-end" onClick={reload}>
49 + <RefreshCw className="h-3.5 w-3.5" /> Refresh
50 + </button>
51 + </>
52 + }
53 + />
54 + {error && <ErrorState error={error} retry={reload} />}
55 + <Card>
56 + <DataTable
57 + rows={data?.data}
58 + loading={loading}
59 + rowKey={(r, i) => String(r.id ?? `${r.at}-${i}`)}
60 + mobile="cards"
61 + maxHeight="75dvh"
62 + columns={[
63 + { key: "at", header: "At (UTC)", render: (r) => <span className="tnum text-xs whitespace-nowrap">{fmtTs(r.at)}</span> },
64 + { key: "action", header: "Action", mono: true },
65 + { key: "actor", header: "Actor", render: (r) => <span className="text-xs">{String(r.actor ?? r.principal ?? "—")}</span> },
66 + { key: "target", header: "Target", render: (r) => <span className="font-mono text-xs break-all">{r.target ?? "—"}</span> },
67 + { key: "outcome", header: "Outcome", render: (r) => (r.outcome ? <StatusBadge status={r.outcome === "denied" ? "denied" : r.outcome === "ok" ? "ok" : r.outcome} label={r.outcome} /> : <span className="text-ink-3">—</span>) },
68 + { key: "ip", header: "IP", hideSm: true, mono: true },
69 + {
70 + key: "detail",
71 + header: "Detail",
72 + render: (r) => {
73 + const d = r.detail ?? Object.fromEntries(Object.entries(r).filter(([k]) => !HIDDEN.has(k)));
74 + return Object.keys(d).length ? <JsonView value={d} collapsed /> : <span className="text-ink-3">—</span>;
75 + },
76 + },
77 + { key: "request_id", header: "Request", hideSm: true, render: (r) => <span className="font-mono text-[11px] text-ink-3">{r.request_id ?? "—"}</span> },
78 + ]}
79 + empty="No audit entries match."
80 + footer={<span>{fmtInt(data?.data.length ?? 0)} entries</span>}
81 + />
82 + </Card>
83 + </>
84 + );
85 +}
modified apps/web/src/app/bars/page.tsx +2 −2
@@ -24,7 +24,7 @@ export default function BarsPage() {
24 24 const [out, setOut] = useState<BarsOut | null>(null);
25 25 const [rows, setRows] = useState<Bar[]>([]);
26 26 const [busy, setBusy] = useState(false);
27 − const [error, setError] = useState<unknown>(null);
27 + const [error, setError] = useState<Error | null>(null);
28 28 const [view, setView] = useState<"chart" | "table">("chart");
29 29 const toast = useToast();
30 30
@@ -47,7 +47,7 @@ export default function BarsPage() {
47 47 setOut(o);
48 48 setRows(o.data);
49 49 } catch (err) {
50 − setError(err);
50 + setError(err instanceof Error ? err : new Error(String(err)));
51 51 setOut(null);
52 52 setRows([]);
53 53 } finally {
added apps/web/src/app/docs/page.tsx +239 −0
@@ -0,0 +1,239 @@
1 +"use client";
2 +import Link from "next/link";
3 +import { ExternalLink } from "lucide-react";
4 +import { Banner, Card, PageHeader } from "@/components/ui";
5 +
6 +const BAR_COLUMNS: [string, string, string][] = [
7 + ["instrument_id", "string", "Stable id, e.g. firstrate:stock:AAPL"],
8 + ["symbol_as_reported", "string", "Ticker as delivered by the vendor at that date"],
9 + ["venue", "string?", "Exchange / venue when known"],
10 + ["asset_class", "string", "stock, etf, index, crypto, fx, futures"],
11 + ["interval", "string", "1m, 5m, 1d"],
12 + ["timestamp_utc", "timestamp[us, UTC]", "Bar OPEN instant, UTC"],
13 + ["session_date", "date", "US trading session (America/New_York)"],
14 + ["open / high / low / close", "float64", "Raw (as traded) prices"],
15 + ["volume", "float64", "Shares/contracts; zero-volume bars are absent"],
16 + ["currency", "string", "ISO 4217 (USD)"],
17 + ["trade_count", "int32?", "When supplied by the vendor"],
18 + ["vwap", "float64?", "When supplied by the vendor"],
19 + ["adjustment_type", "string", "Always raw in storage"],
20 + ["source_id", "string", "Source / product that produced the row"],
21 + ["ingested_at", "timestamp[us, UTC]", "Load instant"],
22 + ["quality_flags", "list<string>", "e.g. ohlc_inconsistent, price_jump, outside_session"],
23 + ["revision_id", "int32", "Increments when the vendor re-delivers a bar"],
24 +];
25 +
26 +export default function DocsPage() {
27 + return (
28 + <>
29 + <PageHeader
30 + title="Documentation"
31 + subtitle="What FinData OS is, the data contract, licensing and how to use the API."
32 + actions={
33 + <>
34 + <a className="btn btn-sm" href="/api/docs" target="_blank" rel="noopener">
35 + Swagger UI <ExternalLink className="h-3.5 w-3.5" />
36 + </a>
37 + <a className="btn btn-sm" href="/api/v1/openapi.json" target="_blank" rel="noopener">
38 + OpenAPI JSON <ExternalLink className="h-3.5 w-3.5" />
39 + </a>
40 + </>
41 + }
42 + />
43 + <div className="prose-sm grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
44 + <nav className="card hidden h-fit lg:sticky lg:top-6 lg:block" aria-label="On this page">
45 + <ul className="flex flex-col gap-1 text-sm">
46 + {[["#platform", "The platform"], ["#contract", "Data contract"], ["#bars", "Bars columns"], ["#adjustments", "Adjustments"], ["#fundamentals", "Fundamentals (PIT)"], ["#license", "License"], ["#api", "API quick start"], ["#sdk", "Python SDK"], ["#limits", "Limits"], ["#limitations", "Limitations"]].map(([h, l]) => (
47 + <li key={h}>
48 + <a href={h} className="nav-link">
49 + {l}
50 + </a>
51 + </li>
52 + ))}
53 + </ul>
54 + </nav>
55 + <div className="flex min-w-0 flex-col gap-4">
56 + <Card title="The platform" id="platform">
57 + <p className="text-sm">
58 + <strong>FinData OS</strong> is a private, research-grade financial data lakehouse. It ingests vendor files (FirstRate Data intraday bars, corporate actions and fundamentals; SEC EDGAR when configured) into an immutable Parquet lake with a
59 + Postgres catalog, versions every dataset, checks quality at load time, and serves the result through one HTTP API that this console, the Python SDK and ad-hoc SQL all share. One writer (the ingest worker) owns the lake; the API is read-only
60 + except for job control, keys and settings.
61 + </p>
62 + <ul className="mt-2 list-disc pl-5 text-sm text-ink-2">
63 + <li>Curated layer: Parquet files per dataset, <code>base</code> files plus <code>delta</code> appends, compacted into new versions.</li>
64 + <li>Raw layer: vendor archives kept for re-processing (hash-verified).</li>
65 + <li>Catalog: datasets, versions, files, coverage per (symbol, session), instruments, corporate actions, jobs, exports, audit.</li>
66 + </ul>
67 + </Card>
68 +
69 + <Card title="Data contract" id="contract">
70 + <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[auto_1fr]">
71 + <dt className="font-medium">Timestamp convention</dt>
72 + <dd>
73 + Every bar is stamped at its <strong>open</strong>: the 1-minute bar labelled 13:30:00Z covers 13:30:00 → 13:30:59.999 UTC.
74 + </dd>
75 + <dt className="font-medium">Storage timezone</dt>
76 + <dd>
77 + <code>timestamp_utc</code> is UTC. <code>session_date</code> is the US trading session in America/New_York; it is the key for coverage and expected-bar counts.
78 + </dd>
79 + <dt className="font-medium">Session span</dt>
80 + <dd>
81 + Bars are retained for the extended session <strong>04:00 – 20:00 ET</strong>; regular hours are 09:30 – 16:00 ET. Completeness = bars ÷ expected regular-hours bars, capped at 100 %.
82 + </dd>
83 + <dt className="font-medium">Zero-volume bars</dt>
84 + <dd>Minutes without a trade are <strong>absent</strong>, not stored as zero rows. A gap is not necessarily a vendor outage — check the coverage page.</dd>
85 + <dt className="font-medium">Adjustments</dt>
86 + <dd>
87 + Stored prices are <strong>raw</strong>. <code>split</code> and <code>split_div</code> are computed at query time from the corporate-actions table (ex-date based, backward factor), so a new split re-adjusts history without rewriting
88 + files. The API echoes <code>adjustment_notes</code> explaining what was applied.
89 + </dd>
90 + <dt className="font-medium">Versioning</dt>
91 + <dd>
92 + Each dataset has a <code>current_version</code> with a snapshot id. Exports pin the version at submission; <code>GET /market/bars?version=</code> can read an earlier one.
93 + </dd>
94 + <dt className="font-medium">Quality flags</dt>
95 + <dd>Rows are never silently dropped; suspicious bars carry <code>quality_flags</code> and are also recorded as anomalies.</dd>
96 + </dl>
97 + </Card>
98 +
99 + <Card title="Bars schema (findata.schema = bars, version 1)" id="bars">
100 + <div className="table-wrap">
101 + <table className="table">
102 + <thead>
103 + <tr>
104 + <th>Column</th>
105 + <th>Arrow type</th>
106 + <th>Meaning</th>
107 + </tr>
108 + </thead>
109 + <tbody>
110 + {BAR_COLUMNS.map(([c, t, m]) => (
111 + <tr key={c}>
112 + <td className="font-mono text-xs">{c}</td>
113 + <td className="font-mono text-xs text-ink-2">{t}</td>
114 + <td className="text-xs">{m}</td>
115 + </tr>
116 + ))}
117 + </tbody>
118 + </table>
119 + </div>
120 + <p className="mt-2 text-xs text-ink-3">
121 + The authoritative schema is served by <code>GET /api/v1/catalog/schema/bars</code>. In the SQL playground each dataset is a DuckDB view named after its id with dots replaced by underscores, e.g. <code>market_bars_stock_1m_raw</code>.
122 + </p>
123 + </Card>
124 +
125 + <Card title="Adjustments in practice" id="adjustments">
126 + <ul className="list-disc pl-5 text-sm">
127 + <li>
128 + <code>raw</code> — prices as traded; volumes as reported. Use for microstructure work and when you join your own actions.
129 + </li>
130 + <li>
131 + <code>split</code> — prices divided and volumes multiplied by the cumulative split factor of all splits with ex-date after the bar.
132 + </li>
133 + <li>
134 + <code>split_div</code> — additionally applies the cash-dividend backward factor <em>(1 − D / P<sub>close, prev</sub>)</em>. Total-return style; not suitable for tick-size studies.
135 + </li>
136 + </ul>
137 + </Card>
138 +
139 + <Card title="Fundamentals — point in time" id="fundamentals">
140 + <p className="text-sm">
141 + Every fact carries <code>available_at</code> (= filing date + 1 day 00:00 America/New_York, a conservative knowability instant) and <code>observed_at</code> (when this platform first saw the value). A request with <code>as_of</code> returns
142 + only facts with <code>available_at ≤ as_of</code>; among revisions, the first observed value is used unless a later revision was observed before <code>as_of</code>. The response counts <code>facts_hidden_by_as_of</code> so you can tell how much of the
143 + record you are not seeing.
144 + </p>
145 + </Card>
146 +
147 + <Card title="License and redistribution" id="license">
148 + <Banner tone="warn">
149 + <strong>FirstRate Data — private use only, no redistribution.</strong>
150 + </Banner>
151 + <ul className="mt-3 list-disc pl-5 text-sm">
152 + <li>Raw bars, exports, SQL results and tables derived row-for-row from them may be used by the subscriber only. They may <strong>not</strong> be published, shared, re-hosted or served through a public API.</li>
153 + <li>Aggregated results (charts, statistics, model outputs) may be published with attribution to FirstRate Data.</li>
154 + <li>SEC EDGAR content is U.S. public domain; the SEC fair-access policy (declared User-Agent, request rate) applies to retrieval.</li>
155 + <li>API keys are personal. Every download, export and query is written to the audit log with the principal and request id.</li>
156 + </ul>
157 + </Card>
158 +
159 + <Card title="API quick start" id="api">
160 + <p className="text-sm">All endpoints live under <code>/api/v1</code>. Authenticate with the console session (cookie + <code>X-CSRF-Token</code> on writes) or with an API key: <code>Authorization: Bearer fd_…</code>. Errors are JSON: <code>{`{"error":{"code","message","request_id"}}`}</code>.</p>
161 + <pre className="mt-2 overflow-auto rounded-md border border-hairline bg-surface-2 p-3 text-xs leading-relaxed">{`export FINDATA_URL="https://findata.spboucher.ai"
162 +export FINDATA_API_KEY="fd_xxx.yyy"
163 +
164 +# health + catalog
165 +curl -s -H "Authorization: Bearer $FINDATA_API_KEY" "$FINDATA_URL/api/v1/health" | jq .status
166 +curl -s -H "Authorization: Bearer $FINDATA_API_KEY" "$FINDATA_URL/api/v1/catalog/datasets" | jq '.data[].id'
167 +
168 +# one week of 1-minute bars, split-adjusted, as CSV (≤ 31 days per call for 1m)
169 +curl -s -H "Authorization: Bearer $FINDATA_API_KEY" \\
170 + "$FINDATA_URL/api/v1/market/bars?symbol=AAPL&interval=1m&start=2026-10-20&end=2026-10-24&adjustment=split&format=csv&limit=10000" -o aapl.csv
171 +
172 +# bigger ranges: async export → poll → download
173 +curl -s -X POST -H "Authorization: Bearer $FINDATA_API_KEY" -H "Content-Type: application/json" \\
174 + -d '{"dataset_id":"market_bars.stock.1m.raw","symbols":["AAPL","MSFT"],"start":"2024-01-01","end":"2026-10-30","format":"parquet"}' \\
175 + "$FINDATA_URL/api/v1/exports"
176 +# → {"id":"…","status":"queued","poll":"/api/v1/exports/…"}; when status=ready: GET /api/v1/exports/{id}/download
177 +
178 +# point-in-time fundamentals
179 +curl -s -H "Authorization: Bearer $FINDATA_API_KEY" \\
180 + "$FINDATA_URL/api/v1/fundamentals/AAPL?as_of=2025-01-01T00:00:00Z&metrics=rev,net_inc&period_type=q"
181 +
182 +# read-only SQL (DuckDB)
183 +curl -s -X POST -H "Authorization: Bearer $FINDATA_API_KEY" -H "Content-Type: application/json" \\
184 + -d '{"sql":"SELECT session_date, sum(volume) v FROM market_bars_stock_1m_raw WHERE symbol_as_reported='"'"'AAPL'"'"' GROUP BY 1 ORDER BY 1 DESC LIMIT 5","max_rows":100}' \\
185 + "$FINDATA_URL/api/v1/query"`}</pre>
186 + </Card>
187 +
188 + <Card title="Python SDK" id="sdk">
189 + <p className="text-sm">
190 + Install from the repo (<code>packages/python-sdk</code>): <code>pip install -e packages/python-sdk</code>. The client pages through Arrow IPC automatically.
191 + </p>
192 + <pre className="mt-2 overflow-auto rounded-md border border-hairline bg-surface-2 p-3 text-xs leading-relaxed">{`from findata_sdk import FinData
193 +
194 +fd = FinData("https://findata.spboucher.ai", api_key="fd_xxx.yyy") # or FINDATA_URL / FINDATA_API_KEY env vars
195 +
196 +bars = fd.bars("AAPL", "1m", "2026-10-26", "2026-10-30", adjustment="split") # pyarrow.Table, all pages
197 +df = bars.to_pandas() # or .to_polars()
198 +
199 +path = fd.export(["AAPL", "MSFT"], "2024-01-01", "2026-10-30", format="parquet") # waits, downloads, returns local path
200 +
201 +facts = fd.fundamentals("AAPL", as_of="2025-01-01T00:00:00Z", metrics=["rev", "net_inc"])
202 +print(fd.datasets()[0]["id"], fd.health()["status"])`}</pre>
203 + </Card>
204 +
205 + <Card title="Limits" id="limits">
206 + <ul className="list-disc pl-5 text-sm">
207 + <li>
208 + <code>GET /market/bars</code>: span ≤ <strong>31 days</strong> for 1m, 186 days for 5m, 3 660 days for 1d; ≤ 10 000 rows per page (cursor pagination). HTTP 413 <code>range_too_large</code> otherwise → use{" "}
209 + <Link href="/exports/" className="text-accent-ink hover:underline">
210 + Exports
211 + </Link>
212 + .
213 + </li>
214 + <li>Exports: ≤ 500 symbols, ≤ 12 years, 3 concurrent per principal, files expire after ~72 h.</li>
215 + <li>SQL: one read-only statement, server-side timeout, ≤ 100 000 rows; the console displays at most 1 000 rows (download the CSV for the rest).</li>
216 + <li>Rate limit per key/session (default 600 requests/min, HTTP 429 with <code>Retry-After</code>).</li>
217 + </ul>
218 + </Card>
219 +
220 + <Card title="Known limitations" id="limitations">
221 + <ul className="list-disc pl-5 text-sm">
222 + <li>
223 + <strong>Fundamentals revision history starts at first ingestion.</strong> Values observed before the platform existed are a single revision (proxy for “as originally filed”); true as-filed history for older periods is not reconstructed.
224 + </li>
225 + <li>
226 + <strong>SEC EDGAR is “pending configuration”</strong> until a fair-access User-Agent is set on the server (<code>SEC_EDGAR_USER_AGENT</code>). Until then the dataset is listed but empty.
227 + </li>
228 + <li>
229 + <strong>Unsupported:</strong> tick data, quotes/NBBO, order-book depth, options chains. Those datasets appear as <em>unsupported</em> in the catalog on purpose so nobody plans on them.
230 + </li>
231 + <li>Vendor files arrive with a lag (typically next day); the overview shows <em>vendor lag</em> and <em>days since last session</em> per dataset.</li>
232 + <li>The console is a static bundle served by the API; it needs JavaScript and a modern browser. No telemetry, no third-party assets.</li>
233 + </ul>
234 + </Card>
235 + </div>
236 + </div>
237 + </>
238 + );
239 +}
added apps/web/src/app/fundamentals/page.tsx +248 −0
@@ -0,0 +1,248 @@
1 +"use client";
2 +import { useEffect, useMemo, useState, type FormEvent } from "react";
3 +import { Play } from "lucide-react";
4 +import clsx from "clsx";
5 +import { api, type Fact, type FundamentalsOut, type PeriodsOut } from "@/lib/api";
6 +import { useQueryParam } from "@/lib/hooks";
7 +import { fmtCompact, fmtInt, fmtNum, fmtTs, nowLocalInput } from "@/lib/format";
8 +import { Banner, Card, Empty, ErrorState, Field, PageHeader, StatusBadge, Tabs } from "@/components/ui";
9 +import { DataTable } from "@/components/DataTable";
10 +
11 +const DEFAULT_PIVOT = 6;
12 +
13 +export default function FundamentalsPage() {
14 + const qTicker = useQueryParam("ticker");
15 + const [ticker, setTicker] = useState("AAPL");
16 + const [asOf, setAsOf] = useState(nowLocalInput());
17 + const [periodType, setPeriodType] = useState<"" | "q" | "a">("q");
18 + const [metrics, setMetrics] = useState("");
19 + const [out, setOut] = useState<FundamentalsOut | null>(null);
20 + const [periods, setPeriods] = useState<PeriodsOut | null>(null);
21 + const [busy, setBusy] = useState(false);
22 + const [error, setError] = useState<Error | null>(null);
23 + const [tab, setTab] = useState<"pivot" | "facts" | "periods" | "earnings">("pivot");
24 + const [pivotMetrics, setPivotMetrics] = useState<string[]>([]);
25 +
26 + useEffect(() => {
27 + if (qTicker) setTicker(qTicker.toUpperCase());
28 + }, [qTicker]);
29 +
30 + async function run(e?: FormEvent) {
31 + e?.preventDefault();
32 + setBusy(true);
33 + setError(null);
34 + try {
35 + const asOfIso = asOf ? new Date(asOf).toISOString() : undefined;
36 + const t = ticker.trim();
37 + const [f, p] = await Promise.all([
38 + api.get<FundamentalsOut>(`/fundamentals/${encodeURIComponent(t)}`, { as_of: asOfIso, metrics: metrics.trim() || undefined, period_type: periodType || undefined, limit: 5000 }),
39 + api.get<PeriodsOut>(`/fundamentals/${encodeURIComponent(t)}/periods`).catch(() => null),
40 + ]);
41 + setOut(f);
42 + setPeriods(p);
43 + const all = Array.from(new Set(f.data.map((x) => x.metric)));
44 + setPivotMetrics((cur) => (cur.length ? cur.filter((m) => all.includes(m)) : all.slice(0, DEFAULT_PIVOT)));
45 + } catch (err) {
46 + setError(err instanceof Error ? err : new Error(String(err)));
47 + setOut(null);
48 + setPeriods(null);
49 + } finally {
50 + setBusy(false);
51 + }
52 + }
53 +
54 + const allMetrics = useMemo(() => Array.from(new Set((out?.data ?? []).map((x) => x.metric))).sort(), [out]);
55 + const pivot = useMemo(() => {
56 + const rows = new Map<string, { period: string; type: string; fy: number | null; fp: string | null; filing: string | null; values: Record<string, Fact> }>();
57 + for (const f of out?.data ?? []) {
58 + const key = `${f.fiscal_period_end}|${f.fiscal_period_type}`;
59 + const r = rows.get(key) ?? { period: f.fiscal_period_end, type: f.fiscal_period_type, fy: f.fiscal_year, fp: f.fiscal_period, filing: f.filing_date, values: {} };
60 + r.values[f.metric] = f;
61 + rows.set(key, r);
62 + }
63 + return Array.from(rows.values()).sort((a, b) => b.period.localeCompare(a.period));
64 + }, [out]);
65 +
66 + return (
67 + <>
68 + <PageHeader title="Fundamentals" subtitle="Point-in-time facts: only what was knowable at the chosen instant is returned." />
69 + <Card className="mb-4">
70 + <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-5 md:items-end">
71 + <Field label="Ticker or entity id">
72 + <input className="input font-mono uppercase" value={ticker} onChange={(e) => setTicker(e.target.value)} required placeholder="AAPL or frd:AAPL" />
73 + </Field>
74 + <Field label="As of (local time → UTC)">
75 + <input type="datetime-local" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
76 + </Field>
77 + <Field label="Period type">
78 + <select className="input" value={periodType} onChange={(e) => setPeriodType(e.target.value as typeof periodType)}>
79 + <option value="q">quarterly</option>
80 + <option value="a">annual</option>
81 + <option value="">both</option>
82 + </select>
83 + </Field>
84 + <Field label="Metrics (comma-separated)">
85 + <input className="input font-mono text-xs" value={metrics} onChange={(e) => setMetrics(e.target.value)} placeholder="rev, net_inc, eps" />
86 + </Field>
87 + <button className="btn btn-primary" disabled={busy || !ticker.trim()}>
88 + <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"}
89 + </button>
90 + </form>
91 + </Card>
92 +
93 + {error && <ErrorState error={error} retry={() => void run()} />}
94 +
95 + {out && (
96 + <>
97 + <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
98 + <span className="pill bg-surface-2 text-ink-2 tnum">{fmtInt(out.meta.rows)} facts</span>
99 + <span className="pill bg-surface-2 text-ink-2 tnum">as of {fmtTs(out.meta.as_of)}</span>
100 + {out.meta.facts_hidden_by_as_of > 0 && <span className="pill bg-warn-soft text-warn-ink tnum">{fmtInt(out.meta.facts_hidden_by_as_of)} facts hidden by as_of</span>}
101 + {out.entity && <span className="pill bg-surface-2 text-ink-2">{String(out.entity.name ?? out.entity.entity_id ?? "")}</span>}
102 + <span className="ml-auto">
103 + <Tabs value={tab} onChange={setTab} items={[{ value: "pivot", label: "Pivot" }, { value: "facts", label: "Raw facts" }, { value: "periods", label: `Periods (${periods?.periods.length ?? 0})` }, { value: "earnings", label: `Earnings (${periods?.earnings.length ?? 0})` }]} />
104 + </span>
105 + </div>
106 + <Banner tone="accent" className="mb-4">
107 + <span className="text-xs">PIT policy: {out.meta.pit_policy}. Revision history exists only from the first ingestion by this platform.</span>
108 + </Banner>
109 +
110 + {tab === "pivot" && (
111 + <Card title="Periods × metrics" actions={<span className="text-xs text-ink-3">{pivotMetrics.length} of {allMetrics.length} metrics</span>}>
112 + <div className="mb-3 flex flex-wrap gap-1">
113 + {allMetrics.map((m) => {
114 + const on = pivotMetrics.includes(m);
115 + return (
116 + <button key={m} className={clsx("pill border font-mono", on ? "border-accent bg-accent-soft text-accent-ink" : "border-border bg-surface-1 text-ink-2 hover:bg-surface-3")} onClick={() => setPivotMetrics((cur) => (on ? cur.filter((x) => x !== m) : [...cur, m]))} aria-pressed={on}>
117 + {m}
118 + </button>
119 + );
120 + })}
121 + </div>
122 + {pivot.length === 0 ? (
123 + <Empty>No facts visible at this as-of instant.</Empty>
124 + ) : (
125 + <div className="table-wrap" style={{ maxHeight: "60dvh", overflowY: "auto" }}>
126 + <table className="table">
127 + <thead>
128 + <tr>
129 + <th>Period end</th>
130 + <th>FY / FP</th>
131 + <th>Filed</th>
132 + {pivotMetrics.map((m) => (
133 + <th key={m} className="text-right font-mono">
134 + {m}
135 + </th>
136 + ))}
137 + </tr>
138 + </thead>
139 + <tbody>
140 + {pivot.map((r) => (
141 + <tr key={`${r.period}-${r.type}`}>
142 + <td className="tnum whitespace-nowrap">
143 + {r.period} <span className="text-ink-3">{r.type}</span>
144 + </td>
145 + <td className="tnum text-xs text-ink-2 whitespace-nowrap">
146 + {r.fy ?? "—"} / {r.fp ?? "—"}
147 + </td>
148 + <td className="tnum text-xs text-ink-2 whitespace-nowrap">{r.filing ?? "—"}</td>
149 + {pivotMetrics.map((m) => {
150 + const f = r.values[m];
151 + return (
152 + <td key={m} className="num whitespace-nowrap" title={f ? `${f.value} ${f.unit} · rev ${f.revision_id}${f.restatement_flag ? " · restated" : ""}` : undefined}>
153 + {f ? (
154 + <>
155 + {fmtValue(f)}
156 + {f.revision_id > 1 && <sup className="ml-0.5 text-[9px] text-warn-ink">r{f.revision_id}</sup>}
157 + </>
158 + ) : (
159 + <span className="text-ink-3">—</span>
160 + )}
161 + </td>
162 + );
163 + })}
164 + </tr>
165 + ))}
166 + </tbody>
167 + </table>
168 + </div>
169 + )}
170 + <p className="mt-2 text-[11px] text-ink-3">Values compacted (K/M/B); hover for exact value and unit. <sup>rN</sup> marks a revision observed before as-of.</p>
171 + </Card>
172 + )}
173 +
174 + {tab === "facts" && (
175 + <Card title="Raw facts">
176 + <DataTable
177 + rows={out.data}
178 + rowKey={(f, i) => `${f.metric}-${f.fiscal_period_end}-${f.fiscal_period_type}-${f.revision_id}-${i}`}
179 + mobile="scroll"
180 + maxHeight="60dvh"
181 + columns={[
182 + { key: "metric", header: "Metric", mono: true },
183 + { key: "value", header: "Value", num: true, render: (f) => <span title={String(f.value)}>{fmtValue(f)}</span> },
184 + { key: "unit", header: "Unit", hideSm: true },
185 + { key: "fiscal_period_end", header: "Period end", render: (f) => <span className="tnum">{f.fiscal_period_end} <span className="text-ink-3">{f.fiscal_period_type}</span></span> },
186 + { key: "fy", header: "FY/FP", hideSm: true, render: (f) => `${f.fiscal_year ?? "—"}/${f.fiscal_period ?? "—"}` },
187 + { key: "filing_date", header: "Filed", render: (f) => <span className="tnum">{f.filing_date ?? "—"}</span> },
188 + { key: "available_at", header: "Available at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.available_at)}</span> },
189 + { key: "observed_at", header: "Observed at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.observed_at)}</span> },
190 + { key: "revision_id", header: "Rev", num: true },
191 + { key: "restatement_flag", header: "Restated", render: (f) => (f.restatement_flag ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) },
192 + { key: "source_document", header: "Source", hideSm: true, render: (f) => <span className="text-xs text-ink-2">{f.source_document ?? f.source_id}</span> },
193 + ]}
194 + />
195 + </Card>
196 + )}
197 +
198 + {tab === "periods" && (
199 + <Card title="Fiscal periods (all revisions, not PIT-filtered)">
200 + <DataTable
201 + rows={periods?.periods}
202 + rowKey={(p) => `${p.fiscal_period_end}-${p.fiscal_period_type}-${p.filing_date}`}
203 + mobile="scroll"
204 + maxHeight="60dvh"
205 + columns={[
206 + { key: "fiscal_period_end", header: "Period end", render: (p) => <span className="tnum">{p.fiscal_period_end} <span className="text-ink-3">{p.fiscal_period_type}</span></span> },
207 + { key: "fy", header: "FY/FP", render: (p) => `${p.fiscal_year ?? "—"}/${p.fiscal_period ?? "—"}` },
208 + { key: "filing_date", header: "Filed", render: (p) => <span className="tnum">{p.filing_date ?? "—"}</span> },
209 + { key: "available_at", header: "Available at", render: (p) => <span className="tnum text-xs">{fmtTs(p.available_at)}</span> },
210 + { key: "metrics", header: "Metrics", num: true, render: (p) => fmtInt(p.metrics) },
211 + { key: "max_revision", header: "Max rev", num: true },
212 + { key: "restated", header: "Restated", render: (p) => (p.restated ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) },
213 + ]}
214 + empty="No periods."
215 + />
216 + </Card>
217 + )}
218 +
219 + {tab === "earnings" && (
220 + <Card title="Earnings events">
221 + <DataTable
222 + rows={periods?.earnings}
223 + rowKey={(e) => e.event_date}
224 + mobile="scroll"
225 + maxHeight="60dvh"
226 + columns={[
227 + { key: "event_date", header: "Date", render: (e) => <span className="tnum">{e.event_date}</span> },
228 + { key: "release_time", header: "Release", render: (e) => e.release_time ?? "—" },
229 + { key: "eps_estimate", header: "EPS est.", num: true, render: (e) => fmtNum(e.eps_estimate, 2) },
230 + { key: "eps_reported", header: "EPS rep.", num: true, render: (e) => fmtNum(e.eps_reported, 2) },
231 + { key: "surprise", header: "Surprise", num: true, render: (e) => (e.eps_estimate !== null && e.eps_reported !== null ? fmtNum(e.eps_reported - e.eps_estimate, 2) : "—") },
232 + ]}
233 + empty="No earnings events."
234 + />
235 + </Card>
236 + )}
237 + </>
238 + )}
239 + </>
240 + );
241 +}
242 +
243 +function fmtValue(f: Fact): string {
244 + if (f.value === null || f.value === undefined) return "—";
245 + const u = (f.unit ?? "").toLowerCase();
246 + if (u.includes("ratio") || u.includes("per_share") || u === "pct" || u === "percent" || Math.abs(f.value) < 1000) return fmtNum(f.value, 2);
247 + return fmtCompact(f.value);
248 +}
modified apps/web/src/app/globals.css +1 −1
@@ -104,7 +104,7 @@ body {
104 104 ::selection { background: var(--accent-soft); }
105 105 a { color: inherit; }
106 106 code, pre, kbd, .mono { font-family: var(--font-mono); }
107 −.tnum { font-variant-numeric: tabular-nums; }
107 +@utility tnum { font-variant-numeric: tabular-nums; }
108 108
109 109 /* ------------------------------------------------------------------ components */
110 110 @layer components {
modified apps/web/src/app/playground/page.tsx +2 −2
@@ -16,7 +16,7 @@ export default function PlaygroundPage() {
16 16 const [maxRows, setMaxRows] = useState(1000);
17 17 const [out, setOut] = useState<QueryOut | null>(null);
18 18 const [busy, setBusy] = useState(false);
19 − const [error, setError] = useState<unknown>(null);
19 + const [error, setError] = useState<Error | null>(null);
20 20 const toast = useToast();
21 21 const ta = useRef<HTMLTextAreaElement>(null);
22 22 const views = useAsync((s) => api.get<{ views: { name: string; files: number }[]; examples: string[] }>("/query/views", undefined, s), []);
@@ -34,7 +34,7 @@ export default function PlaygroundPage() {
34 34 const o = await api.post<QueryOut>("/query", { sql, max_rows: maxRows });
35 35 setOut(o);
36 36 } catch (e) {
37 − setError(e);
37 + setError(e instanceof Error ? e : new Error(String(e)));
38 38 setOut(null);
39 39 } finally {
40 40 setBusy(false);
added apps/web/src/app/settings/page.tsx +199 −0
@@ -0,0 +1,199 @@
1 +"use client";
2 +/* eslint-disable @next/next/no-img-element */
3 +import { useEffect, useState, type FormEvent } from "react";
4 +import { QrCode, Radar, Save, ShieldCheck } from "lucide-react";
5 +import { api, type SettingsOut } from "@/lib/api";
6 +import { useAsync } from "@/lib/hooks";
7 +import { useAuth } from "@/lib/auth";
8 +import { useToast } from "@/lib/toast";
9 +import { fmtInt, fmtTs, splitList } from "@/lib/format";
10 +import { Banner, Card, Empty, ErrorState, Field, KV, PageHeader, StatusBadge } from "@/components/ui";
11 +import { JsonView } from "@/components/JsonView";
12 +import { CopyButton } from "@/components/CopyButton";
13 +
14 +export default function SettingsPage() {
15 + const { me, isOwner, refresh } = useAuth();
16 + const toast = useToast();
17 + const settings = useAsync((s) => api.get<SettingsOut>("/admin/settings", undefined, s), [], { enabled: isOwner });
18 +
19 + // TOTP
20 + const [setup, setSetup] = useState<{ otpauth_uri: string; qr_png_data_url: string; secret: string } | null>(null);
21 + const [code, setCode] = useState("");
22 + const [busy, setBusy] = useState(false);
23 +
24 + async function startSetup() {
25 + setBusy(true);
26 + try {
27 + setSetup(await api.post("/auth/totp/setup"));
28 + } catch (e) {
29 + toast.error(e);
30 + } finally {
31 + setBusy(false);
32 + }
33 + }
34 + async function verify(e: FormEvent) {
35 + e.preventDefault();
36 + setBusy(true);
37 + try {
38 + await api.post("/auth/totp/verify", { code: code.replace(/\s+/g, "") });
39 + toast.success("Two-factor authentication enabled");
40 + setSetup(null);
41 + setCode("");
42 + await refresh();
43 + } catch (err) {
44 + toast.error(err);
45 + } finally {
46 + setBusy(false);
47 + }
48 + }
49 +
50 + // Tracked tickers
51 + const [tickers, setTickers] = useState("");
52 + const [tickersDirty, setTickersDirty] = useState(false);
53 + useEffect(() => {
54 + const row = settings.data?.settings.find((s) => s.key === "fundamentals_tracked_tickers");
55 + if (row && !tickersDirty) setTickers(Array.isArray(row.value) ? (row.value as string[]).join("\n") : "");
56 + }, [settings.data, tickersDirty]);
57 + async function saveTickers() {
58 + setBusy(true);
59 + try {
60 + const out = await api.put<{ tickers: string[] }>("/admin/settings/tracked-tickers", { tickers: splitList(tickers) });
61 + toast.success("Tracked tickers saved", `${out.tickers.length} tickers`);
62 + setTickers(out.tickers.join("\n"));
63 + setTickersDirty(false);
64 + settings.reload();
65 + } catch (e) {
66 + toast.error(e);
67 + } finally {
68 + setBusy(false);
69 + }
70 + }
71 +
72 + // Discovery
73 + const [disc, setDisc] = useState<unknown>(null);
74 + async function discover() {
75 + setBusy(true);
76 + setDisc(null);
77 + try {
78 + setDisc(await api.post("/admin/discover"));
79 + toast.success("Discovery finished");
80 + } catch (e) {
81 + toast.error(e);
82 + } finally {
83 + setBusy(false);
84 + }
85 + }
86 +
87 + const env = settings.data?.env;
88 + const otherSettings = (settings.data?.settings ?? []).filter((s) => s.key !== "fundamentals_tracked_tickers");
89 +
90 + return (
91 + <>
92 + <PageHeader title="Settings" subtitle="Account security, environment flags and ingestion preferences" />
93 + <div className="grid gap-4 lg:grid-cols-2">
94 + <Card title="Account">
95 + <KV
96 + items={[
97 + ["E-mail", me?.email ?? "—"],
98 + ["Role", <StatusBadge key="r" status="neutral" label={me?.role ?? "—"} icon={false} />],
99 + ["Scopes", <div key="s" className="flex flex-wrap gap-1">{(me?.scopes ?? []).map((s) => <span key={s} className="pill bg-surface-2 text-ink-2">{s}</span>)}</div>],
100 + ["Two-factor (TOTP)", me?.totp_enabled ? <StatusBadge key="t" status="ok" label="enabled" /> : <StatusBadge key="t" status="warning" label="not enabled" />],
101 + ]}
102 + />
103 + <div className="mt-4 border-t border-hairline pt-3">
104 + {!setup ? (
105 + <button className="btn" onClick={startSetup} disabled={busy}>
106 + <ShieldCheck className="h-4 w-4" /> {me?.totp_enabled ? "Re-enrol authenticator" : "Enable two-factor authentication"}
107 + </button>
108 + ) : (
109 + <form onSubmit={verify} className="flex flex-col gap-3 sm:flex-row">
110 + <div className="shrink-0 rounded-md border border-border bg-white p-2 self-start">
111 + <img src={setup.qr_png_data_url} alt="TOTP enrolment QR code" width={160} height={160} />
112 + </div>
113 + <div className="flex flex-1 flex-col gap-2">
114 + <p className="text-sm text-ink-2">
115 + <QrCode className="mr-1 inline h-4 w-4" /> Scan with your authenticator, or enter the secret manually:
116 + </p>
117 + <div className="flex items-center gap-2">
118 + <code className="break-all rounded bg-surface-2 px-2 py-1 text-xs">{setup.secret}</code>
119 + <CopyButton text={setup.secret} />
120 + </div>
121 + <Field label="Code from the app">
122 + <input className="input font-mono tracking-[0.3em]" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} required />
123 + </Field>
124 + <div className="flex gap-2">
125 + <button className="btn btn-primary" disabled={busy || code.replace(/\s+/g, "").length < 6}>
126 + Verify & enable
127 + </button>
128 + <button type="button" className="btn" onClick={() => setSetup(null)}>
129 + Cancel
130 + </button>
131 + </div>
132 + </div>
133 + </form>
134 + )}
135 + </div>
136 + </Card>
137 +
138 + <Card title="Environment">
139 + {!isOwner && <Empty>Environment flags are visible to the owner only.</Empty>}
140 + {settings.error && <ErrorState error={settings.error} retry={settings.reload} />}
141 + {env && (
142 + <KV
143 + items={[
144 + ["Environment", <StatusBadge key="e" status={env.env === "production" ? "ok" : "warning"} label={env.env} />],
145 + ["Lake path", <code key="l" className="text-xs">{env.lake}</code>],
146 + ["Public URL", <a key="u" href={env.public_url} className="text-accent-ink hover:underline break-all" target="_blank" rel="noopener">{env.public_url}</a>],
147 + ["Backup target", <code key="b" className="text-xs">{env.backup_target}</code>],
148 + ["FirstRate Data vendor", <StatusBadge key="v" status={env.vendor_configured ? "ok" : "error"} label={env.vendor_configured ? "configured" : "not configured"} />],
149 + ["SEC EDGAR", <StatusBadge key="s" status={env.sec_edgar_configured ? "ok" : "pending_configuration"} label={env.sec_edgar_configured ? "configured" : "pending configuration"} />],
150 + ["AI providers", <span key="a" className="flex gap-1"><StatusBadge status={env.ai_providers.anthropic ? "ok" : "neutral"} label={`anthropic ${env.ai_providers.anthropic ? "yes" : "no"}`} icon={false} /><StatusBadge status={env.ai_providers.openai ? "ok" : "neutral"} label={`openai ${env.ai_providers.openai ? "yes" : "no"}`} icon={false} /></span>],
151 + ]}
152 + />
153 + )}
154 + <p className="mt-3 text-[11px] text-ink-3">Secrets are never sent to the browser; only configured yes/no flags are shown.</p>
155 + </Card>
156 +
157 + {isOwner && (
158 + <Card title="Tracked tickers (fundamentals)" actions={<span className="text-xs text-ink-3">{fmtInt(splitList(tickers).length)} tickers</span>}>
159 + <textarea className="input min-h-40 font-mono uppercase" value={tickers} onChange={(e) => { setTickers(e.target.value); setTickersDirty(true); }} placeholder={"AAPL\nMSFT\nNVDA"} />
160 + <div className="mt-2 flex items-center justify-between gap-2">
161 + <span className="text-xs text-ink-3">Used by <code>fundamentals</code> jobs when no tickers are given. Max 2 000, deduplicated and upper-cased.</span>
162 + <button className="btn btn-primary" onClick={saveTickers} disabled={busy || !tickersDirty}>
163 + <Save className="h-4 w-4" /> Save
164 + </button>
165 + </div>
166 + </Card>
167 + )}
168 +
169 + {isOwner && (
170 + <Card title="Vendor discovery" actions={<button className="btn btn-sm" onClick={discover} disabled={busy}><Radar className="h-3.5 w-3.5" /> {busy ? "Probing…" : "Run discovery"}</button>}>
171 + <p className="text-xs text-ink-2">Live, sanitized probe of FirstRate Data and SEC EDGAR (no bulk download). Output is redacted server-side and recorded in the audit log.</p>
172 + <div className="mt-2">{disc ? <JsonView value={disc} maxHeight="28rem" /> : <Empty>No discovery run yet in this session.</Empty>}</div>
173 + </Card>
174 + )}
175 +
176 + {isOwner && otherSettings.length > 0 && (
177 + <Card title="Other settings (read-only)" className="lg:col-span-2">
178 + <ul className="flex flex-col gap-2">
179 + {otherSettings.map((s) => (
180 + <li key={s.key} className="text-sm">
181 + <div className="flex items-center justify-between gap-2">
182 + <code className="text-xs">{s.key}</code>
183 + <span className="tnum text-xs text-ink-3">{fmtTs(s.updated_at)}</span>
184 + </div>
185 + <JsonView value={s.value} collapsed />
186 + </li>
187 + ))}
188 + </ul>
189 + </Card>
190 + )}
191 + </div>
192 + {!me?.totp_enabled && (
193 + <Banner tone="warn" className="mt-4">
194 + Two-factor authentication is not enabled on this account. Enable it above — the owner role controls jobs, keys and storage.
195 + </Banner>
196 + )}
197 + </>
198 + );
199 +}
modified apps/web/src/components/charts.tsx +1 −1
@@ -22,7 +22,7 @@ function TipBox({ title, rows }: { title: string; rows: { label: string; value:
22 22 );
23 23 }
24 24
25 −type TipPayload = { payload?: Record<string, unknown>; dataKey?: string | number; value?: unknown }[];
25 +type TipPayload = ReadonlyArray<{ payload?: unknown; value?: unknown }>;
26 26
27 27 // ---------------------------------------------------------------- price (close) line
28 28 export function CloseLineChart({ data, height = 240 }: { data: { t: string; close: number; label: string }[]; height?: number }) {
modified apps/web/src/lib/hooks.ts +2 −2
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
5 5 export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unknown[], opts: { enabled?: boolean } = {}) {
6 6 const enabled = opts.enabled ?? true;
7 7 const [data, setData] = useState<T | null>(null);
8 − const [error, setError] = useState<unknown>(null);
8 + const [error, setError] = useState<Error | null>(null);
9 9 const [loading, setLoading] = useState<boolean>(enabled);
10 10 const [tick, setTick] = useState(0);
11 11 const fnRef = useRef(fn);
@@ -28,7 +28,7 @@ export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unkno
28 28 })
29 29 .catch((e) => {
30 30 if (!alive || ctl.signal.aborted) return;
31 − setError(e);
31 + setError(e instanceof Error ? e : new Error(String(e)));
32 32 })
33 33 .finally(() => {
34 34 if (alive) setLoading(false);
modified services/api/pyproject.toml +1 −1
@@ -4,7 +4,7 @@ version = "0.1.0"
4 4 requires-python = ">=3.12"
5 5 dependencies = ["findata-core", "findata-schema", "findata-ingest", "findata-fundamentals", "findata-market-firstrate",
6 6 "fastapi>=0.115", "email-validator>=2.2", "uvicorn[standard]>=0.32", "pydantic>=2.9", "argon2-cffi>=23.1", "pyotp>=2.9", "qrcode>=8.0",
7 − "prometheus-client>=0.21", "python-multipart>=0.0.12", "itsdangerous>=2.2", "pyarrow>=18.0", "duckdb>=1.1", "polars>=1.12"]
7 + "prometheus-client>=0.21", "anthropic>=1.0", "python-multipart>=0.0.12", "itsdangerous>=2.2", "pyarrow>=18.0", "duckdb>=1.1", "polars>=1.12"]
8 8 [tool.uv.sources]
9 9 findata-core = { workspace = true }
10 10 findata-schema = { workspace = true }
modified services/api/src/findata_api/main.py +2 −1
@@ -19,6 +19,7 @@ from findata_api import metrics as M
19 19 from findata_api.ratelimit import bucket
20 20 from findata_api.routers import (
21 21 admin,
22 + assistant,
22 23 auth,
23 24 catalog,
24 25 coverage,
@@ -111,7 +112,7 @@ def create_app() -> FastAPI:
111 112 return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500)
112 113
113 114 for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router,
114 − coverage.router, quality.router, health.router, admin.router):
115 + coverage.router, quality.router, health.router, admin.router, assistant.router):
115 116 app.include_router(r, prefix=API_PREFIX)
116 117
117 118 @app.get("/metrics", include_in_schema=False)
added services/api/src/findata_api/routers/assistant.py +137 −0
@@ -0,0 +1,137 @@
1 +"""Optional AI data assistant (phase 6): natural language → *validated* read-only SQL over the catalog views.
2 +
3 +Guarantees: the model never executes anything — it only proposes SQL; FinData validates it with the same sandbox as
4 +the playground (single SELECT, allow-listed directories, timeout, row cap), shows the SQL + EXPLAIN plan, cites the
5 +dataset versions used, and refuses questions about data that is not in the catalog. Server-side BYOK (Anthropic)
6 +only; status is `pending_configuration` until ANTHROPIC_API_KEY is set and the capability test has been recorded.
7 +"""
8 +from __future__ import annotations
9 +
10 +import json
11 +import time
12 +from typing import Any
13 +
14 +from fastapi import APIRouter, Depends, Request
15 +from findata_core.config import get_settings
16 +from findata_core.db import connection
17 +from findata_ingest.reader import dataset_views, run_sql
18 +from pydantic import BaseModel, Field
19 +
20 +from findata_api import auth as A
21 +from findata_api.errors import ApiError
22 +
23 +router = APIRouter(prefix="/assistant", tags=["assistant"])
24 +MODEL = "claude-opus-5-5"
25 +
26 +
27 +class AskIn(BaseModel):
28 + question: str = Field(..., min_length=3, max_length=2000)
29 + max_rows: int = Field(200, ge=1, le=5000)
30 + execute: bool = True
31 +
32 +
33 +def _catalog_context() -> tuple[str, dict[str, Any]]:
34 + with connection() as conn:
35 + ds = conn.execute("SELECT id, name, interval, adjustment, status, current_version, timestamp_convention, timezone FROM datasets WHERE kind IN ('market_bars','derived') ORDER BY id").fetchall()
36 + cov = conn.execute("SELECT dataset_id, min(session_date) d0, max(session_date) d1, count(DISTINCT symbol) syms FROM coverage GROUP BY 1").fetchall()
37 + covm = {c["dataset_id"]: c for c in cov}
38 + lines = ["Available DuckDB views (one per dataset; columns: instrument_id, symbol_as_reported, asset_class, interval, timestamp_utc (bar OPEN, UTC), session_date, open, high, low, close, volume, currency, trade_count, vwap, adjustment_type, source_id, ingested_at, quality_flags, revision_id):"]
39 + versions = {}
40 + for d in ds:
41 + c = covm.get(d["id"])
42 + v = f"view {d['id'].replace('.', '_')}: {d['name']} — interval {d['interval']}, adjustment {d['adjustment']}, status {d['status']}, version {d['current_version']}"
43 + if c:
44 + v += f", sessions {c['d0']}→{c['d1']}, {c['syms']} symbols"
45 + else:
46 + v += ", NO DATA LOADED"
47 + lines.append(v)
48 + versions[d["id"]] = d["current_version"]
49 + lines.append("Fundamentals are NOT available through SQL views (use the fundamentals endpoint); say so if asked.")
50 + return "\n".join(lines), versions
51 +
52 +
53 +SYSTEM = """You translate research questions about a private market-data lake into ONE read-only DuckDB SQL statement.
54 +Rules: SELECT/WITH only; use only the listed views and columns; never invent datasets, symbols, dates or columns that are not listed; if the question needs data that is not available (e.g. a dataset with NO DATA LOADED, fundamentals, options, ticks, a date outside coverage), set "sql" to null and explain in "refusal". Prices are raw (unadjusted) unless the view says otherwise. timestamp_utc marks the bar open; convert with timezone 'America/New_York' when the user talks about Eastern time. Always LIMIT results reasonably.
55 +Respond with JSON only: {"sql": string|null, "explanation": string, "assumptions": [string], "refusal": string|null}."""
56 +
57 +
58 +def _ask_model(question: str, context: str) -> dict[str, Any]:
59 + import anthropic
60 + s = get_settings()
61 + client = anthropic.Anthropic(api_key=s.anthropic_api_key.get_secret_value(), max_retries=1, timeout=90.0)
62 + try:
63 + resp = client.messages.create(
64 + model=MODEL, max_tokens=4000, output_config={"effort": "medium"},
65 + system=[{"type": "text", "text": SYSTEM, "cache_control": {"type": "ephemeral"}}, {"type": "text", "text": context}],
66 + messages=[{"role": "user", "content": question}],
67 + )
68 + except anthropic.AuthenticationError:
69 + raise ApiError(502, "provider_auth", "Anthropic rejected the configured key") from None
70 + except anthropic.RateLimitError:
71 + raise ApiError(503, "provider_rate_limited", "Anthropic rate limit; retry later") from None
72 + except anthropic.APIStatusError as e:
73 + raise ApiError(502, "provider_error", f"Anthropic error {e.status_code}") from None
74 + except anthropic.APIConnectionError:
75 + raise ApiError(502, "provider_unreachable", "Could not reach Anthropic") from None
76 + if resp.stop_reason == "refusal":
77 + return {"sql": None, "explanation": "", "assumptions": [], "refusal": "The model declined this request."}
78 + text = "".join(b.text for b in resp.content if b.type == "text").strip()
79 + if text.startswith("```"):
80 + text = text.strip("`").split("\n", 1)[1] if "\n" in text else text.strip("`")
81 + text = text.rsplit("```", 1)[0]
82 + try:
83 + out = json.loads(text)
84 + except json.JSONDecodeError:
85 + return {"sql": None, "explanation": text[:500], "assumptions": [], "refusal": "Model output was not valid JSON"}
86 + out["_usage"] = {"input_tokens": resp.usage.input_tokens, "output_tokens": resp.usage.output_tokens,
87 + "cache_read_input_tokens": getattr(resp.usage, "cache_read_input_tokens", None), "model": resp.model}
88 + return out
89 +
90 +
91 +@router.get("/status")
92 +def status(p: A.Principal = Depends(A.require("query"))):
93 + s = get_settings()
94 + configured = bool(s.anthropic_api_key.get_secret_value())
95 + return {"status": "active" if configured else "pending_configuration", "provider": "anthropic" if configured else None, "model": MODEL if configured else None,
96 + "note": None if configured else "Set ANTHROPIC_API_KEY in the node .env (server-side BYOK) and run the capability/cost test described in docs/STATUS.md.",
97 + "guarantees": ["model proposes SQL only; FinData validates and executes in the read-only sandbox", "generated SQL and EXPLAIN plan are always shown",
98 + "dataset versions used are cited", "questions about data not in the catalog are refused"]}
99 +
100 +
101 +@router.post("")
102 +def ask(body: AskIn, request: Request, p: A.Principal = Depends(A.require("query"))):
103 + s = get_settings()
104 + if not s.anthropic_api_key.get_secret_value():
105 + raise ApiError(409, "pending_configuration", "AI assistant is not configured (ANTHROPIC_API_KEY missing on the server)")
106 + context, versions = _catalog_context()
107 + t0 = time.monotonic()
108 + proposal = _ask_model(body.question, context)
109 + out: dict[str, Any] = {"question": body.question, "sql": proposal.get("sql"), "explanation": proposal.get("explanation"),
110 + "assumptions": proposal.get("assumptions") or [], "refusal": proposal.get("refusal"),
111 + "dataset_versions": versions, "model_usage": proposal.get("_usage"), "executed": False}
112 + if not proposal.get("sql"):
113 + A.audit(request, "assistant.ask", None, refused=True, question=body.question[:300])
114 + return out
115 + views = dataset_views()
116 + try:
117 + plan, _ = run_sql(f"EXPLAIN {proposal['sql']}", 50, 10, views)
118 + out["plan"] = "\n".join(str(r.get("explain_value") or r) for r in plan.to_pylist())
119 + except Exception as e:
120 + out["refusal"] = f"Generated SQL rejected by the sandbox: {str(e)[:300]}"
121 + A.audit(request, "assistant.ask", None, rejected=True, question=body.question[:300], sql=proposal["sql"][:500])
122 + return out
123 + if body.execute:
124 + try:
125 + tbl, meta = run_sql(proposal["sql"], min(body.max_rows, s.query_max_rows), s.query_timeout_seconds, views)
126 + except Exception as e:
127 + out["refusal"] = f"Execution failed: {str(e)[:300]}"
128 + return out
129 + rows = tbl.to_pylist()
130 + for r in rows:
131 + for k, v in r.items():
132 + if hasattr(v, "isoformat"):
133 + r[k] = v.isoformat()
134 + out.update({"executed": True, "columns": [{"name": f.name, "type": str(f.type)} for f in tbl.schema], "rows": rows,
135 + "truncated": meta.get("truncated"), "elapsed_ms": int((time.monotonic() - t0) * 1000)})
136 + A.audit(request, "assistant.ask", None, question=body.question[:300], sql=proposal["sql"][:500], rows=len(out.get("rows", [])))
137 + return out
modified services/ingest/src/findata_ingest/reader.py +2 −1
@@ -139,7 +139,8 @@ def run_sql(sql: str, max_rows: int, timeout_s: int, views: dict[str, list[str]]
139 139 timer = threading.Timer(timeout_s, con.interrupt)
140 140 timer.start()
141 141 try:
142 − tbl = _arrow(con, f"SELECT * FROM ({sql.strip().rstrip(';')}) q LIMIT {int(max_rows) + 1}", None)
142 + body = sql.strip().rstrip(";")
143 + tbl = _arrow(con, body if is_explain else f"SELECT * FROM ({body}) q LIMIT {int(max_rows) + 1}", None)
143 144 except duckdb.InterruptException as e:
144 145 raise TimeoutError(f"query exceeded {timeout_s}s") from e
145 146 finally:
modified tests/test_z_api_sdk.py +26 −0
@@ -272,3 +272,29 @@ def test_vendor_secret_never_in_responses_or_openapi(app_client, api_key):
272 272 r = c.get("/api/v1/health")
273 273 assert r.headers["X-Content-Type-Options"] == "nosniff" and r.headers["X-Frame-Options"] == "DENY" and "default-src 'self'" in r.headers["Content-Security-Policy"]
274 274 assert c.get("/api/docs").status_code == 200
275 +
276 +
277 +def test_assistant_pending_without_key_and_validated_with_stub(app_client, api_key, monkeypatch):
278 + c = app_client
279 + H = {"Authorization": f"Bearer {api_key}"}
280 + st = c.get("/api/v1/assistant/status", headers=H).json()
281 + assert st["status"] == "pending_configuration"
282 + r = c.post("/api/v1/assistant", json={"question": "average AAPL close on 2026-10-30"}, headers=H)
283 + assert r.status_code == 409 and r.json()["error"]["code"] == "pending_configuration"
284 + # stub the provider: the platform must still validate/execute the SQL itself and refuse unsafe statements
285 + from findata_api.routers import assistant as AS
286 + from findata_core.config import get_settings
287 + from pydantic import SecretStr
288 + monkeypatch.setattr(get_settings(), "anthropic_api_key", SecretStr("sk-test"))
289 + monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": "SELECT avg(close) AS avg_close FROM market_bars_stock_1m_raw WHERE symbol_as_reported='AAPL' AND session_date=DATE '2026-10-30'", "explanation": "mean of 1m closes", "assumptions": ["raw prices"], "refusal": None, "_usage": {"input_tokens": 1, "output_tokens": 1}})
290 + r = c.post("/api/v1/assistant", json={"question": "average AAPL close on 2026-10-30"}, headers=H)
291 + assert r.status_code == 200, r.text
292 + b = r.json()
293 + assert b["executed"] and 300 < b["rows"][0]["avg_close"] < 400 and "plan" in b and b["dataset_versions"]["market_bars.stock.1m.raw"] >= 1
294 + monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": "COPY (SELECT 1) TO '/tmp/x'", "explanation": "", "assumptions": [], "refusal": None})
295 + b = c.post("/api/v1/assistant", json={"question": "exfiltrate"}, headers=H).json()
296 + assert not b["executed"] and "rejected by the sandbox" in b["refusal"]
297 + monkeypatch.setattr(AS, "_ask_model", lambda q, ctx: {"sql": None, "explanation": "", "assumptions": [], "refusal": "Options data is not loaded"})
298 + b = c.post("/api/v1/assistant", json={"question": "SPY option chain"}, headers=H).json()
299 + assert b["sql"] is None and "not loaded" in b["refusal"]
300 + monkeypatch.setattr(get_settings(), "anthropic_api_key", SecretStr(""))
modified uv.lock +182 −0
@@ -41,6 +41,25 @@ wheels = [
41 41 { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
42 42 ]
43 43
44 +[[package]]
45 +name = "anthropic"
46 +version = "2.3.0"
47 +source = { registry = "https://pypi.org/simple" }
48 +dependencies = [
49 + { name = "anyio" },
50 + { name = "docstring-parser" },
51 + { name = "httpx2" },
52 + { name = "jiter" },
53 + { name = "opentelemetry-api" },
54 + { name = "pydantic" },
55 + { name = "sniffio" },
56 + { name = "typing-extensions" },
57 +]
58 +sdist = { url = "https://files.pythonhosted.org/packages/3e/ba/4c91947e0d0062d66d371db371597287153f7685fbed512d3294d0cde3c7/anthropic-2.3.0.tar.gz", hash = "sha256:edfdea1e3fa7b3ce25df1511953408b2f03bfa61eb6f6268f766101a9e18ad53", size = 1869656, upload-time = "2026-10-23T23:18:47.649Z" }
59 +wheels = [
60 + { url = "https://files.pythonhosted.org/packages/3f/29/eb3558ab6bf252a07ac41c76ac5b0ea24a0aa0c23f87a40aaf31a5f8dba4/anthropic-2.3.0-py3-none-any.whl", hash = "sha256:aa6a3984f9276f307933d42dc6f58b6efc821a5ecafbc8d1bb5993a066767622", size = 2216980, upload-time = "2026-10-23T23:18:45.779Z" },
61 +]
62 +
44 63 [[package]]
45 64 name = "anyio"
46 65 version = "4.16.0"
@@ -413,6 +432,15 @@ wheels = [
413 432 { url = "https://files.pythonhosted.org/packages/61/55/f90405bbbbd3dd8f788e92cec2d39964f5c09783f0570fc35f3bb3381485/dnspython-2.9.1-py3-none-any.whl", hash = "sha256:7c81caf8de1ed57b8cfc7e0ad4f528a0b8ceace473c7eb946df195dc9def751f", size = 360364, upload-time = "2026-10-29T18:05:10.206Z" },
414 433 ]
415 434
435 +[[package]]
436 +name = "docstring-parser"
437 +version = "0.18.0"
438 +source = { registry = "https://pypi.org/simple" }
439 +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
440 +wheels = [
441 + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
442 +]
443 +
416 444 [[package]]
417 445 name = "duckdb"
418 446 version = "2.0.0"
@@ -489,6 +517,7 @@ name = "findata-api"
489 517 version = "0.1.0"
490 518 source = { editable = "services/api" }
491 519 dependencies = [
520 + { name = "anthropic" },
492 521 { name = "argon2-cffi" },
493 522 { name = "duckdb" },
494 523 { name = "email-validator" },
@@ -511,6 +540,7 @@ dependencies = [
511 540
512 541 [package.metadata]
513 542 requires-dist = [
543 + { name = "anthropic", specifier = ">=1.0" },
514 544 { name = "argon2-cffi", specifier = ">=23.1" },
515 545 { name = "duckdb", specifier = ">=1.1" },
516 546 { name = "email-validator", specifier = ">=2.2" },
@@ -762,6 +792,19 @@ wheels = [
762 792 { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
763 793 ]
764 794
795 +[[package]]
796 +name = "httpcore2"
797 +version = "2.16.0"
798 +source = { registry = "https://pypi.org/simple" }
799 +dependencies = [
800 + { name = "h11", marker = "sys_platform != 'emscripten'" },
801 + { name = "truststore", marker = "sys_platform != 'emscripten'" },
802 +]
803 +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" }
804 +wheels = [
805 + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" },
806 +]
807 +
765 808 [[package]]
766 809 name = "httptools"
767 810 version = "0.9.0"
@@ -863,6 +906,32 @@ wheels = [
863 906 { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
864 907 ]
865 908
909 +[[package]]
910 +name = "httpx2"
911 +version = "2.16.0"
912 +source = { registry = "https://pypi.org/simple" }
913 +dependencies = [
914 + { name = "anyio", marker = "sys_platform != 'emscripten'" },
915 + { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
916 + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
917 + { name = "idna" },
918 + { name = "truststore", marker = "sys_platform != 'emscripten'" },
919 + { name = "typing-extensions", marker = "python_full_version < '3.13'" },
920 +]
921 +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" }
922 +wheels = [
923 + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" },
924 +]
925 +
926 +[[package]]
927 +name = "httpx2-jsfetch"
928 +version = "1.0"
929 +source = { registry = "https://pypi.org/simple" }
930 +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
931 +wheels = [
932 + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
933 +]
934 +
866 935 [[package]]
867 936 name = "idna"
868 937 version = "3.20"
@@ -890,6 +959,101 @@ wheels = [
890 959 { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
891 960 ]
892 961
962 +[[package]]
963 +name = "jiter"
964 +version = "0.18.0"
965 +source = { registry = "https://pypi.org/simple" }
966 +sdist = { url = "https://files.pythonhosted.org/packages/63/5b/f68319f4fe624311a744a33d10fdbcac4c971991e348223cba60d31780b2/jiter-0.18.0.tar.gz", hash = "sha256:a0ca63fd9450a212e4e658d4cfe0ab4977c4f9b0003c68828b843c65e27662ff", size = 234470, upload-time = "2026-10-23T13:51:13.103Z" }
967 +wheels = [
968 + { url = "https://files.pythonhosted.org/packages/45/0e/075bfa4fa46820da9ac8cf8a67c435b0586012279fba15deccde0e73b9ce/jiter-0.18.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:77a01cdf4381a1becbe5b827f682006f47b712581667451c5b1eb259b9ae7041", size = 290095, upload-time = "2026-10-23T13:49:14.118Z" },
969 + { url = "https://files.pythonhosted.org/packages/2e/e2/f4000a4d9c519ca019bf0e2922cc0c6b6db347be6303161382eda7635be7/jiter-0.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7b41cd64ee6b54db94bea6274e6e37002ed526aa06c114a8be6f36ffe21c184e", size = 316319, upload-time = "2026-10-23T13:49:15.263Z" },
970 + { url = "https://files.pythonhosted.org/packages/51/93/9aaef4d18d19446fd044b9cff0323b1b45b5526f077f1a80418567a6eef8/jiter-0.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7424194e68ef44e94290961f59319850d7ce11a329a3957f91709c15ded6618", size = 335631, upload-time = "2026-10-23T13:49:16.551Z" },
971 + { url = "https://files.pythonhosted.org/packages/fd/9e/d2dc5e8179e248df7ef1c7c8822554f4c7f694a33be30790bb523f1e657a/jiter-0.18.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04a484fce032a8b2c21dec0410a805d7b16573eb34297156a3b4d242c507d2b4", size = 322596, upload-time = "2026-10-23T13:49:17.875Z" },
972 + { url = "https://files.pythonhosted.org/packages/22/ca/28bfd20743be0b2b37c1ddfe069a637d5180e535f1c8faad555cf7ac0b98/jiter-0.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:860ac97858116bda0ac02ef5f50c541fc1a84ad7b9368cac68d4de5697a9012d", size = 333858, upload-time = "2026-10-23T13:49:19.343Z" },
973 + { url = "https://files.pythonhosted.org/packages/69/89/e42f7dba54dcf7787aa4be3e22cdcb1f14d9635b6527949652ab490d6f8e/jiter-0.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39ef656cf371971486ec545ca0ae2da8d037d5479bc906121e3e46de86ee73c4", size = 348656, upload-time = "2026-10-23T13:49:20.796Z" },
974 + { url = "https://files.pythonhosted.org/packages/74/6a/b13407f609ddaf9a1f0cdf1336326dc5c8c7fc56943e8450a04a542f5edf/jiter-0.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3c741e6b778c951a6861964ee475118812a33208426c54fb92bf4e318bda834", size = 336286, upload-time = "2026-10-23T13:49:22.152Z" },
975 + { url = "https://files.pythonhosted.org/packages/b2/7e/b560f97495a485a569e7fa338a13587328854f6a40cd2e93602c327ca4a6/jiter-0.18.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:1277ac083a58d2f49a24ff9a28fd9876a6443a1992d4467a1db80fb45e354b18", size = 314415, upload-time = "2026-10-23T13:49:23.645Z" },
976 + { url = "https://files.pythonhosted.org/packages/8b/97/457b6ec16f1f3f25ec760f23728f43a2b20ddf934fcd8f02c81a2183597e/jiter-0.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ccf224d2e706be81d464b59c2bc3f98ba65d12c092c1789e625127e64488944e", size = 332078, upload-time = "2026-10-23T13:49:24.917Z" },
977 + { url = "https://files.pythonhosted.org/packages/0e/d9/6f616071650f7bb8fcbce674d4dde7e59cfbd8a4d30f5c71c03630425d6e/jiter-0.18.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:5c7fc8e5357d1ad082bb177be71e398fcd3d458726439255ca5cb0d706060269", size = 480354, upload-time = "2026-10-23T13:49:26.221Z" },
978 + { url = "https://files.pythonhosted.org/packages/42/72/52a3630c8213cdc8093cc86dc6ad50f2bc89ccfa97ef272205d38a24e16d/jiter-0.18.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a6bba0404b46157a651d1fe512cf2c59c8e42836a9284c35cdff35310758ba4f", size = 515322, upload-time = "2026-10-23T13:49:27.652Z" },
979 + { url = "https://files.pythonhosted.org/packages/31/54/987cfee52bdf8a286e9c1c58192a9bb64f8773a6be36e4ec46959f67fb7e/jiter-0.18.0-cp312-cp312-win32.whl", hash = "sha256:07ae058bc052ead609b49bbae78c01bf4dee8f5f9d21705cbae9bfe59b33efc9", size = 185341, upload-time = "2026-10-23T13:49:29.147Z" },
980 + { url = "https://files.pythonhosted.org/packages/c5/c9/b1279eecf68e5f62c918af3f7f856a071ae7db0c90b8fd2235b1b6020c8c/jiter-0.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:79d1fe529bd7065c6f3f50279a08c2ab82eaa0631a6f5dfafa9c65105eeaaafa", size = 225889, upload-time = "2026-10-23T13:49:30.306Z" },
981 + { url = "https://files.pythonhosted.org/packages/e0/14/715f84be58a7fc97779aa40c37164acfa25ccb2cee89bfb69358e7071ff3/jiter-0.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f8b3b3d8761dee3cddcb5d3e99eaccc0155bbefc345affcdedcc758a4933a1f", size = 181852, upload-time = "2026-10-23T13:49:31.811Z" },
982 + { url = "https://files.pythonhosted.org/packages/50/94/aa275cdb099fd84e402977f8fc8628e2cee2f7c8eafe1d1d5c5e722f5ee5/jiter-0.18.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1517648b5f6268513dea206241a99d9991d6ffcbdbc406848e77a7f02ed3ccf1", size = 289648, upload-time = "2026-10-23T13:49:33.059Z" },
983 + { url = "https://files.pythonhosted.org/packages/8d/30/3eaf67279c6183393d3608b6713c93668ae82ea59f0589ea59d8176d8741/jiter-0.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7161217e2190b9b2c8c79b81699be29d67010f6f77e09a53a3b62261792d5489", size = 315919, upload-time = "2026-10-23T13:49:34.457Z" },
984 + { url = "https://files.pythonhosted.org/packages/f8/2b/d18adcfec3516b97b24c0d5cd1b0ced22cada8ea9806f21bece75af49ffe/jiter-0.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9623fc90f1a4754452a525383ce70476476c16556098944709b8cdbd9ad905f0", size = 335231, upload-time = "2026-10-23T13:49:35.776Z" },
985 + { url = "https://files.pythonhosted.org/packages/41/e8/42cff1077883ea5311621dc9384a1ede4799bc3c01411798ac75f7a71f15/jiter-0.18.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9083a7c2a4ee6358bc0401c46b4d5090f208f6fadfb6275c7b5fe3217e13506", size = 321590, upload-time = "2026-10-23T13:49:36.997Z" },
986 + { url = "https://files.pythonhosted.org/packages/1d/4d/2fbedfce563223d4783a872cead5f5bb74d2d1e418f963410ca28e4b7394/jiter-0.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4632f5172800da4cd42fd53f4553c772acd08f1c33dd7ac135e29d735080fa0d", size = 332960, upload-time = "2026-10-23T13:49:38.615Z" },
987 + { url = "https://files.pythonhosted.org/packages/04/a0/b082b2d8bd3bef9a888cc0a0f060a24644f24b5195f2e8227a40507e6b71/jiter-0.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c00b1f8fc1dd06c610305be36778b38148ed1e9a532bdd81189c3c0d9905c888", size = 348149, upload-time = "2026-10-23T13:49:39.826Z" },
988 + { url = "https://files.pythonhosted.org/packages/64/11/babc8d3c004bb2f11b7e3adfe27a5bc7c1e13cdfc6360d424c57355c55ff/jiter-0.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e3350b7431f7b8315b925d9ca0f29a44e8fb6ed45c1fe552392cb6297966bb9", size = 335978, upload-time = "2026-10-23T13:49:41.165Z" },
989 + { url = "https://files.pythonhosted.org/packages/3b/21/c50bf83c39d60f0df896ebcb025b316b670a703397d08bea2c69b9615101/jiter-0.18.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3b49071fb7917cdbc6d80c81a491c6ad146c1a8fd6cb3076cd3c1bfeb6e266f6", size = 313767, upload-time = "2026-10-23T13:49:42.538Z" },
990 + { url = "https://files.pythonhosted.org/packages/af/19/3f66480d5bfd3f49571a073eefac4d890dd590a8eb3c78b15e0293909cc6/jiter-0.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04e210f5ddcccdbde484821040216a112381e6484abc549c20c38e9698ab07ca", size = 331070, upload-time = "2026-10-23T13:49:43.782Z" },
991 + { url = "https://files.pythonhosted.org/packages/5a/3d/b5662c84b14475c8b018ed1ca2ea44069194b7d97a03bac81ae6b46f6b9f/jiter-0.18.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5b339f6ae14fe7be92f391cc65cc5c80ff76e8da76d63b18fc14664f09b7cece", size = 479939, upload-time = "2026-10-23T13:49:45.033Z" },
992 + { url = "https://files.pythonhosted.org/packages/80/b8/081c0414379ee2564aa7808f78e963538a2c4aed2f8d31775e2423e1cdbf/jiter-0.18.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:5cb43336244e063f9cc11e44730e4b92dea70c6eb9b649d268c5ebebc4bc7685", size = 514734, upload-time = "2026-10-23T13:49:46.236Z" },
993 + { url = "https://files.pythonhosted.org/packages/0f/1c/6f7bf98786851ae424a1d88562586302536cc137848fd12d2add3960fab3/jiter-0.18.0-cp313-cp313-win32.whl", hash = "sha256:9656d0e9001f69c4af6523874ff445ab4e5db7b835f59733cc5ff51e73a9aa57", size = 184699, upload-time = "2026-10-23T13:49:47.398Z" },
994 + { url = "https://files.pythonhosted.org/packages/38/53/5803216c024a241a0bf41ed2668713d7228f92c5593e40d5e873e3b20e0b/jiter-0.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f26198220165eb9627847401fff4d0ca8bebf3ef840a88c6312918d2291c33a", size = 222493, upload-time = "2026-10-23T13:49:48.533Z" },
995 + { url = "https://files.pythonhosted.org/packages/fe/8e/484919ccb041c9a1e84059be02bd37ff086b8b084e21630e0958d3ae41a3/jiter-0.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:bef1fe687dc6af17bef9f644a95d465f15f0efa20b743c858e0b6a14e2c0c73e", size = 181250, upload-time = "2026-10-23T13:49:49.777Z" },
996 + { url = "https://files.pythonhosted.org/packages/74/0e/45a502771d73d4a939132d8bf85d7b049c7ca06a07f84b414b525c3dc084/jiter-0.18.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:248e939f67a9f8030e2624260a9f417decfe275af37aa96728d256468a9c4086", size = 291757, upload-time = "2026-10-23T13:49:50.95Z" },
997 + { url = "https://files.pythonhosted.org/packages/21/f6/d9cfa2bf2a969fb8e002fb8d8a6cd98d49a6248cb7e4ffe2247766af13fb/jiter-0.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0390fa550fcafbf69fb97ccb287bd71192f90410a1c7dd600ca4000aa9aabcf2", size = 318585, upload-time = "2026-10-23T13:49:52.407Z" },
998 + { url = "https://files.pythonhosted.org/packages/46/38/ea1dae56f123e3a864188cbbc577314293c0dcc019a880fa9c0a8097934a/jiter-0.18.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8013186f2b9a8c27ededa601050392527aa9d27ffd35d49aae424fbd2062f38", size = 338099, upload-time = "2026-10-23T13:49:53.658Z" },
999 + { url = "https://files.pythonhosted.org/packages/1f/d9/fcc5c53a32f98d52f4fd46fda62b69f5c7c2c975a582c0e18501250a4f78/jiter-0.18.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd4316df8e2074fcf5173f6001f1fc9596e7c1e8c4bf499b02f9ebe92babdfb1", size = 324016, upload-time = "2026-10-23T13:49:54.999Z" },
1000 + { url = "https://files.pythonhosted.org/packages/d4/63/c24cd08907f08e6a3ba3173616ad268a3d32dd81c63ba34b991d08577d28/jiter-0.18.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9e474da7a9f65d3b460cca93cc8a0f4ec0220d1fa41f1f7a3709195ead60056", size = 335904, upload-time = "2026-10-23T13:49:56.214Z" },
1001 + { url = "https://files.pythonhosted.org/packages/0b/78/19ca1c8fe87bd3c8540b91504ff2dd52c9c538233044c4cf787fc1fab39b/jiter-0.18.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d55acd326db8ecfc94f21951abd20902c7939d35925444e48d94527e287bead7", size = 350523, upload-time = "2026-10-23T13:49:57.552Z" },
1002 + { url = "https://files.pythonhosted.org/packages/c3/27/84be9cc1c2b781d2da7c694fd7608311af3aaab91f20eb00d4999285b351/jiter-0.18.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a01642d403b8925f2b1e1bb51b4ba49ece8a2bdc9b9560fc8ed0b1f2b35b567a", size = 338324, upload-time = "2026-10-23T13:49:58.842Z" },
1003 + { url = "https://files.pythonhosted.org/packages/9a/ac/7b3bfb49b19e42ebc2ae56ec307c372602dd1e17892f7e051c9bf97cd7e6/jiter-0.18.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:89ba6b674d4bd7290f9943bbeba0e1bf35955bbb1e07e41ba94691648cf133d5", size = 316484, upload-time = "2026-10-23T13:50:00.257Z" },
1004 + { url = "https://files.pythonhosted.org/packages/f9/6a/e37f50885aa4daa1dfe413784692ead6b48a1d9e857cd394a61bf345189c/jiter-0.18.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:feadebdfee32c61e91066433f4aa74c891752e26e43032c1feef08dda6d9b6ed", size = 334225, upload-time = "2026-10-23T13:50:01.542Z" },
1005 + { url = "https://files.pythonhosted.org/packages/8b/f8/1026bc92f0fd443a1137993d1448a7d7c4b7a34bec2722a1533302ebc4b4/jiter-0.18.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:dd7e57efd54a3580620f635c48a3226f910e23649b7eb83ffbc6f0f3386293b8", size = 482157, upload-time = "2026-10-23T13:50:02.849Z" },
1006 + { url = "https://files.pythonhosted.org/packages/1b/2d/61f3c35736873c34eeee3d0008067febd88ded8e8cf6f9a0ab090348a787/jiter-0.18.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:ea69e23481497c2143775a98d273ccc43de5c92a6387df18c903d2d37bc2b181", size = 516956, upload-time = "2026-10-23T13:50:04.19Z" },
1007 + { url = "https://files.pythonhosted.org/packages/6b/bf/9d14be58e76324f4927c7f8e5dbe2f9fd04faaec884a6465173f8142eac1/jiter-0.18.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:d9b61af76a3cd90ad9656a331737d97dab8c05facb2d04cb41fb16c9adadc966", size = 112087, upload-time = "2026-10-23T13:50:05.423Z" },
1008 + { url = "https://files.pythonhosted.org/packages/c6/7d/cff751c560d514da283bae595fe154714f285ec5ac15e2b16e9e0c0c8948/jiter-0.18.0-cp314-cp314-win32.whl", hash = "sha256:fe42726d7fe171cd8fc1966d4457a6aae03bd408d519d2cc5e7726805460b51e", size = 186562, upload-time = "2026-10-23T13:50:06.81Z" },
1009 + { url = "https://files.pythonhosted.org/packages/04/70/4e7f51e517be4146400fd1ce642d535380a38c7db5ab475d38bbcd16a82d/jiter-0.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:f1e738b851019d6bb6ff4a42c6319bdede967dec958ef3ec6feb7ab9d12408f7", size = 225448, upload-time = "2026-10-23T13:50:08.302Z" },
1010 + { url = "https://files.pythonhosted.org/packages/78/43/02bfb85f63e04c87c406731197e41f2e6b415035e41497428e0422fb326e/jiter-0.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:80a961b1c5c7786bc03b332deb7f7ebe818b7baa6e26685e2bf480548ca128b4", size = 182907, upload-time = "2026-10-23T13:50:09.795Z" },
1011 + { url = "https://files.pythonhosted.org/packages/9c/66/5cbde35cbb14c8339af39c9bc61cbda08b212116f96603d23b7c9cc84a4d/jiter-0.18.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b066f37dd9ec19fc7931d79dee38f56b6f849217bbe824a7dad4518ef96ee515", size = 318962, upload-time = "2026-10-23T13:50:10.91Z" },
1012 + { url = "https://files.pythonhosted.org/packages/eb/d1/dccc47ed6070d3b25622823048a80a49fe87ccd737e5d66b802ca20e4fde/jiter-0.18.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccdc09887b04f50f1761eedcfd19da882d9d834096bc1215049af637023f7db7", size = 336718, upload-time = "2026-10-23T13:50:12.149Z" },
1013 + { url = "https://files.pythonhosted.org/packages/de/a5/8d4ec06d7a0f2bccbde8508ed847ccd323a9dd1fbab5900cda3fc5bd71f8/jiter-0.18.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6133abcd0f332964fbb8145adb1ce775cd0312c9eaf6e4bf26b7a5c9b6f7cca7", size = 322945, upload-time = "2026-10-23T13:50:13.591Z" },
1014 + { url = "https://files.pythonhosted.org/packages/7f/50/49d06214b30c685d64ffdd40fdb50a98b6ccff4b012fc9ed491d44de9ef1/jiter-0.18.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca381b4a466023d1fdd04c889c759b46f7d9654f622b28bb01e65abf31d07bba", size = 335325, upload-time = "2026-10-23T13:50:14.807Z" },
1015 + { url = "https://files.pythonhosted.org/packages/f4/9a/4ecb866a7a01a4e76d9ffbde3fac2cd66ad978c8c797953d44f91fbf2040/jiter-0.18.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b961fedf589ffa182fe302fc4371acf4606d3ed4a6a48a151fbfd724d811f5a", size = 350578, upload-time = "2026-10-23T13:50:16.125Z" },
1016 + { url = "https://files.pythonhosted.org/packages/16/7e/c8b5ecb6af4c9454376263f167f3f8bb5dec41b10fac4f48ea4557b9f115/jiter-0.18.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b77eabfbae1ac47872be0076899e66340bbef39b862971c3d6c9a4b7c91fd10", size = 338791, upload-time = "2026-10-23T13:50:17.703Z" },
1017 + { url = "https://files.pythonhosted.org/packages/8a/04/45ba31ce79022706323fd2291f87d956fd51fdfcba2ba8924b3de62f0ce2/jiter-0.18.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:92a16933d0fa852133500837d33cd6a4f75e4414d77389c58269e12bafc2ee02", size = 316169, upload-time = "2026-10-23T13:50:18.915Z" },
1018 + { url = "https://files.pythonhosted.org/packages/62/c1/fd36126144f4c32d1b76939278e9d3b7b798075c0478b641d6066621bafd/jiter-0.18.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2f7ba518893a4ccef5afb38e41bc3e126bf652354417ec4e75e5a5b000fb1ef6", size = 334754, upload-time = "2026-10-23T13:50:20.169Z" },
1019 + { url = "https://files.pythonhosted.org/packages/cc/e2/30a5ac1a3ee9e01bda75a4fcb55bbeddcf8875f8ce7e680b787c177f14b5/jiter-0.18.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:04d67a44b040a6662a35edc9869030f7aa566bf5baaf3549c99d847aa44213e2", size = 481094, upload-time = "2026-10-23T13:50:21.481Z" },
1020 + { url = "https://files.pythonhosted.org/packages/33/de/5e736a36eff6933da012dc440540f79e3ccfc06e27f557638280657df023/jiter-0.18.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e4d4819398d5330636bc0b91ccc0048dba782076fb99257038257ba6f121c840", size = 515524, upload-time = "2026-10-23T13:50:22.872Z" },
1021 + { url = "https://files.pythonhosted.org/packages/0a/ea/1e8bc5b0c920a13e5b698106dbb2d2da5c5e3474668746246f649abb6d5f/jiter-0.18.0-cp314-cp314t-win32.whl", hash = "sha256:c435e795a7ea4b372166a058a5b49a25caeb9c94e469a1414868b14c83ee5c41", size = 185058, upload-time = "2026-10-23T13:50:24.098Z" },
1022 + { url = "https://files.pythonhosted.org/packages/1e/0f/a742b397699c3af52dd95443332164884fe6204e5faa5321b7c073beab0f/jiter-0.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5a3d5397da7ae46c7a9601ef02d2c9ec47a6c3abe15e1ea940eaf93e675ee20e", size = 225598, upload-time = "2026-10-23T13:50:25.44Z" },
1023 + { url = "https://files.pythonhosted.org/packages/b3/b7/08ae26347dca48d5cadca33be2f2f5b6b1ae790b322109083d6f95959feb/jiter-0.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4b81b474e4a8ec745ab20a6762275a360e1147cb70cf08872ec14e1aa5debc37", size = 183052, upload-time = "2026-10-23T13:50:26.675Z" },
1024 + { url = "https://files.pythonhosted.org/packages/c2/d6/0d2a8a9747ad918141827fabdc05abc3bf73986f7f36647fb8c19708e21e/jiter-0.18.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:30f8ae18425e0247ffff4e4a5753ec1952e1b81ed4e7949b84e5ecfaf8315833", size = 291908, upload-time = "2026-10-23T13:50:27.94Z" },
1025 + { url = "https://files.pythonhosted.org/packages/54/b4/19c582727e22681c6069db037c3b3e354c6fbdce7fbc5290a7bc7bc5884f/jiter-0.18.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:93150a3bb0317d8f5a94d4c3ea68db1cb434b94ac819e08c443cea97b7645f2f", size = 316544, upload-time = "2026-10-23T13:50:29.167Z" },
1026 + { url = "https://files.pythonhosted.org/packages/c3/62/5d34e93dc5d1e707788a378335efc876cdc731ceb2111b0a9d7c6f9fdada/jiter-0.18.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c8a61d6227cdcb0a67c678f30a020837c9f3dd7e0b3f916781defa157e4eb9", size = 338101, upload-time = "2026-10-23T13:50:30.457Z" },
1027 + { url = "https://files.pythonhosted.org/packages/75/e2/0b16a6ca5479cd9efa3c59497cd42f1979a539b6c664a5b9c87e3632f176/jiter-0.18.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:285cc15ff9ffcd4ce7f0b9ce02fcbc65e22fcf7dfbda1a3d71ccac8d7b73f8a1", size = 324213, upload-time = "2026-10-23T13:50:31.695Z" },
1028 + { url = "https://files.pythonhosted.org/packages/85/9d/620a97457a0210092bf9faa1eb30f5da094fab2de07a9478af7c254e22fd/jiter-0.18.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8f69ef2e28f8c1759bc1522385ab3e4ade0b1056053cecf67449a947c4525a8", size = 335777, upload-time = "2026-10-23T13:50:32.984Z" },
1029 + { url = "https://files.pythonhosted.org/packages/76/08/a3514df093824f10a61dc1d0bbc22285ed779ba42f15d727499a8c877e16/jiter-0.18.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eec2da60423287bd60377b0fad1723560dcc51cfb2b99615be8431ad425c4438", size = 350490, upload-time = "2026-10-23T13:50:34.421Z" },
1030 + { url = "https://files.pythonhosted.org/packages/cd/60/8f8b51bae62259b429e83d0adff69d93e39e4fadbc4bd1856dc2a0950639/jiter-0.18.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5db2cdae6a2f01e8f52ab2154886dcf5bb1844b4f31245440bf623ec91b65b08", size = 338591, upload-time = "2026-10-23T13:50:35.759Z" },
1031 + { url = "https://files.pythonhosted.org/packages/2e/ed/5307f0eb051d987510fdd1020c904927c0b364a4937d372817bdefaced31/jiter-0.18.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:029da1f29852beac2f9001f20209b73b29b4d7bdc748c7f8028895769e2cc0d8", size = 316623, upload-time = "2026-10-23T13:50:37.316Z" },
1032 + { url = "https://files.pythonhosted.org/packages/f9/0e/4bdc945ffbf77c60f07c726fbeb45508018322df9d9942d262256397062e/jiter-0.18.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:67c6662b1b7e5728a507db06ad1b8a7ff8ade04c2a74f092d467209ef744d3a4", size = 334720, upload-time = "2026-10-23T13:50:38.507Z" },
1033 + { url = "https://files.pythonhosted.org/packages/4b/33/fb3e8f9354e7edab05f3752383eac58ce6e785f2f1626b697dd5dd0a86e4/jiter-0.18.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:4db12daef4a231df528a16f70d5ff7f5e3be21f70e8ad904e4c61442366e8a10", size = 482130, upload-time = "2026-10-23T13:50:40.023Z" },
1034 + { url = "https://files.pythonhosted.org/packages/2e/3d/3aef2be95155bbacadd8691208b6f0f7371cc0cefe80241a1245e38e308d/jiter-0.18.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:4654ea93f525ea96adf289afcf1ead32d773b8325ec326862b4f81f5f4570835", size = 516986, upload-time = "2026-10-23T13:50:41.279Z" },
1035 + { url = "https://files.pythonhosted.org/packages/3c/f6/2aa54fd5da3d0a93de2655e2525426263168454259ba3f2fb70d1b36fbc9/jiter-0.18.0-cp315-cp315-win32.whl", hash = "sha256:f035d01b450ac4e141502f1ce142462cee638198ff5a0237c80e6683798b3b87", size = 186645, upload-time = "2026-10-23T13:50:42.526Z" },
1036 + { url = "https://files.pythonhosted.org/packages/b5/77/5b54ca8c292b83991cafbac6b022f515e46f1ae77dd5f4a97cc33644b235/jiter-0.18.0-cp315-cp315-win_amd64.whl", hash = "sha256:4a18ef4fe95685b000d95eca797cc683e0c178a4b364f2e39f6c79ab107a26e6", size = 225087, upload-time = "2026-10-23T13:50:43.779Z" },
1037 + { url = "https://files.pythonhosted.org/packages/9f/f3/53b7f39b83c757380ab57d9bc3f934431d6b6751f007e8122f7f63c10c2c/jiter-0.18.0-cp315-cp315-win_arm64.whl", hash = "sha256:c73922e4ff4b1ad2929cd795c98223c6688a43c5fd42e9deb8955e4d43b00abb", size = 183120, upload-time = "2026-10-23T13:50:44.985Z" },
1038 + { url = "https://files.pythonhosted.org/packages/77/f8/3a3ef55c528722f3b4be0e7b9686a37ddbe86ed96510bc1c2a63dab7f14b/jiter-0.18.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0ccf55f6fcdcee82fe40ae617e9d64b8e5f433fa6cb5bf7d2969e584e6df0e5d", size = 317996, upload-time = "2026-10-23T13:50:46.266Z" },
1039 + { url = "https://files.pythonhosted.org/packages/e1/15/b06c1c17bed30b5c5dabb40f80a56a3b71b89c16ae86552307f7ccedc93a/jiter-0.18.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:163a55e77f8e92d739b1f4ef212f1d445ddc58ecdf0da1feca44ef424b5331f8", size = 336638, upload-time = "2026-10-23T13:50:47.444Z" },
1040 + { url = "https://files.pythonhosted.org/packages/87/79/b7bcd3e8c850326a323d18a5607334ea40659e22a4fc321e315d1d230007/jiter-0.18.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9de6c9133be12a51ea2d8c270f39a04ed45ee369802bb6270649b952e6cec256", size = 323116, upload-time = "2026-10-23T13:50:48.715Z" },
1041 + { url = "https://files.pythonhosted.org/packages/5e/99/4c53c9d46462cd0691c3c188f89612e63a424d19f5befd1b0631f8f90638/jiter-0.18.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ea4f2ed18eba7454182d4ff727444788ae51ad11bf5a25ad892f5f0ad3e754b", size = 335162, upload-time = "2026-10-23T13:50:49.987Z" },
1042 + { url = "https://files.pythonhosted.org/packages/6f/ab/ef873065a3a1c442a4e62558b77bfa338066317dbafdfdc991121201f5ad/jiter-0.18.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:626d5c9fb8b57babc6ce9d03224846ecb094e1d22ff5bff6080398e7f8aee162", size = 350560, upload-time = "2026-10-23T13:50:51.478Z" },
1043 + { url = "https://files.pythonhosted.org/packages/19/7e/48e78558d7cb3d289211da0397b7ed31cfe3efc98dd85f2324abd574c07c/jiter-0.18.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3c3318cc6ef857b6554b43479c30e8249fc143189d500d4137cde9c58412e6d", size = 337848, upload-time = "2026-10-23T13:50:52.961Z" },
1044 + { url = "https://files.pythonhosted.org/packages/2c/35/b0a093e72def7caec970dd8536c3d42af3add3aedfb3c06ac0588effde26/jiter-0.18.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:9a70f25f6de76a01e309e109c9bcf2143c5c126b3488dd17a25c7aa404b28f16", size = 315870, upload-time = "2026-10-23T13:50:54.152Z" },
1045 + { url = "https://files.pythonhosted.org/packages/48/a4/fd7ca4d1ce8b876be7a260f5e31615854aed5b041553a63e85c4a265cb63/jiter-0.18.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22d8dd757e603224e46786d63cb7d7bfbddc8f579a02e7b082840468d18c06f3", size = 335253, upload-time = "2026-10-23T13:50:55.427Z" },
1046 + { url = "https://files.pythonhosted.org/packages/f5/71/ccd7154afecd4687b22a8195d5dc6460f89f6e17537ec78edb58a2faac34/jiter-0.18.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:552a8714f26ddcb43115c82da6d045b3b9f2fa5997d8b21b2f8ec3721503bebb", size = 480775, upload-time = "2026-10-23T13:50:56.91Z" },
1047 + { url = "https://files.pythonhosted.org/packages/c8/b9/bad5391d7fb734cd6ebc383c8603bc324518bf4ce865f5d813908ae42596/jiter-0.18.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:8060a206e290410d3bfc3bd91715f3ec90a499246f8335d48927e0b3b6a6ccc5", size = 515619, upload-time = "2026-10-23T13:50:58.231Z" },
1048 + { url = "https://files.pythonhosted.org/packages/c8/5c/9596452ad56340e6ed395c5fe27ab102bb2aa0c4b59de2e6a8b3dc3b03a7/jiter-0.18.0-cp315-cp315t-win32.whl", hash = "sha256:91d463858f11ee76ce8e39f391bbcbf1d4c3cc4703a1496fed3a5666dcd5e01d", size = 185204, upload-time = "2026-10-23T13:50:59.456Z" },
1049 + { url = "https://files.pythonhosted.org/packages/08/c8/62c798e2a1a6f827eac1d45232c93b1b0e387a22d559c8483dc7302d5fd5/jiter-0.18.0-cp315-cp315t-win_amd64.whl", hash = "sha256:6ee49daab0b826279c152ad8d61171ea17f4197f91a5e14251d0d5d4adf51059", size = 224948, upload-time = "2026-10-23T13:51:00.624Z" },
1050 + { url = "https://files.pythonhosted.org/packages/64/42/cdfa494914b6d512431e795d8d6226b84b77b733bb8b5eccafd5a1a86dad/jiter-0.18.0-cp315-cp315t-win_arm64.whl", hash = "sha256:bec676a0dc24d4dabd4bdfb3d1f1bf80d4d3dd892fc5c5b3f8f953e2459dbfce", size = 183182, upload-time = "2026-10-23T13:51:01.797Z" },
1051 + { url = "https://files.pythonhosted.org/packages/a0/c2/49ee8f4743c27e61c52b4746ba636b43bedda97131a14043dc6f5ce5bb9d/jiter-0.18.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:118c1fd995f349f1cad0557f31d6860f336b4aeef7f002dedbc068027f68e2c3", size = 287302, upload-time = "2026-10-23T13:51:08.073Z" },
1052 + { url = "https://files.pythonhosted.org/packages/60/72/394036294ac3226216f73bfa4b14df920eb5baf7fb80fa7e0a0d7ef442a6/jiter-0.18.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:44d0d719cd7845e66b01a0cf346ec9882073e710a05e89a5b6d87be47a9dc69c", size = 277797, upload-time = "2026-10-23T13:51:09.324Z" },
1053 + { url = "https://files.pythonhosted.org/packages/ce/80/f0dadedaa9a109f31e8978c7db355593916968d5956f382188a3f8c0888c/jiter-0.18.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d1d2dc4d930ebc225f488b6b3790acebb2e51b3a165a4a42cc1d05bb927f83e", size = 300192, upload-time = "2026-10-23T13:51:10.551Z" },
1054 + { url = "https://files.pythonhosted.org/packages/93/51/e57dbc6c264ec6557a677189755fe4525d83439237418e3ae59771ac1cde/jiter-0.18.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebb0c346b6c5d9c033fe9e9b6ed6a1ff6dac0288fe681c3434dde6eb9c264fa1", size = 301141, upload-time = "2026-10-23T13:51:11.882Z" },
1055 +]
1056 +
893 1057 [[package]]
894 1058 name = "korean-lunar-calendar"
895 1059 version = "0.4.0"
@@ -1790,6 +1954,15 @@ wheels = [
1790 1954 { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
1791 1955 ]
1792 1956
1957 +[[package]]
1958 +name = "sniffio"
1959 +version = "1.3.1"
1960 +source = { registry = "https://pypi.org/simple" }
1961 +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
1962 +wheels = [
1963 + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
1964 +]
1965 +
1793 1966 [[package]]
1794 1967 name = "sortedcontainers"
1795 1968 version = "2.4.0"
@@ -1902,6 +2075,15 @@ wheels = [
1902 2075 { url = "https://files.pythonhosted.org/packages/db/17/4c8beb6c8c4176c6bf143bfd7e1e4dd6719b00ced90738c7ac471b71c1df/toolz-1.2.0-py3-none-any.whl", hash = "sha256:890f820b1cb8152785aaf9386d8707770110809035800985ca65cb24ce1120ef", size = 60789, upload-time = "2026-10-07T04:16:24.173Z" },
1903 2076 ]
1904 2077
2078 +[[package]]
2079 +name = "truststore"
2080 +version = "0.10.5"
2081 +source = { registry = "https://pypi.org/simple" }
2082 +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" }
2083 +wheels = [
2084 + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" },
2085 +]
2086 +
1905 2087 [[package]]
1906 2088 name = "typer"
1907 2089 version = "0.27.3"
1908 2090
Menu

Theme