SPB Git

spb/findata

Public

FinData OS: phase 0–4 foundation (discovery, lakehouse, ingest, API, SDK, tests)

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

135 changed files +17,015 −0

135 changed files
Unified Split
added .env.example +22 −0
@@ -0,0 +1,22 @@
1+# FinData OS — runtime configuration (copy to .env on the node; mode 600; never commit)
2+FINDATA_ENV=production
3+FINDATA_ROOT=/Users/simon-pierreboucher/apps/findata
4+FINDATA_LAKE=/Users/simon-pierreboucher/findata-lake
5+FINDATA_DATABASE_URL=postgresql://findata:CHANGE_ME@127.0.0.1:5432/findata
6+FINDATA_BIND_HOST=127.0.0.1
7+FINDATA_BIND_PORT=8600
8+FINDATA_PUBLIC_URL=https://findata.spboucher.ai
9+# 32+ random bytes, base64 — session cookie signing
10+FINDATA_SECRET_KEY=CHANGE_ME
11+# Vendor credentials (FirstRate Data customer id). Never logged, never sent to the browser.
12+FRD_USERID=CHANGE_ME
13+# SEC EDGAR fair-access User-Agent (name + contact email). Leave empty to keep the connector "pending configuration".
14+SEC_EDGAR_USER_AGENT=
15+# Optional AI assistant (phase 2) — server-side BYOK only
16+ANTHROPIC_API_KEY=
17+OPENAI_API_KEY=
18+# Lake budget (GB)
19+FINDATA_LAKE_SOFT_LIMIT_GB=250
20+FINDATA_LAKE_HARD_MIN_FREE_GB=60
21+# Backup target (rsync over SSH, LAN)
22+FINDATA_BACKUP_TARGET=M4M64a:~/backups/findata
added .gitignore +18 −0
@@ -0,0 +1,18 @@
1+.venv/
2+__pycache__/
3+*.pyc
4+.pytest_cache/
5+.ruff_cache/
6+.env
7+.env.*
8+!.env.example
9+*.secret
10+node_modules/
11+apps/web/.next/
12+apps/web/out/
13+apps/web/next-env.d.ts
14+data/
15+logs/
16+backups/
17+.DS_Store
18+/tests/_tmp/
added Makefile +10 −0
@@ -0,0 +1,10 @@
1+.PHONY: sync test lint web migrate api worker status audit
2+sync: ; uv sync
3+test: ; uv run pytest -q
4+lint: ; uv run ruff check .
5+audit: ; uv run pip-audit --strict || true
6+web: ; cd apps/web && pnpm install --frozen-lockfile && pnpm build
7+migrate: ; uv run findata migrate
8+api: ; uv run uvicorn findata_api.main:app --host 127.0.0.1 --port 8600 --reload
9+worker: ; uv run findata worker
10+status: ; uv run findata status
added README.md +14 −0
@@ -0,0 +1,14 @@
1+# FinData OS
2+
3+Private, research-grade financial data lakehouse for the MacLustr cluster — https://findata.spboucher.ai (owner only).
4+
5+- Vendor: **FirstRate Data** (market data bundles + US fundamentals API). License: private use, no redistribution.
6+- Node: M3U96a (1 TB NVMe), lake `~/findata-lake`, PostgreSQL 17 metadata, DuckDB reads, FastAPI `/api/v1`, Next.js console.
7+- Docs: `docs/DISCOVERY.md` (phase 0 evidence), `docs/ARCHITECTURE.md`, `docs/DATA_DICTIONARY.md`, `docs/OPERATIONS.md`, `docs/SECURITY.md`, `docs/STATUS.md`.
8+
9+```bash
10+uv sync && make test # 36 tests (unit + integration against local Postgres `findata_test`)
11+make web # build the console (apps/web/out)
12+uv run findata --help # operator CLI
13+```
14+Python SDK: `packages/python-sdk` — `from findata_sdk import FinData`.
added apps/web/.gitignore +6 −0
@@ -0,0 +1,6 @@
1+node_modules/
2+.next/
3+out/
4+next-env.d.ts
5+*.tsbuildinfo
6+.DS_Store
added apps/web/.npmrc +1 −0
@@ -0,0 +1 @@
1+node-linker=hoisted
added apps/web/next.config.ts +23 −0
@@ -0,0 +1,23 @@
1+import type { NextConfig } from "next";
2+
3+const isDev = process.env.NODE_ENV === "development";
4+
5+const nextConfig: NextConfig = {
6+ // Static export: the Python API (FastAPI StaticFiles, html=True) serves apps/web/out
7+ // with directory index.html fallback, so every route must end with a slash.
8+ output: "export",
9+ trailingSlash: true,
10+ images: { unoptimized: true },
11+ reactStrictMode: true,
12+ poweredByHeader: false,
13+ // Dev only: proxy the API to the local FastAPI instance. Ignored by `next build` (static export).
14+ ...(isDev
15+ ? {
16+ async rewrites() {
17+ return [{ source: "/api/:path*", destination: "http://127.0.0.1:8600/api/:path*" }];
18+ },
19+ }
20+ : {}),
21+};
22+
23+export default nextConfig;
added apps/web/package.json +31 −0
@@ -0,0 +1,31 @@
1+{
2+ "name": "findata-web",
3+ "version": "1.0.0",
4+ "private": true,
5+ "description": "FinData OS — private web console (static export served by the Python API)",
6+ "scripts": {
7+ "dev": "next dev -p 3000",
8+ "build": "next build",
9+ "typecheck": "tsc --noEmit",
10+ "lint": "next lint"
11+ },
12+ "dependencies": {
13+ "clsx": "^2.1.1",
14+ "lucide-react": "^1.66.0",
15+ "next": "^15.5.0",
16+ "react": "^19.0.0",
17+ "react-dom": "^19.0.0",
18+ "recharts": "^3.10.1"
19+ },
20+ "devDependencies": {
21+ "@tailwindcss/postcss": "^4.1.0",
22+ "@types/node": "^22.0.0",
23+ "@types/react": "^19.0.0",
24+ "@types/react-dom": "^19.0.0",
25+ "tailwindcss": "^4.1.0",
26+ "typescript": "^5.7.0"
27+ },
28+ "engines": {
29+ "node": ">=20"
30+ }
31+}
added apps/web/pnpm-lock.yaml +1338 −0
@@ -0,0 +1,1338 @@
1+lockfileVersion: '9.0'
2+
3+settings:
4+ autoInstallPeers: true
5+ excludeLinksFromLockfile: false
6+
7+importers:
8+
9+ .:
10+ dependencies:
11+ clsx:
12+ specifier: ^2.1.1
13+ version: 2.1.1
14+ lucide-react:
15+ specifier: ^1.66.0
16+ version: 1.66.0(@types/react@19.3.0)(react@19.3.1)
17+ next:
18+ specifier: ^15.5.0
19+ version: 15.5.28(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
20+ react:
21+ specifier: ^19.0.0
22+ version: 19.3.1
23+ react-dom:
24+ specifier: ^19.0.0
25+ version: 19.3.1(react@19.3.1)
26+ recharts:
27+ specifier: ^3.10.1
28+ version: 3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@18.3.1)(react@19.3.1)(redux@5.0.1)
29+ devDependencies:
30+ '@tailwindcss/postcss':
31+ specifier: ^4.1.0
32+ version: 4.3.3
33+ '@types/node':
34+ specifier: ^22.0.0
35+ version: 22.20.5
36+ '@types/react':
37+ specifier: ^19.0.0
38+ version: 19.3.0
39+ '@types/react-dom':
40+ specifier: ^19.0.0
41+ version: 19.3.0(@types/react@19.3.0)
42+ tailwindcss:
43+ specifier: ^4.1.0
44+ version: 4.3.3
45+ typescript:
46+ specifier: ^5.7.0
47+ version: 5.9.3
48+
49+packages:
50+
51+ '@alloc/quick-lru@5.3.0':
52+ resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
53+ engines: {node: '>=10'}
54+
55+ '@emnapi/runtime@1.11.3':
56+ resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
57+
58+ '@img/colour@1.1.0':
59+ resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
60+ engines: {node: '>=18'}
61+
62+ '@img/sharp-darwin-arm64@0.35.5':
63+ resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
64+ engines: {node: '>=20.9.0'}
65+ cpu: [arm64]
66+ os: [darwin]
67+
68+ '@img/sharp-darwin-x64@0.35.5':
69+ resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
70+ engines: {node: '>=20.9.0'}
71+ cpu: [x64]
72+ os: [darwin]
73+
74+ '@img/sharp-freebsd-wasm32@0.35.5':
75+ resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
76+ engines: {node: '>=20.9.0'}
77+ os: [freebsd]
78+
79+ '@img/sharp-libvips-darwin-arm64@1.3.4':
80+ resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
81+ cpu: [arm64]
82+ os: [darwin]
83+
84+ '@img/sharp-libvips-darwin-x64@1.3.4':
85+ resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
86+ cpu: [x64]
87+ os: [darwin]
88+
89+ '@img/sharp-libvips-linux-arm64@1.3.4':
90+ resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
91+ cpu: [arm64]
92+ os: [linux]
93+ libc: [glibc]
94+
95+ '@img/sharp-libvips-linux-arm@1.3.4':
96+ resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
97+ cpu: [arm]
98+ os: [linux]
99+ libc: [glibc]
100+
101+ '@img/sharp-libvips-linux-ppc64@1.3.4':
102+ resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
103+ cpu: [ppc64]
104+ os: [linux]
105+ libc: [glibc]
106+
107+ '@img/sharp-libvips-linux-riscv64@1.3.4':
108+ resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
109+ cpu: [riscv64]
110+ os: [linux]
111+ libc: [glibc]
112+
113+ '@img/sharp-libvips-linux-s390x@1.3.4':
114+ resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
115+ cpu: [s390x]
116+ os: [linux]
117+ libc: [glibc]
118+
119+ '@img/sharp-libvips-linux-x64@1.3.4':
120+ resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
121+ cpu: [x64]
122+ os: [linux]
123+ libc: [glibc]
124+
125+ '@img/sharp-libvips-linuxmusl-arm64@1.3.4':
126+ resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
127+ cpu: [arm64]
128+ os: [linux]
129+ libc: [musl]
130+
131+ '@img/sharp-libvips-linuxmusl-x64@1.3.4':
132+ resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
133+ cpu: [x64]
134+ os: [linux]
135+ libc: [musl]
136+
137+ '@img/sharp-linux-arm64@0.35.5':
138+ resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
139+ engines: {node: '>=20.9.0'}
140+ cpu: [arm64]
141+ os: [linux]
142+ libc: [glibc]
143+
144+ '@img/sharp-linux-arm@0.35.5':
145+ resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
146+ engines: {node: '>=20.9.0'}
147+ cpu: [arm]
148+ os: [linux]
149+ libc: [glibc]
150+
151+ '@img/sharp-linux-ppc64@0.35.5':
152+ resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
153+ engines: {node: '>=20.9.0'}
154+ cpu: [ppc64]
155+ os: [linux]
156+ libc: [glibc]
157+
158+ '@img/sharp-linux-riscv64@0.35.5':
159+ resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
160+ engines: {node: '>=20.9.0'}
161+ cpu: [riscv64]
162+ os: [linux]
163+ libc: [glibc]
164+
165+ '@img/sharp-linux-s390x@0.35.5':
166+ resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
167+ engines: {node: '>=20.9.0'}
168+ cpu: [s390x]
169+ os: [linux]
170+ libc: [glibc]
171+
172+ '@img/sharp-linux-x64@0.35.5':
173+ resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
174+ engines: {node: '>=20.9.0'}
175+ cpu: [x64]
176+ os: [linux]
177+ libc: [glibc]
178+
179+ '@img/sharp-linuxmusl-arm64@0.35.5':
180+ resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
181+ engines: {node: '>=20.9.0'}
182+ cpu: [arm64]
183+ os: [linux]
184+ libc: [musl]
185+
186+ '@img/sharp-linuxmusl-x64@0.35.5':
187+ resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
188+ engines: {node: '>=20.9.0'}
189+ cpu: [x64]
190+ os: [linux]
191+ libc: [musl]
192+
193+ '@img/sharp-wasm32@0.35.5':
194+ resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
195+ engines: {node: '>=20.9.0'}
196+
197+ '@img/sharp-webcontainers-wasm32@0.35.5':
198+ resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
199+ engines: {node: '>=20.9.0'}
200+ cpu: [wasm32]
201+
202+ '@img/sharp-win32-arm64@0.35.5':
203+ resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
204+ engines: {node: '>=20.9.0'}
205+ cpu: [arm64]
206+ os: [win32]
207+
208+ '@img/sharp-win32-ia32@0.35.5':
209+ resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
210+ engines: {node: ^20.9.0}
211+ cpu: [ia32]
212+ os: [win32]
213+
214+ '@img/sharp-win32-x64@0.35.5':
215+ resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
216+ engines: {node: '>=20.9.0'}
217+ cpu: [x64]
218+ os: [win32]
219+
220+ '@jridgewell/gen-mapping@0.3.13':
221+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
222+
223+ '@jridgewell/remapping@2.3.5':
224+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
225+
226+ '@jridgewell/resolve-uri@3.1.2':
227+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
228+ engines: {node: '>=6.0.0'}
229+
230+ '@jridgewell/sourcemap-codec@1.6.0':
231+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
232+
233+ '@jridgewell/trace-mapping@0.3.31':
234+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
235+
236+ '@next/env@15.5.28':
237+ resolution: {integrity: sha512-xxW5EBI8fMPuVTPBW7LL980IRbuDXlbkUAwp9DDhtb4Lnich3nJ13uYpvrX4VE74hWPf6AvHHAMmGCidcHjbfg==}
238+
239+ '@next/swc-darwin-arm64@15.5.28':
240+ resolution: {integrity: sha512-dPoQYXcfSIqx5o+TOXVXO7kONK5qbtNRDeiTfII0bT3puGIT6VF5e0QwmxNZfEZ5Zety5f3m9P/p4xqwfiLlPg==}
241+ engines: {node: '>= 10'}
242+ cpu: [arm64]
243+ os: [darwin]
244+
245+ '@next/swc-darwin-x64@15.5.28':
246+ resolution: {integrity: sha512-RgCJ7buBivTH+6sddqaVm+/v8kwMHlFkobvdt3DfETutws5HrxnToMIWSN9X3MZ3aAi7BbKrGZZWUDQLEycXqw==}
247+ engines: {node: '>= 10'}
248+ cpu: [x64]
249+ os: [darwin]
250+
251+ '@next/swc-linux-arm64-gnu@15.5.28':
252+ resolution: {integrity: sha512-OHXQe13olE59aKe++zHavGcJPh5SLHXi6Anret3BBeq1+qhSuOJDNAli/EDDfxs6UTqA4Qddo4uyYry2slyjiQ==}
253+ engines: {node: '>= 10'}
254+ cpu: [arm64]
255+ os: [linux]
256+ libc: [glibc]
257+
258+ '@next/swc-linux-arm64-musl@15.5.28':
259+ resolution: {integrity: sha512-nTB7bB6vfn4ezCJNYP43ukt2o8L3+WxKF/sTcTxepdXsJh3SM9RhlU7wrZYbH7L4HO5M87dqDTwm8587vAkCiw==}
260+ engines: {node: '>= 10'}
261+ cpu: [arm64]
262+ os: [linux]
263+ libc: [musl]
264+
265+ '@next/swc-linux-x64-gnu@15.5.28':
266+ resolution: {integrity: sha512-WXCfVI32rW3xCusoUX1RpZKtyChWU2Zwe27JUDmM3KrXwcDNc9vV4zEq0GKFAURnuQ/Ouo/XqPvB0yh445XZCg==}
267+ engines: {node: '>= 10'}
268+ cpu: [x64]
269+ os: [linux]
270+ libc: [glibc]
271+
272+ '@next/swc-linux-x64-musl@15.5.28':
273+ resolution: {integrity: sha512-Miq+NDb/HWsXuxseYxMM6fuEOQXgTziarkKIRpVKCn8W69cEfhQaQr+90pO+u4q2CbJR2wzXmUuCFyZaJs/Nng==}
274+ engines: {node: '>= 10'}
275+ cpu: [x64]
276+ os: [linux]
277+ libc: [musl]
278+
279+ '@next/swc-win32-arm64-msvc@15.5.28':
280+ resolution: {integrity: sha512-5LR79lemgDlgxvG7MQJ+zHCkVcrUoQuO7CiIvq3I58vNW4goSOE2b+19Z6FujRWMouvni4gvudYFuFuNxolGWA==}
281+ engines: {node: '>= 10'}
282+ cpu: [arm64]
283+ os: [win32]
284+
285+ '@next/swc-win32-x64-msvc@15.5.28':
286+ resolution: {integrity: sha512-GNipvXUYROSTf4PXEyH95FrWImIB5zdaK6uEOg0RP1/X2drnvoMwiBkJ0VWdzX7w3iiBm3y5g2TxkKDGVTOQcg==}
287+ engines: {node: '>= 10'}
288+ cpu: [x64]
289+ os: [win32]
290+
291+ '@reduxjs/toolkit@2.13.0':
292+ resolution: {integrity: sha512-f6jwVRfwW30+tnllz4YcsHHpPYYOE7abcHkyUCovD+P7DyhdAI5prz2Fh/8TLhakjpN+7aIEion1s5L/tvlYlA==}
293+ peerDependencies:
294+ react: ^16.9.0 || ^17.0.0 || ^18 || ^19
295+ react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
296+ peerDependenciesMeta:
297+ react:
298+ optional: true
299+ react-redux:
300+ optional: true
301+
302+ '@standard-schema/spec@1.1.0':
303+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
304+
305+ '@standard-schema/utils@0.3.0':
306+ resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
307+
308+ '@swc/helpers@0.5.15':
309+ resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
310+
311+ '@tailwindcss/node@4.3.3':
312+ resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
313+
314+ '@tailwindcss/oxide-android-arm64@4.3.3':
315+ resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
316+ engines: {node: '>= 20'}
317+ cpu: [arm64]
318+ os: [android]
319+
320+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
321+ resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
322+ engines: {node: '>= 20'}
323+ cpu: [arm64]
324+ os: [darwin]
325+
326+ '@tailwindcss/oxide-darwin-x64@4.3.3':
327+ resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
328+ engines: {node: '>= 20'}
329+ cpu: [x64]
330+ os: [darwin]
331+
332+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
333+ resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
334+ engines: {node: '>= 20'}
335+ cpu: [x64]
336+ os: [freebsd]
337+
338+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
339+ resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
340+ engines: {node: '>= 20'}
341+ cpu: [arm]
342+ os: [linux]
343+
344+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
345+ resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
346+ engines: {node: '>= 20'}
347+ cpu: [arm64]
348+ os: [linux]
349+ libc: [glibc]
350+
351+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
352+ resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
353+ engines: {node: '>= 20'}
354+ cpu: [arm64]
355+ os: [linux]
356+ libc: [musl]
357+
358+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
359+ resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
360+ engines: {node: '>= 20'}
361+ cpu: [x64]
362+ os: [linux]
363+ libc: [glibc]
364+
365+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
366+ resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
367+ engines: {node: '>= 20'}
368+ cpu: [x64]
369+ os: [linux]
370+ libc: [musl]
371+
372+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
373+ resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
374+ engines: {node: '>=14.0.0'}
375+ cpu: [wasm32]
376+ bundledDependencies:
377+ - '@napi-rs/wasm-runtime'
378+ - '@emnapi/core'
379+ - '@emnapi/runtime'
380+ - '@tybys/wasm-util'
381+ - '@emnapi/wasi-threads'
382+ - tslib
383+
384+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
385+ resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
386+ engines: {node: '>= 20'}
387+ cpu: [arm64]
388+ os: [win32]
389+
390+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
391+ resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
392+ engines: {node: '>= 20'}
393+ cpu: [x64]
394+ os: [win32]
395+
396+ '@tailwindcss/oxide@4.3.3':
397+ resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
398+ engines: {node: '>= 20'}
399+
400+ '@tailwindcss/postcss@4.3.3':
401+ resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
402+
403+ '@types/d3-array@3.2.2':
404+ resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
405+
406+ '@types/d3-color@3.1.3':
407+ resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
408+
409+ '@types/d3-ease@3.0.2':
410+ resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
411+
412+ '@types/d3-interpolate@3.0.4':
413+ resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
414+
415+ '@types/d3-path@3.1.1':
416+ resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
417+
418+ '@types/d3-scale@4.0.9':
419+ resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
420+
421+ '@types/d3-shape@3.2.0':
422+ resolution: {integrity: sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==}
423+
424+ '@types/d3-time@3.0.4':
425+ resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
426+
427+ '@types/d3-timer@3.0.2':
428+ resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
429+
430+ '@types/node@22.20.5':
431+ resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
432+
433+ '@types/react-dom@19.3.0':
434+ resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==}
435+ peerDependencies:
436+ '@types/react': ^19.3.0
437+
438+ '@types/react@19.3.0':
439+ resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==}
440+
441+ '@types/use-sync-external-store@0.0.6':
442+ resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
443+
444+ caniuse-lite@1.0.30001820:
445+ resolution: {integrity: sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==}
446+
447+ client-only@0.0.1:
448+ resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
449+
450+ clsx@2.1.1:
451+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
452+ engines: {node: '>=6'}
453+
454+ csstype@3.2.3:
455+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
456+
457+ d3-array@3.2.4:
458+ resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
459+ engines: {node: '>=12'}
460+
461+ d3-color@3.1.0:
462+ resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
463+ engines: {node: '>=12'}
464+
465+ d3-ease@3.0.1:
466+ resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
467+ engines: {node: '>=12'}
468+
469+ d3-format@3.1.2:
470+ resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
471+ engines: {node: '>=12'}
472+
473+ d3-interpolate@3.0.1:
474+ resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
475+ engines: {node: '>=12'}
476+
477+ d3-path@3.1.0:
478+ resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
479+ engines: {node: '>=12'}
480+
481+ d3-scale@4.0.2:
482+ resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
483+ engines: {node: '>=12'}
484+
485+ d3-shape@3.2.0:
486+ resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
487+ engines: {node: '>=12'}
488+
489+ d3-time-format@4.1.0:
490+ resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
491+ engines: {node: '>=12'}
492+
493+ d3-time@3.1.0:
494+ resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
495+ engines: {node: '>=12'}
496+
497+ d3-timer@3.0.1:
498+ resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
499+ engines: {node: '>=12'}
500+
501+ decimal.js-light@2.5.1:
502+ resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
503+
504+ detect-libc@2.1.2:
505+ resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
506+ engines: {node: '>=8'}
507+
508+ enhanced-resolve@5.26.0:
509+ resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
510+ engines: {node: '>=10.13.0'}
511+
512+ es-toolkit@1.53.1:
513+ resolution: {integrity: sha512-VhMc6IXmexu8iYFfL2aaAww6IvJTdHlqBLruLoEUs33YD4IcP5DdZcLOybMsMmmLsiy1c8PsiLsrM0DdIuuRUA==}
514+
515+ eventemitter3@5.0.4:
516+ resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
517+
518+ graceful-fs@4.2.11:
519+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
520+
521+ immer@11.2.9:
522+ resolution: {integrity: sha512-IQ4UEtXjZjyefyWjtLEDXUSrF6T2hwjKPi6BIJl3bY/2XXW24d6cNGdXJSBOA/+KNnuiXkZY6DR0C3efolhitA==}
523+
524+ internmap@2.0.3:
525+ resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
526+ engines: {node: '>=12'}
527+
528+ jiti@2.7.0:
529+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
530+ hasBin: true
531+
532+ lightningcss-android-arm64@1.32.0:
533+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
534+ engines: {node: '>= 12.0.0'}
535+ cpu: [arm64]
536+ os: [android]
537+
538+ lightningcss-darwin-arm64@1.32.0:
539+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
540+ engines: {node: '>= 12.0.0'}
541+ cpu: [arm64]
542+ os: [darwin]
543+
544+ lightningcss-darwin-x64@1.32.0:
545+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
546+ engines: {node: '>= 12.0.0'}
547+ cpu: [x64]
548+ os: [darwin]
549+
550+ lightningcss-freebsd-x64@1.32.0:
551+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
552+ engines: {node: '>= 12.0.0'}
553+ cpu: [x64]
554+ os: [freebsd]
555+
556+ lightningcss-linux-arm-gnueabihf@1.32.0:
557+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
558+ engines: {node: '>= 12.0.0'}
559+ cpu: [arm]
560+ os: [linux]
561+
562+ lightningcss-linux-arm64-gnu@1.32.0:
563+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
564+ engines: {node: '>= 12.0.0'}
565+ cpu: [arm64]
566+ os: [linux]
567+ libc: [glibc]
568+
569+ lightningcss-linux-arm64-musl@1.32.0:
570+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
571+ engines: {node: '>= 12.0.0'}
572+ cpu: [arm64]
573+ os: [linux]
574+ libc: [musl]
575+
576+ lightningcss-linux-x64-gnu@1.32.0:
577+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
578+ engines: {node: '>= 12.0.0'}
579+ cpu: [x64]
580+ os: [linux]
581+ libc: [glibc]
582+
583+ lightningcss-linux-x64-musl@1.32.0:
584+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
585+ engines: {node: '>= 12.0.0'}
586+ cpu: [x64]
587+ os: [linux]
588+ libc: [musl]
589+
590+ lightningcss-win32-arm64-msvc@1.32.0:
591+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
592+ engines: {node: '>= 12.0.0'}
593+ cpu: [arm64]
594+ os: [win32]
595+
596+ lightningcss-win32-x64-msvc@1.32.0:
597+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
598+ engines: {node: '>= 12.0.0'}
599+ cpu: [x64]
600+ os: [win32]
601+
602+ lightningcss@1.32.0:
603+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
604+ engines: {node: '>= 12.0.0'}
605+
606+ lucide-react@1.66.0:
607+ resolution: {integrity: sha512-8zjbi/Us+ETXqfBwmXF/FHrWGIqOdE6zT1yUdRJLQVoptomSkB0zdFWVIM667t9K8djWDaXdkBkqi2qVTuDNiw==}
608+ peerDependencies:
609+ '@types/react': '*'
610+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
611+ peerDependenciesMeta:
612+ '@types/react':
613+ optional: true
614+
615+ magic-string@0.30.21:
616+ resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
617+
618+ nanoid@3.3.20:
619+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
620+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
621+ hasBin: true
622+
623+ next@15.5.28:
624+ resolution: {integrity: sha512-JjhLBBY1PNnuwPtcZ7t8yxVeJVYvD2Jyp633J+gRCNbzSt7N0E+xqCf6uQiff3+3qmUkwcF/ksfSsK1xsZ76Tw==}
625+ engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
626+ hasBin: true
627+ peerDependencies:
628+ '@opentelemetry/api': ^1.1.0
629+ '@playwright/test': ^1.51.1
630+ babel-plugin-react-compiler: '*'
631+ react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
632+ react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
633+ sass: ^1.3.0
634+ peerDependenciesMeta:
635+ '@opentelemetry/api':
636+ optional: true
637+ '@playwright/test':
638+ optional: true
639+ babel-plugin-react-compiler:
640+ optional: true
641+ sass:
642+ optional: true
643+
644+ picocolors@1.1.1:
645+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
646+
647+ postcss@8.4.31:
648+ resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
649+ engines: {node: ^10 || ^12 || >=14}
650+
651+ postcss@8.5.32:
652+ resolution: {integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==}
653+ engines: {node: ^10 || ^12 || >=14}
654+
655+ react-dom@19.3.1:
656+ resolution: {integrity: sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==}
657+ peerDependencies:
658+ react: ^19.3.1
659+
660+ react-is@18.3.1:
661+ resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
662+
663+ react-redux@9.3.1:
664+ resolution: {integrity: sha512-vxMLT0lalUjbZPkoTepWyKAZbjIIFssNx/CEL9lTfwQBU6XpkGSmCNdcCIrkOBUgB68uftZMnbXVb6DwGnhRKQ==}
665+ peerDependencies:
666+ '@types/react': ^18.2.25 || ^19
667+ react: ^18.0 || ^19
668+ redux: ^5.0.0
669+ peerDependenciesMeta:
670+ '@types/react':
671+ optional: true
672+ redux:
673+ optional: true
674+
675+ react@19.3.1:
676+ resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
677+ engines: {node: '>=0.10.0'}
678+
679+ recharts@3.10.1:
680+ resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==}
681+ engines: {node: '>=18'}
682+ peerDependencies:
683+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
684+ react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
685+ react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
686+
687+ redux-thunk@3.1.0:
688+ resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
689+ peerDependencies:
690+ redux: ^5.0.0
691+
692+ redux@5.0.1:
693+ resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
694+
695+ reselect@5.2.0:
696+ resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
697+
698+ scheduler@0.28.0:
699+ resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==}
700+
701+ semver@7.8.5:
702+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
703+ engines: {node: '>=10'}
704+ hasBin: true
705+
706+ sharp@0.35.5:
707+ resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
708+ engines: {node: '>=20.9.0'}
709+ peerDependencies:
710+ '@types/node': '*'
711+ peerDependenciesMeta:
712+ '@types/node':
713+ optional: true
714+
715+ source-map-js@1.2.2:
716+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
717+ engines: {node: '>=0.10.0'}
718+
719+ styled-jsx@5.1.6:
720+ resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
721+ engines: {node: '>= 12.0.0'}
722+ peerDependencies:
723+ '@babel/core': '*'
724+ babel-plugin-macros: '*'
725+ react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
726+ peerDependenciesMeta:
727+ '@babel/core':
728+ optional: true
729+ babel-plugin-macros:
730+ optional: true
731+
732+ tailwindcss@4.3.3:
733+ resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
734+
735+ tapable@2.3.3:
736+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
737+ engines: {node: '>=6'}
738+
739+ tiny-invariant@1.3.3:
740+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
741+
742+ tslib@2.9.1:
743+ resolution: {integrity: sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==}
744+
745+ typescript@5.9.3:
746+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
747+ engines: {node: '>=14.17'}
748+ hasBin: true
749+
750+ undici-types@6.21.0:
751+ resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
752+
753+ use-sync-external-store@1.7.0:
754+ resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
755+ peerDependencies:
756+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
757+
758+ victory-vendor@37.4.1:
759+ resolution: {integrity: sha512-geV4p2GVfVM9lgm9ZRS3pvsbAPS+BUGhM9W9nPzPHTKi+/+KjNrFXdgP6KkG+XspNZuvcXmchcrtklfuQ4gniw==}
760+
761+snapshots:
762+
763+ '@alloc/quick-lru@5.3.0': {}
764+
765+ '@emnapi/runtime@1.11.3':
766+ dependencies:
767+ tslib: 2.9.1
768+ optional: true
769+
770+ '@img/colour@1.1.0':
771+ optional: true
772+
773+ '@img/sharp-darwin-arm64@0.35.5':
774+ optionalDependencies:
775+ '@img/sharp-libvips-darwin-arm64': 1.3.4
776+ optional: true
777+
778+ '@img/sharp-darwin-x64@0.35.5':
779+ optionalDependencies:
780+ '@img/sharp-libvips-darwin-x64': 1.3.4
781+ optional: true
782+
783+ '@img/sharp-freebsd-wasm32@0.35.5':
784+ dependencies:
785+ '@img/sharp-wasm32': 0.35.5
786+ optional: true
787+
788+ '@img/sharp-libvips-darwin-arm64@1.3.4':
789+ optional: true
790+
791+ '@img/sharp-libvips-darwin-x64@1.3.4':
792+ optional: true
793+
794+ '@img/sharp-libvips-linux-arm64@1.3.4':
795+ optional: true
796+
797+ '@img/sharp-libvips-linux-arm@1.3.4':
798+ optional: true
799+
800+ '@img/sharp-libvips-linux-ppc64@1.3.4':
801+ optional: true
802+
803+ '@img/sharp-libvips-linux-riscv64@1.3.4':
804+ optional: true
805+
806+ '@img/sharp-libvips-linux-s390x@1.3.4':
807+ optional: true
808+
809+ '@img/sharp-libvips-linux-x64@1.3.4':
810+ optional: true
811+
812+ '@img/sharp-libvips-linuxmusl-arm64@1.3.4':
813+ optional: true
814+
815+ '@img/sharp-libvips-linuxmusl-x64@1.3.4':
816+ optional: true
817+
818+ '@img/sharp-linux-arm64@0.35.5':
819+ optionalDependencies:
820+ '@img/sharp-libvips-linux-arm64': 1.3.4
821+ optional: true
822+
823+ '@img/sharp-linux-arm@0.35.5':
824+ optionalDependencies:
825+ '@img/sharp-libvips-linux-arm': 1.3.4
826+ optional: true
827+
828+ '@img/sharp-linux-ppc64@0.35.5':
829+ optionalDependencies:
830+ '@img/sharp-libvips-linux-ppc64': 1.3.4
831+ optional: true
832+
833+ '@img/sharp-linux-riscv64@0.35.5':
834+ optionalDependencies:
835+ '@img/sharp-libvips-linux-riscv64': 1.3.4
836+ optional: true
837+
838+ '@img/sharp-linux-s390x@0.35.5':
839+ optionalDependencies:
840+ '@img/sharp-libvips-linux-s390x': 1.3.4
841+ optional: true
842+
843+ '@img/sharp-linux-x64@0.35.5':
844+ optionalDependencies:
845+ '@img/sharp-libvips-linux-x64': 1.3.4
846+ optional: true
847+
848+ '@img/sharp-linuxmusl-arm64@0.35.5':
849+ optionalDependencies:
850+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
851+ optional: true
852+
853+ '@img/sharp-linuxmusl-x64@0.35.5':
854+ optionalDependencies:
855+ '@img/sharp-libvips-linuxmusl-x64': 1.3.4
856+ optional: true
857+
858+ '@img/sharp-wasm32@0.35.5':
859+ dependencies:
860+ '@emnapi/runtime': 1.11.3
861+ optional: true
862+
863+ '@img/sharp-webcontainers-wasm32@0.35.5':
864+ dependencies:
865+ '@img/sharp-wasm32': 0.35.5
866+ optional: true
867+
868+ '@img/sharp-win32-arm64@0.35.5':
869+ optional: true
870+
871+ '@img/sharp-win32-ia32@0.35.5':
872+ optional: true
873+
874+ '@img/sharp-win32-x64@0.35.5':
875+ optional: true
876+
877+ '@jridgewell/gen-mapping@0.3.13':
878+ dependencies:
879+ '@jridgewell/sourcemap-codec': 1.6.0
880+ '@jridgewell/trace-mapping': 0.3.31
881+
882+ '@jridgewell/remapping@2.3.5':
883+ dependencies:
884+ '@jridgewell/gen-mapping': 0.3.13
885+ '@jridgewell/trace-mapping': 0.3.31
886+
887+ '@jridgewell/resolve-uri@3.1.2': {}
888+
889+ '@jridgewell/sourcemap-codec@1.6.0': {}
890+
891+ '@jridgewell/trace-mapping@0.3.31':
892+ dependencies:
893+ '@jridgewell/resolve-uri': 3.1.2
894+ '@jridgewell/sourcemap-codec': 1.6.0
895+
896+ '@next/env@15.5.28': {}
897+
898+ '@next/swc-darwin-arm64@15.5.28':
899+ optional: true
900+
901+ '@next/swc-darwin-x64@15.5.28':
902+ optional: true
903+
904+ '@next/swc-linux-arm64-gnu@15.5.28':
905+ optional: true
906+
907+ '@next/swc-linux-arm64-musl@15.5.28':
908+ optional: true
909+
910+ '@next/swc-linux-x64-gnu@15.5.28':
911+ optional: true
912+
913+ '@next/swc-linux-x64-musl@15.5.28':
914+ optional: true
915+
916+ '@next/swc-win32-arm64-msvc@15.5.28':
917+ optional: true
918+
919+ '@next/swc-win32-x64-msvc@15.5.28':
920+ optional: true
921+
922+ '@reduxjs/toolkit@2.13.0(react-redux@9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1))(react@19.3.1)':
923+ dependencies:
924+ '@standard-schema/spec': 1.1.0
925+ '@standard-schema/utils': 0.3.0
926+ immer: 11.2.9
927+ redux: 5.0.1
928+ redux-thunk: 3.1.0(redux@5.0.1)
929+ reselect: 5.2.0
930+ optionalDependencies:
931+ react: 19.3.1
932+ react-redux: 9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1)
933+
934+ '@standard-schema/spec@1.1.0': {}
935+
936+ '@standard-schema/utils@0.3.0': {}
937+
938+ '@swc/helpers@0.5.15':
939+ dependencies:
940+ tslib: 2.9.1
941+
942+ '@tailwindcss/node@4.3.3':
943+ dependencies:
944+ '@jridgewell/remapping': 2.3.5
945+ enhanced-resolve: 5.26.0
946+ jiti: 2.7.0
947+ lightningcss: 1.32.0
948+ magic-string: 0.30.21
949+ source-map-js: 1.2.2
950+ tailwindcss: 4.3.3
951+
952+ '@tailwindcss/oxide-android-arm64@4.3.3':
953+ optional: true
954+
955+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
956+ optional: true
957+
958+ '@tailwindcss/oxide-darwin-x64@4.3.3':
959+ optional: true
960+
961+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
962+ optional: true
963+
964+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
965+ optional: true
966+
967+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
968+ optional: true
969+
970+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
971+ optional: true
972+
973+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
974+ optional: true
975+
976+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
977+ optional: true
978+
979+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
980+ optional: true
981+
982+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
983+ optional: true
984+
985+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
986+ optional: true
987+
988+ '@tailwindcss/oxide@4.3.3':
989+ optionalDependencies:
990+ '@tailwindcss/oxide-android-arm64': 4.3.3
991+ '@tailwindcss/oxide-darwin-arm64': 4.3.3
992+ '@tailwindcss/oxide-darwin-x64': 4.3.3
993+ '@tailwindcss/oxide-freebsd-x64': 4.3.3
994+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
995+ '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
996+ '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
997+ '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
998+ '@tailwindcss/oxide-linux-x64-musl': 4.3.3
999+ '@tailwindcss/oxide-wasm32-wasi': 4.3.3
1000+ '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
1001+ '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
1002+
1003+ '@tailwindcss/postcss@4.3.3':
1004+ dependencies:
1005+ '@alloc/quick-lru': 5.3.0
1006+ '@tailwindcss/node': 4.3.3
1007+ '@tailwindcss/oxide': 4.3.3
1008+ postcss: 8.5.32
1009+ tailwindcss: 4.3.3
1010+
1011+ '@types/d3-array@3.2.2': {}
1012+
1013+ '@types/d3-color@3.1.3': {}
1014+
1015+ '@types/d3-ease@3.0.2': {}
1016+
1017+ '@types/d3-interpolate@3.0.4':
1018+ dependencies:
1019+ '@types/d3-color': 3.1.3
1020+
1021+ '@types/d3-path@3.1.1': {}
1022+
1023+ '@types/d3-scale@4.0.9':
1024+ dependencies:
1025+ '@types/d3-time': 3.0.4
1026+
1027+ '@types/d3-shape@3.2.0':
1028+ dependencies:
1029+ '@types/d3-path': 3.1.1
1030+
1031+ '@types/d3-time@3.0.4': {}
1032+
1033+ '@types/d3-timer@3.0.2': {}
1034+
1035+ '@types/node@22.20.5':
1036+ dependencies:
1037+ undici-types: 6.21.0
1038+
1039+ '@types/react-dom@19.3.0(@types/react@19.3.0)':
1040+ dependencies:
1041+ '@types/react': 19.3.0
1042+
1043+ '@types/react@19.3.0':
1044+ dependencies:
1045+ csstype: 3.2.3
1046+
1047+ '@types/use-sync-external-store@0.0.6': {}
1048+
1049+ caniuse-lite@1.0.30001820: {}
1050+
1051+ client-only@0.0.1: {}
1052+
1053+ clsx@2.1.1: {}
1054+
1055+ csstype@3.2.3: {}
1056+
1057+ d3-array@3.2.4:
1058+ dependencies:
1059+ internmap: 2.0.3
1060+
1061+ d3-color@3.1.0: {}
1062+
1063+ d3-ease@3.0.1: {}
1064+
1065+ d3-format@3.1.2: {}
1066+
1067+ d3-interpolate@3.0.1:
1068+ dependencies:
1069+ d3-color: 3.1.0
1070+
1071+ d3-path@3.1.0: {}
1072+
1073+ d3-scale@4.0.2:
1074+ dependencies:
1075+ d3-array: 3.2.4
1076+ d3-format: 3.1.2
1077+ d3-interpolate: 3.0.1
1078+ d3-time: 3.1.0
1079+ d3-time-format: 4.1.0
1080+
1081+ d3-shape@3.2.0:
1082+ dependencies:
1083+ d3-path: 3.1.0
1084+
1085+ d3-time-format@4.1.0:
1086+ dependencies:
1087+ d3-time: 3.1.0
1088+
1089+ d3-time@3.1.0:
1090+ dependencies:
1091+ d3-array: 3.2.4
1092+
1093+ d3-timer@3.0.1: {}
1094+
1095+ decimal.js-light@2.5.1: {}
1096+
1097+ detect-libc@2.1.2: {}
1098+
1099+ enhanced-resolve@5.26.0:
1100+ dependencies:
1101+ graceful-fs: 4.2.11
1102+ tapable: 2.3.3
1103+
1104+ es-toolkit@1.53.1: {}
1105+
1106+ eventemitter3@5.0.4: {}
1107+
1108+ graceful-fs@4.2.11: {}
1109+
1110+ immer@11.2.9: {}
1111+
1112+ internmap@2.0.3: {}
1113+
1114+ jiti@2.7.0: {}
1115+
1116+ lightningcss-android-arm64@1.32.0:
1117+ optional: true
1118+
1119+ lightningcss-darwin-arm64@1.32.0:
1120+ optional: true
1121+
1122+ lightningcss-darwin-x64@1.32.0:
1123+ optional: true
1124+
1125+ lightningcss-freebsd-x64@1.32.0:
1126+ optional: true
1127+
1128+ lightningcss-linux-arm-gnueabihf@1.32.0:
1129+ optional: true
1130+
1131+ lightningcss-linux-arm64-gnu@1.32.0:
1132+ optional: true
1133+
1134+ lightningcss-linux-arm64-musl@1.32.0:
1135+ optional: true
1136+
1137+ lightningcss-linux-x64-gnu@1.32.0:
1138+ optional: true
1139+
1140+ lightningcss-linux-x64-musl@1.32.0:
1141+ optional: true
1142+
1143+ lightningcss-win32-arm64-msvc@1.32.0:
1144+ optional: true
1145+
1146+ lightningcss-win32-x64-msvc@1.32.0:
1147+ optional: true
1148+
1149+ lightningcss@1.32.0:
1150+ dependencies:
1151+ detect-libc: 2.1.2
1152+ optionalDependencies:
1153+ lightningcss-android-arm64: 1.32.0
1154+ lightningcss-darwin-arm64: 1.32.0
1155+ lightningcss-darwin-x64: 1.32.0
1156+ lightningcss-freebsd-x64: 1.32.0
1157+ lightningcss-linux-arm-gnueabihf: 1.32.0
1158+ lightningcss-linux-arm64-gnu: 1.32.0
1159+ lightningcss-linux-arm64-musl: 1.32.0
1160+ lightningcss-linux-x64-gnu: 1.32.0
1161+ lightningcss-linux-x64-musl: 1.32.0
1162+ lightningcss-win32-arm64-msvc: 1.32.0
1163+ lightningcss-win32-x64-msvc: 1.32.0
1164+
1165+ lucide-react@1.66.0(@types/react@19.3.0)(react@19.3.1):
1166+ dependencies:
1167+ react: 19.3.1
1168+ optionalDependencies:
1169+ '@types/react': 19.3.0
1170+
1171+ magic-string@0.30.21:
1172+ dependencies:
1173+ '@jridgewell/sourcemap-codec': 1.6.0
1174+
1175+ nanoid@3.3.20: {}
1176+
1177+ next@15.5.28(@types/node@22.20.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1):
1178+ dependencies:
1179+ '@next/env': 15.5.28
1180+ '@swc/helpers': 0.5.15
1181+ caniuse-lite: 1.0.30001820
1182+ postcss: 8.4.31
1183+ react: 19.3.1
1184+ react-dom: 19.3.1(react@19.3.1)
1185+ styled-jsx: 5.1.6(react@19.3.1)
1186+ optionalDependencies:
1187+ '@next/swc-darwin-arm64': 15.5.28
1188+ '@next/swc-darwin-x64': 15.5.28
1189+ '@next/swc-linux-arm64-gnu': 15.5.28
1190+ '@next/swc-linux-arm64-musl': 15.5.28
1191+ '@next/swc-linux-x64-gnu': 15.5.28
1192+ '@next/swc-linux-x64-musl': 15.5.28
1193+ '@next/swc-win32-arm64-msvc': 15.5.28
1194+ '@next/swc-win32-x64-msvc': 15.5.28
1195+ sharp: 0.35.5(@types/node@22.20.5)
1196+ transitivePeerDependencies:
1197+ - '@babel/core'
1198+ - '@types/node'
1199+ - babel-plugin-macros
1200+
1201+ picocolors@1.1.1: {}
1202+
1203+ postcss@8.4.31:
1204+ dependencies:
1205+ nanoid: 3.3.20
1206+ picocolors: 1.1.1
1207+ source-map-js: 1.2.2
1208+
1209+ postcss@8.5.32:
1210+ dependencies:
1211+ nanoid: 3.3.20
1212+ picocolors: 1.1.1
1213+ source-map-js: 1.2.2
1214+
1215+ react-dom@19.3.1(react@19.3.1):
1216+ dependencies:
1217+ react: 19.3.1
1218+ scheduler: 0.28.0
1219+
1220+ react-is@18.3.1: {}
1221+
1222+ react-redux@9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1):
1223+ dependencies:
1224+ '@types/use-sync-external-store': 0.0.6
1225+ react: 19.3.1
1226+ use-sync-external-store: 1.7.0(react@19.3.1)
1227+ optionalDependencies:
1228+ '@types/react': 19.3.0
1229+ redux: 5.0.1
1230+
1231+ react@19.3.1: {}
1232+
1233+ recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react-is@18.3.1)(react@19.3.1)(redux@5.0.1):
1234+ dependencies:
1235+ '@reduxjs/toolkit': 2.13.0(react-redux@9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1))(react@19.3.1)
1236+ clsx: 2.1.1
1237+ decimal.js-light: 2.5.1
1238+ es-toolkit: 1.53.1
1239+ eventemitter3: 5.0.4
1240+ immer: 11.2.9
1241+ react: 19.3.1
1242+ react-dom: 19.3.1(react@19.3.1)
1243+ react-is: 18.3.1
1244+ react-redux: 9.3.1(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1)
1245+ reselect: 5.2.0
1246+ tiny-invariant: 1.3.3
1247+ use-sync-external-store: 1.7.0(react@19.3.1)
1248+ victory-vendor: 37.4.1
1249+ transitivePeerDependencies:
1250+ - '@types/react'
1251+ - redux
1252+
1253+ redux-thunk@3.1.0(redux@5.0.1):
1254+ dependencies:
1255+ redux: 5.0.1
1256+
1257+ redux@5.0.1: {}
1258+
1259+ reselect@5.2.0: {}
1260+
1261+ scheduler@0.28.0: {}
1262+
1263+ semver@7.8.5:
1264+ optional: true
1265+
1266+ sharp@0.35.5(@types/node@22.20.5):
1267+ dependencies:
1268+ '@img/colour': 1.1.0
1269+ detect-libc: 2.1.2
1270+ semver: 7.8.5
1271+ optionalDependencies:
1272+ '@img/sharp-darwin-arm64': 0.35.5
1273+ '@img/sharp-darwin-x64': 0.35.5
1274+ '@img/sharp-freebsd-wasm32': 0.35.5
1275+ '@img/sharp-libvips-darwin-arm64': 1.3.4
1276+ '@img/sharp-libvips-darwin-x64': 1.3.4
1277+ '@img/sharp-libvips-linux-arm': 1.3.4
1278+ '@img/sharp-libvips-linux-arm64': 1.3.4
1279+ '@img/sharp-libvips-linux-ppc64': 1.3.4
1280+ '@img/sharp-libvips-linux-riscv64': 1.3.4
1281+ '@img/sharp-libvips-linux-s390x': 1.3.4
1282+ '@img/sharp-libvips-linux-x64': 1.3.4
1283+ '@img/sharp-libvips-linuxmusl-arm64': 1.3.4
1284+ '@img/sharp-libvips-linuxmusl-x64': 1.3.4
1285+ '@img/sharp-linux-arm': 0.35.5
1286+ '@img/sharp-linux-arm64': 0.35.5
1287+ '@img/sharp-linux-ppc64': 0.35.5
1288+ '@img/sharp-linux-riscv64': 0.35.5
1289+ '@img/sharp-linux-s390x': 0.35.5
1290+ '@img/sharp-linux-x64': 0.35.5
1291+ '@img/sharp-linuxmusl-arm64': 0.35.5
1292+ '@img/sharp-linuxmusl-x64': 0.35.5
1293+ '@img/sharp-webcontainers-wasm32': 0.35.5
1294+ '@img/sharp-win32-arm64': 0.35.5
1295+ '@img/sharp-win32-ia32': 0.35.5
1296+ '@img/sharp-win32-x64': 0.35.5
1297+ '@types/node': 22.20.5
1298+ optional: true
1299+
1300+ source-map-js@1.2.2: {}
1301+
1302+ styled-jsx@5.1.6(react@19.3.1):
1303+ dependencies:
1304+ client-only: 0.0.1
1305+ react: 19.3.1
1306+
1307+ tailwindcss@4.3.3: {}
1308+
1309+ tapable@2.3.3: {}
1310+
1311+ tiny-invariant@1.3.3: {}
1312+
1313+ tslib@2.9.1: {}
1314+
1315+ typescript@5.9.3: {}
1316+
1317+ undici-types@6.21.0: {}
1318+
1319+ use-sync-external-store@1.7.0(react@19.3.1):
1320+ dependencies:
1321+ react: 19.3.1
1322+
1323+ victory-vendor@37.4.1:
1324+ dependencies:
1325+ '@types/d3-array': 3.2.2
1326+ '@types/d3-ease': 3.0.2
1327+ '@types/d3-interpolate': 3.0.4
1328+ '@types/d3-scale': 4.0.9
1329+ '@types/d3-shape': 3.2.0
1330+ '@types/d3-time': 3.0.4
1331+ '@types/d3-timer': 3.0.2
1332+ d3-array: 3.2.4
1333+ d3-ease: 3.0.1
1334+ d3-interpolate: 3.0.1
1335+ d3-scale: 4.0.2
1336+ d3-shape: 3.2.0
1337+ d3-time: 3.1.0
1338+ d3-timer: 3.0.1
added apps/web/postcss.config.mjs +2 −0
@@ -0,0 +1,2 @@
1+const config = { plugins: { "@tailwindcss/postcss": {} } };
2+export default config;
added apps/web/public/favicon.svg +1 −0
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2a78d6"/><path d="M8 22V10h4v12zm6 0V14h4v8zm6 0v-5h4v5z" fill="#fff"/></svg>
added apps/web/public/robots.txt +2 −0
@@ -0,0 +1,2 @@
1+User-agent: *
2+Disallow: /
added apps/web/src/app/bars/page.tsx +237 −0
@@ -0,0 +1,237 @@
1+"use client";
2+import Link from "next/link";
3+import { useEffect, useMemo, useState, type FormEvent } from "react";
4+import { Download, Play } from "lucide-react";
5+import { api, isApiError, type Bar, type BarsOut } from "@/lib/api";
6+import { useQueryParam } from "@/lib/hooks";
7+import { useToast } from "@/lib/toast";
8+import { daysAgo, downloadText, fmtInt, fmtNum, fmtTs, toCsv, today } from "@/lib/format";
9+import { Banner, Card, Empty, ErrorState, Field, PageHeader, Tabs } from "@/components/ui";
10+import { DataTable } from "@/components/DataTable";
11+import { CloseLineChart, VolumeBarChart } from "@/components/charts";
12+
13+const MAX_SPAN: Record<string, number> = { "1m": 31, "5m": 186, "1d": 3660 };
14+
15+export default function BarsPage() {
16+ const qSymbol = useQueryParam("symbol");
17+ const qInterval = useQueryParam("interval");
18+ const [symbol, setSymbol] = useState("AAPL");
19+ const [interval, setInterval_] = useState<"1m" | "5m" | "1d">("1m");
20+ const [start, setStart] = useState(daysAgo(7));
21+ const [end, setEnd] = useState(today());
22+ const [adjustment, setAdjustment] = useState<"raw" | "split" | "split_div">("raw");
23+ const [limit, setLimit] = useState(2000);
24+ const [out, setOut] = useState<BarsOut | null>(null);
25+ const [rows, setRows] = useState<Bar[]>([]);
26+ const [busy, setBusy] = useState(false);
27+ const [error, setError] = useState<unknown>(null);
28+ const [view, setView] = useState<"chart" | "table">("chart");
29+ const toast = useToast();
30+
31+ useEffect(() => {
32+ if (qSymbol) setSymbol(qSymbol.toUpperCase());
33+ }, [qSymbol]);
34+ useEffect(() => {
35+ if (qInterval === "1m" || qInterval === "5m" || qInterval === "1d") setInterval_(qInterval);
36+ }, [qInterval]);
37+
38+ const spanDays = useMemo(() => Math.round((new Date(end).getTime() - new Date(start).getTime()) / 86_400_000), [start, end]);
39+ const tooLarge = spanDays > (MAX_SPAN[interval] ?? 31);
40+
41+ async function run(e?: FormEvent) {
42+ e?.preventDefault();
43+ setBusy(true);
44+ setError(null);
45+ try {
46+ const o = await api.get<BarsOut>("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit });
47+ setOut(o);
48+ setRows(o.data);
49+ } catch (err) {
50+ setError(err);
51+ setOut(null);
52+ setRows([]);
53+ } finally {
54+ setBusy(false);
55+ }
56+ }
57+
58+ async function loadMore() {
59+ if (!out?.next_cursor) return;
60+ setBusy(true);
61+ try {
62+ const o = await api.get<BarsOut>("/market/bars", { symbol: symbol.trim().toUpperCase(), interval, start, end, adjustment, limit, cursor: out.next_cursor });
63+ setOut({ ...o, data: [] });
64+ setRows((r) => [...r, ...o.data]);
65+ } catch (err) {
66+ toast.error(err);
67+ } finally {
68+ setBusy(false);
69+ }
70+ }
71+
72+ const chart = useMemo(
73+ () =>
74+ rows.map((b) => ({
75+ t: fmtTs(b.timestamp_utc),
76+ label: interval === "1d" ? b.session_date : fmtTs(b.timestamp_utc).slice(5, 16),
77+ close: b.close,
78+ open: b.open,
79+ high: b.high,
80+ low: b.low,
81+ volume: b.volume,
82+ })),
83+ [rows, interval],
84+ );
85+ const flagged = useMemo(() => rows.filter((b) => b.quality_flags && b.quality_flags.length > 0).length, [rows]);
86+ const flagCounts = useMemo(() => {
87+ const m = new Map<string, number>();
88+ for (const b of rows) for (const f of b.quality_flags ?? []) m.set(f, (m.get(f) ?? 0) + 1);
89+ return Array.from(m.entries()).sort((a, b) => b[1] - a[1]);
90+ }, [rows]);
91+
92+ const rangeErr = isApiError(error) && error.code === "range_too_large";
93+
94+ return (
95+ <>
96+ <PageHeader title="Bars explorer" subtitle="OHLCV bars stamped at bar open (UTC). Adjustments are applied at query time from corporate actions." />
97+ <Card className="mb-4">
98+ <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-6 lg:items-end">
99+ <Field label="Symbol">
100+ <input className="input font-mono uppercase" value={symbol} onChange={(e) => setSymbol(e.target.value)} required />
101+ </Field>
102+ <Field label="Interval">
103+ <select className="input" value={interval} onChange={(e) => setInterval_(e.target.value as typeof interval)}>
104+ <option value="1m">1 minute</option>
105+ <option value="5m">5 minutes</option>
106+ <option value="1d">1 day</option>
107+ </select>
108+ </Field>
109+ <Field label="Start">
110+ <input type="date" className="input" value={start} max={end} onChange={(e) => setStart(e.target.value)} required />
111+ </Field>
112+ <Field label="End">
113+ <input type="date" className="input" value={end} min={start} onChange={(e) => setEnd(e.target.value)} required />
114+ </Field>
115+ <Field label="Adjustment">
116+ <select className="input" value={adjustment} onChange={(e) => setAdjustment(e.target.value as typeof adjustment)}>
117+ <option value="raw">raw (as traded)</option>
118+ <option value="split">split-adjusted</option>
119+ <option value="split_div">split + dividend</option>
120+ </select>
121+ </Field>
122+ <div className="flex items-end gap-2">
123+ <Field label="Page size" className="w-24">
124+ <input type="number" className="input" min={100} max={10000} step={100} value={limit} onChange={(e) => setLimit(Number(e.target.value) || 2000)} />
125+ </Field>
126+ <button className="btn btn-primary flex-1" disabled={busy || !symbol}>
127+ <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"}
128+ </button>
129+ </div>
130+ </form>
131+ <p className={tooLarge ? "mt-2 text-xs text-warn-ink" : "mt-2 text-xs text-ink-3"}>
132+ Range: {spanDays} day{spanDays === 1 ? "" : "s"} · limit for {interval}: {MAX_SPAN[interval]} days per call (HTTP 413 <code>range_too_large</code> beyond). Larger ranges →{" "}
133+ <Link href="/exports/" className="text-accent-ink hover:underline">
134+ Exports
135+ </Link>{" "}
136+ (async Parquet/CSV).
137+ </p>
138+ </Card>
139+
140+ {error && !rangeErr && <ErrorState error={error} retry={() => void run()} />}
141+ {rangeErr && (
142+ <Banner tone="warn" className="mb-4">
143+ <div className="font-medium">Range too large for {interval}</div>
144+ <div className="text-xs">
145+ {(error as Error).message} Use{" "}
146+ <Link href={`/exports/?symbols=${encodeURIComponent(symbol)}&start=${start}&end=${end}&adjustment=${adjustment}`} className="underline">
147+ an export
148+ </Link>{" "}
149+ for {spanDays} days, or shorten the window.
150+ </div>
151+ </Banner>
152+ )}
153+
154+ {out && (
155+ <>
156+ <div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-ink-2">
157+ <span className="pill bg-surface-2">{out.meta.symbol}</span>
158+ <span className="pill bg-surface-2">{out.meta.interval}</span>
159+ <span className="pill bg-surface-2">{out.meta.adjustment}</span>
160+ {out.meta.version !== undefined && <span className="pill bg-surface-2">dataset v{String(out.meta.version)}</span>}
161+ <span className="tnum">
162+ {fmtInt(rows.length)} of {fmtInt(out.meta.rows_matching || rows.length)} rows
163+ </span>
164+ {flagged > 0 && <span className="pill bg-warn-soft text-warn-ink">{fmtInt(flagged)} flagged bars</span>}
165+ <span className="ml-auto">
166+ <Tabs value={view} onChange={setView} items={[{ value: "chart", label: "Chart" }, { value: "table", label: "Table" }]} />
167+ </span>
168+ </div>
169+ {out.meta.adjustment_notes?.length > 0 && (
170+ <Banner tone="accent" className="mb-3">
171+ <div className="text-xs font-medium">Adjustment notes</div>
172+ <ul className="list-disc pl-4 text-xs">
173+ {out.meta.adjustment_notes.map((n, i) => (
174+ <li key={i}>{n}</li>
175+ ))}
176+ </ul>
177+ </Banner>
178+ )}
179+ {flagCounts.length > 0 && (
180+ <div className="mb-3 flex flex-wrap gap-1 text-xs">
181+ <span className="text-ink-3">quality_flags:</span>
182+ {flagCounts.map(([f, n]) => (
183+ <span key={f} className="pill bg-warn-soft text-warn-ink">
184+ {f} × {fmtInt(n)}
185+ </span>
186+ ))}
187+ </div>
188+ )}
189+ {rows.length === 0 ? (
190+ <Empty>No bars for this range. Zero-volume bars are not stored; check the session coverage.</Empty>
191+ ) : view === "chart" ? (
192+ <Card title={`Close · ${out.meta.symbol} (${out.meta.adjustment})`}>
193+ <CloseLineChart data={chart} height={280} />
194+ <div className="mt-2 text-xs font-semibold text-ink-2">Volume</div>
195+ <VolumeBarChart data={chart} height={110} />
196+ <p className="mt-2 text-[11px] text-ink-3">Hover for OHLCV. Single series per chart — close and volume use separate axes by design.</p>
197+ </Card>
198+ ) : (
199+ <Card
200+ actions={
201+ <button className="btn btn-sm" onClick={() => downloadText(`${out.meta.symbol}_${interval}_${start}_${end}.csv`, toCsv(["timestamp_utc", "session_date", "open", "high", "low", "close", "volume", "vwap", "trade_count", "quality_flags"], rows.map((r) => ({ ...r, quality_flags: (r.quality_flags ?? []).join("|") }))))}>
202+ <Download className="h-3.5 w-3.5" /> CSV (loaded rows)
203+ </button>
204+ }
205+ >
206+ <DataTable
207+ rows={rows}
208+ rowKey={(r) => r.timestamp_utc}
209+ mobile="scroll"
210+ maxHeight="60dvh"
211+ columns={[
212+ { key: "timestamp_utc", header: "Timestamp (UTC, bar open)", render: (r) => <span className="tnum font-mono text-xs">{fmtTs(r.timestamp_utc)}</span> },
213+ { key: "session_date", header: "Session", hideSm: true, render: (r) => <span className="tnum">{r.session_date}</span> },
214+ { key: "open", header: "Open", num: true, render: (r) => fmtNum(r.open, 4) },
215+ { key: "high", header: "High", num: true, render: (r) => fmtNum(r.high, 4) },
216+ { key: "low", header: "Low", num: true, render: (r) => fmtNum(r.low, 4) },
217+ { key: "close", header: "Close", num: true, render: (r) => fmtNum(r.close, 4) },
218+ { key: "volume", header: "Volume", num: true, render: (r) => fmtInt(r.volume) },
219+ { key: "vwap", header: "VWAP", num: true, hideSm: true, render: (r) => (r.vwap === null || r.vwap === undefined ? "—" : fmtNum(r.vwap, 4)) },
220+ { key: "quality_flags", header: "Flags", render: (r) => (r.quality_flags?.length ? r.quality_flags.map((f) => <span key={f} className="pill mr-1 bg-warn-soft text-warn-ink">{f}</span>) : <span className="text-ink-3">—</span>) },
221+ ]}
222+ />
223+ </Card>
224+ )}
225+ <div className="mt-3 flex items-center justify-between text-xs text-ink-3">
226+ <span>{out.meta.license} · timestamps = {out.meta.timestamp_convention}</span>
227+ {out.next_cursor && (
228+ <button className="btn btn-sm" onClick={loadMore} disabled={busy}>
229+ {busy ? "Loading…" : `Load next ${fmtInt(limit)}`}
230+ </button>
231+ )}
232+ </div>
233+ </>
234+ )}
235+ </>
236+ );
237+}
added apps/web/src/app/coverage/page.tsx +134 −0
@@ -0,0 +1,134 @@
1+"use client";
2+import Link from "next/link";
3+import { useEffect, useMemo, useState } from "react";
4+import { api, type Dataset, type Heatmap, type SessionRow } from "@/lib/api";
5+import { useAsync, useDebounced, useQueryParam } from "@/lib/hooks";
6+import { daysAgo, fmtCompact, fmtInt, fmtPct, fmtTime, today } from "@/lib/format";
7+import { Card, ErrorState, Field, PageHeader, Skeleton } from "@/components/ui";
8+import { DataTable } from "@/components/DataTable";
9+import { DateRange } from "@/components/DateRange";
10+import { CompletenessHeatmap, VolumeBarChart } from "@/components/charts";
11+
12+const DEFAULT_DS = "market_bars.stock.1m.raw";
13+
14+export default function CoveragePage() {
15+ const qDs = useQueryParam("dataset_id");
16+ const qSym = useQueryParam("symbol");
17+ const [datasetId, setDatasetId] = useState(DEFAULT_DS);
18+ const [start, setStart] = useState(daysAgo(120));
19+ const [end, setEnd] = useState(today());
20+ const [symbol, setSymbol] = useState("");
21+ const dsym = useDebounced(symbol.trim().toUpperCase(), 400);
22+
23+ useEffect(() => {
24+ if (qDs) setDatasetId(qDs);
25+ }, [qDs]);
26+ useEffect(() => {
27+ if (qSym) setSymbol(qSym.toUpperCase());
28+ }, [qSym]);
29+
30+ const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);
31+ const heat = useAsync((s) => api.get<Heatmap>("/coverage/heatmap", { dataset_id: datasetId, start, end }, s), [datasetId, start, end]);
32+ const interval = datasetId.includes(".5m.") ? "5m" : "1m";
33+ const sessions = useAsync((s) => api.get<{ data: SessionRow[]; meta: { dataset_id: string; symbol: string } }>("/market/sessions", { symbol: dsym, interval, start, end }, s), [dsym, interval, start, end], { enabled: dsym.length > 0 });
34+
35+ const barData = useMemo(() => (heat.data?.sessions ?? []).map((s) => ({ label: s.session_date, t: s.session_date, symbols: s.symbols, bars: s.bars })), [heat.data]);
36+ const totals = useMemo(() => {
37+ const ss = heat.data?.sessions ?? [];
38+ const bars = ss.reduce((a, s) => a + Number(s.bars ?? 0), 0);
39+ const comp = ss.filter((s) => s.completeness !== null);
40+ const avg = comp.length ? comp.reduce((a, s) => a + Number(s.completeness), 0) / comp.length : null;
41+ return { sessions: ss.length, bars, avg, maxSymbols: Math.max(0, ...ss.map((s) => s.symbols)) };
42+ }, [heat.data]);
43+
44+ const candidates = (datasets.data?.data ?? []).filter((d) => d.kind === "market_bars" || d.kind === "derived");
45+
46+ return (
47+ <>
48+ <PageHeader title="Coverage" subtitle="Sessions × completeness (bars ÷ expected regular-hours bars, capped at 100 %)" />
49+ <Card className="mb-4">
50+ <div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
51+ <DateRange start={start} end={end} onChange={(s, e) => { setStart(s); setEnd(e); }} />
52+ <Field label="Dataset" className="lg:w-80">
53+ <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>
54+ {!candidates.some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}
55+ {candidates.map((d) => (
56+ <option key={d.id} value={d.id}>
57+ {d.id} {d.status !== "active" ? `(${d.status})` : ""}
58+ </option>
59+ ))}
60+ </select>
61+ </Field>
62+ </div>
63+ </Card>
64+
65+ {heat.error && <ErrorState error={heat.error} retry={heat.reload} />}
66+ <div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-4">
67+ <div className="card"><div className="text-xs text-ink-2">Sessions in range</div><div className="mt-1 text-2xl font-semibold">{fmtInt(totals.sessions)}</div></div>
68+ <div className="card"><div className="text-xs text-ink-2">Bars in range</div><div className="mt-1 text-2xl font-semibold" title={fmtInt(totals.bars)}>{fmtCompact(totals.bars)}</div></div>
69+ <div className="card"><div className="text-xs text-ink-2">Mean completeness</div><div className="mt-1 text-2xl font-semibold">{fmtPct(totals.avg)}</div></div>
70+ <div className="card"><div className="text-xs text-ink-2">Max symbols / session</div><div className="mt-1 text-2xl font-semibold">{fmtInt(totals.maxSymbols)}</div></div>
71+ </div>
72+
73+ <div className="grid gap-4 lg:grid-cols-[1fr_minmax(300px,0.7fr)]">
74+ <div className="flex flex-col gap-4">
75+ <Card title="Completeness per session" className={heat.loading ? "opacity-70" : undefined}>
76+ {heat.loading && !heat.data ? <Skeleton rows={4} /> : heat.data ? <CompletenessHeatmap cells={heat.data.sessions} start={start} end={end} /> : null}
77+ </Card>
78+ <Card title="Bars per session">
79+ {heat.data && barData.length > 0 ? <VolumeBarChart data={barData} valueKey="bars" title="bars" height={140} /> : <div className="text-sm text-ink-3">No sessions in range.</div>}
80+ </Card>
81+ <Card title="Symbols per session">
82+ {heat.data && barData.length > 0 ? <VolumeBarChart data={barData} valueKey="symbols" title="symbols" height={120} color="var(--series-3)" /> : <div className="text-sm text-ink-3">No sessions in range.</div>}
83+ </Card>
84+ </div>
85+ <Card title={`Top symbols by bars (${heat.data?.top_symbols.length ?? 0})`}>
86+ <DataTable
87+ rows={heat.data?.top_symbols}
88+ loading={heat.loading}
89+ rowKey={(r) => r.symbol}
90+ mobile="scroll"
91+ maxHeight="32rem"
92+ onRowClick={(r) => setSymbol(r.symbol)}
93+ columns={[
94+ { key: "symbol", header: "Symbol", render: (r) => <Link href={`/symbols/detail/?symbol=${encodeURIComponent(r.symbol)}`} className="font-mono font-medium text-accent-ink hover:underline" onClick={(e) => e.stopPropagation()}>{r.symbol}</Link> },
95+ { key: "sessions", header: "Sessions", num: true, render: (r) => fmtInt(r.sessions) },
96+ { key: "bars", header: "Bars", num: true, render: (r) => fmtCompact(r.bars) },
97+ { key: "completeness", header: "Compl.", num: true, render: (r) => fmtPct(r.completeness) },
98+ ]}
99+ empty="No coverage rows in this range."
100+ />
101+ </Card>
102+ </div>
103+
104+ <Card title="Per-symbol sessions" className="mt-4" actions={<span className="text-xs text-ink-3">click a top symbol or type one</span>}>
105+ <Field label="Symbol" className="mb-3 max-w-xs">
106+ <input className="input font-mono uppercase" placeholder="AAPL" value={symbol} onChange={(e) => setSymbol(e.target.value)} />
107+ </Field>
108+ {sessions.error && <ErrorState error={sessions.error} retry={sessions.reload} />}
109+ {dsym ? (
110+ <DataTable
111+ rows={sessions.data?.data}
112+ loading={sessions.loading}
113+ rowKey={(r) => r.session_date}
114+ mobile="scroll"
115+ maxHeight="28rem"
116+ columns={[
117+ { key: "session_date", header: "Session", render: (r) => <span className="tnum">{r.session_date}</span> },
118+ { key: "bar_count", header: "Bars", num: true, render: (r) => fmtInt(r.bar_count) },
119+ { key: "expected_bars", header: "Expected", num: true, render: (r) => fmtInt(r.expected_bars) },
120+ { key: "completeness", header: "Compl.", num: true, render: (r) => (r.expected_bars ? fmtPct(Math.min(1, r.bar_count / r.expected_bars)) : "—") },
121+ { key: "first_ts", header: "First (UTC)", render: (r) => <span className="tnum">{fmtTime(r.first_ts)}</span> },
122+ { key: "last_ts", header: "Last (UTC)", render: (r) => <span className="tnum">{fmtTime(r.last_ts)}</span> },
123+ { key: "flags", header: "Flags", render: (r) => (r.flags?.length ? r.flags.map((f) => <span key={f} className="pill mr-1 bg-warn-soft text-warn-ink">{f}</span>) : <span className="text-ink-3">—</span>) },
124+ ]}
125+ empty={`No sessions for ${dsym} in this range.`}
126+ footer={<span>{fmtInt(sessions.data?.data.length ?? 0)} sessions · dataset {sessions.data?.meta.dataset_id}</span>}
127+ />
128+ ) : (
129+ <div className="text-sm text-ink-3">Enter a symbol to list its sessions.</div>
130+ )}
131+ </Card>
132+ </>
133+ );
134+}
added apps/web/src/app/datasets/detail/page.tsx +128 −0
@@ -0,0 +1,128 @@
1+"use client";
2+import Link from "next/link";
3+import { ArrowLeft } from "lucide-react";
4+import { api, type DatasetDetail } from "@/lib/api";
5+import { useAsync, useQueryParam } from "@/lib/hooks";
6+import { fmtBytes, fmtInt, fmtTs } from "@/lib/format";
7+import { Card, Empty, ErrorState, KV, PageHeader, Skeleton, StatusBadge } from "@/components/ui";
8+import { DataTable } from "@/components/DataTable";
9+import { JsonView } from "@/components/JsonView";
10+
11+export default function DatasetDetailPage() {
12+ const id = useQueryParam("id");
13+ const { data, error, loading, reload } = useAsync((s) => api.get<DatasetDetail>(`/catalog/datasets/${encodeURIComponent(id!)}`, undefined, s), [id], { enabled: !!id });
14+ const d = data?.data;
15+
16+ return (
17+ <>
18+ <Link href="/datasets/" className="mb-2 inline-flex items-center gap-1 text-xs text-ink-2 hover:text-ink-1">
19+ <ArrowLeft className="h-3.5 w-3.5" /> Datasets
20+ </Link>
21+ <PageHeader
22+ title={d?.name ?? id ?? "Dataset"}
23+ subtitle={<span className="font-mono">{id}</span>}
24+ actions={
25+ d && (
26+ <>
27+ <StatusBadge status={d.status} />
28+ {d.status === "active" && d.kind === "market_bars" && (
29+ <Link href={`/bars/?interval=${d.interval ?? "1m"}`} className="btn btn-sm">
30+ Explore bars
31+ </Link>
32+ )}
33+ {d.status === "active" && (
34+ <Link href={`/coverage/?dataset_id=${encodeURIComponent(d.id)}`} className="btn btn-sm">
35+ Coverage
36+ </Link>
37+ )}
38+ </>
39+ )
40+ }
41+ />
42+ {!id && <Empty>Missing <code>?id=</code> parameter.</Empty>}
43+ {error && <ErrorState error={error} retry={reload} />}
44+ {loading && !data && <Skeleton rows={8} />}
45+ {d && (
46+ <div className="grid gap-4 lg:grid-cols-2">
47+ <Card title="Description">
48+ <p className="text-sm text-ink-1 whitespace-pre-wrap">{d.description || <span className="text-ink-3">No description.</span>}</p>
49+ {d.notes && <p className="mt-2 text-xs text-ink-2 whitespace-pre-wrap">{d.notes}</p>}
50+ <KV
51+ className="mt-3"
52+ items={[
53+ ["Kind", d.kind],
54+ ["Asset class", d.asset_class ?? "—"],
55+ ["Interval", d.interval ?? "—"],
56+ ["Source", `${d.source_name ?? d.source_id ?? "—"}${d.vendor ? ` · ${d.vendor}` : ""}`],
57+ ]}
58+ />
59+ </Card>
60+ <Card title="Data contract">
61+ <KV
62+ items={[
63+ ["Timestamp convention", d.timestamp_convention ?? "bar_open"],
64+ ["Storage timezone", d.timezone ?? "UTC"],
65+ ["Stored adjustment", d.adjustment ?? "raw"],
66+ ["Current version", d.current_version !== null && d.current_version !== undefined ? `v${d.current_version}` : "—"],
67+ ["License class", d.license_class],
68+ ]}
69+ />
70+ <p className="mt-3 rounded-md border border-warn/50 bg-warn-soft p-2 text-xs text-warn-ink">{d.license_text || "License not yet determined."}</p>
71+ </Card>
72+
73+ <Card title={`Version history (${data.versions.length})`} className="lg:col-span-2">
74+ <DataTable
75+ rows={data.versions}
76+ rowKey={(v) => v.version}
77+ mobile="scroll"
78+ columns={[
79+ { key: "version", header: "Version", render: (v) => <span className="font-medium tnum">v{v.version}</span> },
80+ { key: "snapshot_id", header: "Snapshot", mono: true },
81+ { key: "row_count", header: "Rows", num: true, render: (v) => fmtInt(v.row_count) },
82+ { key: "file_count", header: "Files", num: true, render: (v) => fmtInt(v.file_count) },
83+ { key: "bytes", header: "Bytes", num: true, render: (v) => fmtBytes(v.bytes) },
84+ { key: "created_at", header: "Created", render: (v) => <span className="tnum text-xs">{fmtTs(v.created_at)}</span> },
85+ { key: "note", header: "Note", render: (v) => <span className="text-xs text-ink-2">{v.note ?? "—"}</span> },
86+ ]}
87+ empty="No version published yet."
88+ />
89+ </Card>
90+
91+ <Card title="Live files (current layout)">
92+ <DataTable
93+ rows={data.files}
94+ rowKey={(f) => f.layout}
95+ mobile="scroll"
96+ columns={[
97+ { key: "layout", header: "Layout", render: (f) => <span className="font-medium">{f.layout}</span> },
98+ { key: "n", header: "Files", num: true, render: (f) => fmtInt(f.n) },
99+ { key: "rows", header: "Rows", num: true, render: (f) => fmtInt(f.rows) },
100+ { key: "bytes", header: "Bytes", num: true, render: (f) => fmtBytes(f.bytes) },
101+ { key: "d0", header: "From", render: (f) => f.d0 ?? "—" },
102+ { key: "d1", header: "To", render: (f) => f.d1 ?? "—" },
103+ ]}
104+ empty="No files registered."
105+ />
106+ </Card>
107+ <Card title={`Checkpoints (${data.checkpoints.length})`}>
108+ {data.checkpoints.length === 0 ? (
109+ <Empty>No checkpoints.</Empty>
110+ ) : (
111+ <ul className="flex flex-col gap-2">
112+ {data.checkpoints.map((c) => (
113+ <li key={c.key} className="text-sm">
114+ <div className="flex items-center justify-between gap-2">
115+ <code className="text-xs">{c.key}</code>
116+ <span className="tnum text-xs text-ink-3">{fmtTs(c.updated_at)}</span>
117+ </div>
118+ <JsonView value={c.value} collapsed />
119+ </li>
120+ ))}
121+ </ul>
122+ )}
123+ </Card>
124+ </div>
125+ )}
126+ </>
127+ );
128+}
added apps/web/src/app/datasets/page.tsx +94 −0
@@ -0,0 +1,94 @@
1+"use client";
2+import Link from "next/link";
3+import { useMemo, useState } from "react";
4+import clsx from "clsx";
5+import { api, type Dataset } from "@/lib/api";
6+import { useAsync } from "@/lib/hooks";
7+import { fmtBytes, fmtCompact, fmtInt } from "@/lib/format";
8+import { Card, ErrorState, PageHeader, Skeleton, StatusBadge, Tabs } from "@/components/ui";
9+
10+const ORDER = ["active", "paused", "pending_configuration", "planned", "unsupported"];
11+
12+export default function DatasetsPage() {
13+ const { data, error, loading, reload } = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);
14+ const [filter, setFilter] = useState<"all" | "active" | "other">("all");
15+ const rows = useMemo(() => {
16+ const r = (data?.data ?? []).slice().sort((a, b) => ORDER.indexOf(a.status) - ORDER.indexOf(b.status) || a.id.localeCompare(b.id));
17+ if (filter === "active") return r.filter((d) => d.status === "active");
18+ if (filter === "other") return r.filter((d) => d.status !== "active");
19+ return r;
20+ }, [data, filter]);
21+
22+ return (
23+ <>
24+ <PageHeader
25+ title="Datasets"
26+ subtitle="Catalog of curated datasets, their contract and current version"
27+ actions={
28+ <Tabs
29+ value={filter}
30+ onChange={setFilter}
31+ items={[
32+ { value: "all", label: `All (${data?.data.length ?? 0})` },
33+ { value: "active", label: "Active" },
34+ { value: "other", label: "Planned / other" },
35+ ]}
36+ />
37+ }
38+ />
39+ {error && <ErrorState error={error} retry={reload} />}
40+ {loading && !data && <Skeleton rows={6} />}
41+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
42+ {rows.map((d) => (
43+ <Link key={d.id} href={`/datasets/detail/?id=${encodeURIComponent(d.id)}`} className={clsx("card flex flex-col gap-2 transition hover:border-accent/60", d.status !== "active" && "opacity-90")}>
44+ <div className="flex items-start justify-between gap-2">
45+ <div className="min-w-0">
46+ <div className="truncate font-semibold">{d.name}</div>
47+ <div className="truncate font-mono text-xs text-ink-3">{d.id}</div>
48+ </div>
49+ <StatusBadge status={d.status} />
50+ </div>
51+ {d.description && <p className="line-clamp-2 text-xs text-ink-2">{d.description}</p>}
52+ <div className="flex flex-wrap gap-1 text-[11px]">
53+ <span className="pill bg-surface-2 text-ink-2">{d.kind}</span>
54+ {d.asset_class && <span className="pill bg-surface-2 text-ink-2">{d.asset_class}</span>}
55+ {d.interval && <span className="pill bg-surface-2 text-ink-2">{d.interval}</span>}
56+ {d.adjustment && <span className="pill bg-surface-2 text-ink-2">{d.adjustment}</span>}
57+ <span className={clsx("pill", d.license_class === "public-domain" ? "bg-ok-soft text-ok-ink" : d.license_class === "pending" ? "bg-warn-soft text-warn-ink" : "bg-accent-soft text-accent-ink")} title={d.license_text}>
58+ {d.license_class}
59+ </span>
60+ </div>
61+ {d.status === "active" && (
62+ <dl className="mt-1 grid grid-cols-3 gap-2 text-xs">
63+ <div>
64+ <dt className="text-ink-3">Version</dt>
65+ <dd className="tnum">v{d.current_version ?? "—"}</dd>
66+ </div>
67+ <div>
68+ <dt className="text-ink-3">Rows</dt>
69+ <dd className="tnum" title={fmtInt(d.row_count)}>
70+ {fmtCompact(d.row_count)}
71+ </dd>
72+ </div>
73+ <div>
74+ <dt className="text-ink-3">Size</dt>
75+ <dd className="tnum">{fmtBytes(d.bytes)}</dd>
76+ </div>
77+ <div>
78+ <dt className="text-ink-3">Symbols</dt>
79+ <dd className="tnum">{fmtInt(d.symbols)}</dd>
80+ </div>
81+ <div className="col-span-2">
82+ <dt className="text-ink-3">Sessions</dt>
83+ <dd className="tnum">{d.first_session && d.last_session ? `${d.first_session} → ${d.last_session}` : "—"}</dd>
84+ </div>
85+ </dl>
86+ )}
87+ {d.source_name && <div className="text-[11px] text-ink-3">Source: {d.source_name}{d.vendor ? ` (${d.vendor})` : ""}</div>}
88+ </Link>
89+ ))}
90+ </div>
91+ {data && rows.length === 0 && <Card>No dataset matches this filter.</Card>}
92+ </>
93+ );
94+}
added apps/web/src/app/exports/page.tsx +160 −0
@@ -0,0 +1,160 @@
1+"use client";
2+import { useEffect, useMemo, useState, type FormEvent } from "react";
3+import { Download, RefreshCw } from "lucide-react";
4+import { api, type Dataset, type ExportRow } from "@/lib/api";
5+import { useAsync, useInterval } from "@/lib/hooks";
6+import { useToast } from "@/lib/toast";
7+import { daysAgo, fmtBytes, fmtInt, fmtRelative, fmtTs, splitList, today } 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+export default function ExportsPage() {
13+ const toast = useToast();
14+ const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);
15+ const list = useAsync((s) => api.get<{ data: ExportRow[] }>("/exports", undefined, s), []);
16+ const live = useMemo(() => (list.data?.data ?? []).some((e) => e.status === "queued" || e.status === "running"), [list.data]);
17+ useInterval(list.reload, live ? 3000 : null);
18+
19+ const [datasetId, setDatasetId] = useState("market_bars.stock.1m.raw");
20+ const [symbols, setSymbols] = useState("");
21+ const [start, setStart] = useState(daysAgo(365));
22+ const [end, setEnd] = useState(today());
23+ const [adjustment, setAdjustment] = useState<"raw" | "split" | "split_div">("raw");
24+ const [format, setFormat] = useState<"parquet" | "csv">("parquet");
25+ const [busy, setBusy] = useState(false);
26+
27+ // Prefill from the Bars explorer (range too large)
28+ useEffect(() => {
29+ const p = new URLSearchParams(window.location.search);
30+ if (p.get("symbols")) setSymbols(p.get("symbols")!.replace(/,/g, "\n"));
31+ if (p.get("start")) setStart(p.get("start")!);
32+ if (p.get("end")) setEnd(p.get("end")!);
33+ const a = p.get("adjustment");
34+ if (a === "raw" || a === "split" || a === "split_div") setAdjustment(a);
35+ }, []);
36+
37+ const syms = splitList(symbols);
38+
39+ async function submit(e: FormEvent) {
40+ e.preventDefault();
41+ setBusy(true);
42+ try {
43+ const out = await api.post<{ id: string; status: string; poll: string }>("/exports", { dataset_id: datasetId, symbols: syms, start, end, adjustment, format });
44+ toast.success("Export queued", out.id);
45+ list.reload();
46+ } catch (err) {
47+ toast.error(err);
48+ } finally {
49+ setBusy(false);
50+ }
51+ }
52+
53+ return (
54+ <>
55+ <PageHeader
56+ title="Exports"
57+ subtitle="Asynchronous Parquet/CSV extracts for ranges larger than the live API allows. Dataset version is pinned at submission."
58+ actions={
59+ <button className="btn btn-sm" onClick={list.reload}>
60+ <RefreshCw className="h-3.5 w-3.5" /> Refresh{live ? " (auto 3 s)" : ""}
61+ </button>
62+ }
63+ />
64+ <div className="grid gap-4 lg:grid-cols-[360px_minmax(0,1fr)]">
65+ <Card title="New export">
66+ <form onSubmit={submit} className="flex flex-col gap-3">
67+ <Field label="Dataset">
68+ <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>
69+ {!(datasets.data?.data ?? []).some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}
70+ {(datasets.data?.data ?? []).filter((d) => d.status === "active").map((d) => (
71+ <option key={d.id} value={d.id}>
72+ {d.id}
73+ </option>
74+ ))}
75+ </select>
76+ </Field>
77+ <Field label={`Symbols (${syms.length}/500)`} hint="comma, space or newline separated">
78+ <textarea className="input min-h-24 font-mono uppercase" value={symbols} onChange={(e) => setSymbols(e.target.value)} placeholder={"AAPL\nMSFT\nSPY"} required />
79+ </Field>
80+ <div className="grid grid-cols-2 gap-2">
81+ <Field label="Start">
82+ <input type="date" className="input" value={start} max={end} onChange={(e) => setStart(e.target.value)} required />
83+ </Field>
84+ <Field label="End">
85+ <input type="date" className="input" value={end} min={start} onChange={(e) => setEnd(e.target.value)} required />
86+ </Field>
87+ <Field label="Adjustment">
88+ <select className="input" value={adjustment} onChange={(e) => setAdjustment(e.target.value as typeof adjustment)}>
89+ <option value="raw">raw</option>
90+ <option value="split">split</option>
91+ <option value="split_div">split + dividend</option>
92+ </select>
93+ </Field>
94+ <Field label="Format">
95+ <select className="input" value={format} onChange={(e) => setFormat(e.target.value as typeof format)}>
96+ <option value="parquet">Parquet (zstd)</option>
97+ <option value="csv">CSV</option>
98+ </select>
99+ </Field>
100+ </div>
101+ <button className="btn btn-primary" disabled={busy || syms.length === 0 || syms.length > 500}>
102+ {busy ? "Submitting…" : "Queue export"}
103+ </button>
104+ <p className="text-[11px] text-ink-3">Limits: 500 symbols, 12 years, 3 concurrent exports per principal. Files expire after ~72 h. Private use only — do not redistribute.</p>
105+ </form>
106+ </Card>
107+
108+ <Card title={`Exports (${list.data?.data.length ?? 0})`}>
109+ {list.error && <ErrorState error={list.error} retry={list.reload} />}
110+ <DataTable
111+ rows={list.data?.data}
112+ loading={list.loading}
113+ rowKey={(e) => e.id}
114+ mobile="cards"
115+ columns={[
116+ { key: "created_at", header: "Created", render: (e) => <span className="tnum text-xs" title={fmtTs(e.created_at)}>{fmtRelative(e.created_at)}</span> },
117+ { key: "status", header: "Status", render: (e) => <StatusBadge status={isExpired(e) ? "expired" : e.status} label={isExpired(e) ? "expired" : e.status} /> },
118+ {
119+ key: "request",
120+ header: "Request",
121+ render: (e) => (
122+ <div className="text-xs">
123+ <div className="font-mono">{e.request.dataset_id}{e.request.version !== undefined ? ` v${e.request.version}` : ""}</div>
124+ <div className="text-ink-2">
125+ {e.request.symbols.length} symbol{e.request.symbols.length === 1 ? "" : "s"} · {e.request.start} → {e.request.end} · {e.request.adjustment} · {e.format}
126+ </div>
127+ <div className="line-clamp-1 font-mono text-[11px] text-ink-3">{e.request.symbols.join(", ")}</div>
128+ </div>
129+ ),
130+ },
131+ { key: "row_count", header: "Rows", num: true, hideSm: true, render: (e) => fmtInt(e.row_count) },
132+ { key: "bytes", header: "Size", num: true, render: (e) => fmtBytes(e.bytes) },
133+ { key: "expires_at", header: "Expires", hideSm: true, render: (e) => <span className="tnum text-xs">{e.expires_at ? fmtRelative(e.expires_at) : "—"}</span> },
134+ {
135+ key: "download",
136+ header: "",
137+ render: (e) =>
138+ e.status === "ready" && !isExpired(e) ? (
139+ <a className="btn btn-sm btn-primary" href={`/api/v1/exports/${e.id}/download`} target="_blank" rel="noopener">
140+ <Download className="h-3.5 w-3.5" /> Download
141+ </a>
142+ ) : e.status === "failed" && e.error ? (
143+ <JsonView value={e.error} collapsed />
144+ ) : null,
145+ },
146+ ]}
147+ empty="No exports yet."
148+ />
149+ </Card>
150+ </div>
151+ <Banner tone="warn" className="mt-4">
152+ Exported files contain FirstRate Data bars licensed for <strong>private use by the subscriber only</strong>. Do not share, publish or re-host them.
153+ </Banner>
154+ </>
155+ );
156+}
157+
158+function isExpired(e: ExportRow): boolean {
159+ return e.status === "expired" || (!!e.expires_at && new Date(e.expires_at).getTime() < Date.now());
160+}
added apps/web/src/app/globals.css +153 −0
@@ -0,0 +1,153 @@
1+@import "tailwindcss";
2+
3+/* ------------------------------------------------------------------ design tokens
4+ Brand-neutral palette (validated with the dataviz skill in light & dark).
5+ Everything is referenced by role; the dark values swap in one place. */
6+:root {
7+ color-scheme: light;
8+ --surface-0: #f4f4f1; /* page plane */
9+ --surface-1: #fcfcfb; /* cards, chart surface */
10+ --surface-2: #f0efec; /* table header, inset */
11+ --surface-3: #e6e5df; /* hover wash */
12+ --ink-1: #0b0b0b;
13+ --ink-2: #52514e;
14+ --ink-3: #898781;
15+ --hairline: #e1e0d9;
16+ --axis: #c3c2b7;
17+ --border: rgba(11, 11, 11, 0.10);
18+ --accent: #2a78d6;
19+ --accent-ink: #1c5cab;
20+ --accent-soft: #cde2fb;
21+ --series-1: #2a78d6;
22+ --series-2: #eb6834;
23+ --series-3: #1baf7a;
24+ --seq-100: #cde2fb; --seq-200: #9ec5f4; --seq-300: #6da7ec; --seq-400: #3987e5;
25+ --seq-500: #256abf; --seq-600: #184f95; --seq-700: #0d366b;
26+ --ok: #0ca30c; --ok-ink: #006300; --ok-soft: #dcf3dc;
27+ --warn: #fab219; --warn-ink: #7a4f00; --warn-soft: #fdf0cc;
28+ --serious: #ec835a; --serious-ink: #8a3a1a; --serious-soft: #fde3d8;
29+ --crit: #d03b3b; --crit-ink: #a02020; --crit-soft: #f8dada;
30+ --focus: #2a78d6;
31+}
32+@media (prefers-color-scheme: dark) {
33+ :root {
34+ color-scheme: dark;
35+ --surface-0: #0d0d0d;
36+ --surface-1: #1a1a19;
37+ --surface-2: #222220;
38+ --surface-3: #2c2c2a;
39+ --ink-1: #f0efec;
40+ --ink-2: #c3c2b7;
41+ --ink-3: #898781;
42+ --hairline: #2c2c2a;
43+ --axis: #383835;
44+ --border: rgba(255, 255, 255, 0.10);
45+ --accent: #3987e5;
46+ --accent-ink: #86b6ef;
47+ --accent-soft: #184f95;
48+ --series-1: #3987e5;
49+ --series-2: #d95926;
50+ --series-3: #199e70;
51+ --seq-100: #0d366b; --seq-200: #184f95; --seq-300: #256abf; --seq-400: #2a78d6;
52+ --seq-500: #3987e5; --seq-600: #6da7ec; --seq-700: #9ec5f4;
53+ --ok: #0ca30c; --ok-ink: #5fd15f; --ok-soft: #10321a;
54+ --warn: #fab219; --warn-ink: #fab219; --warn-soft: #3a2b05;
55+ --serious: #ec835a; --serious-ink: #f3a483; --serious-soft: #3d1f12;
56+ --crit: #d03b3b; --crit-ink: #ef7a7a; --crit-soft: #3d1414;
57+ --focus: #6da7ec;
58+ }
59+}
60+
61+@theme inline {
62+ --color-surface-0: var(--surface-0);
63+ --color-surface-1: var(--surface-1);
64+ --color-surface-2: var(--surface-2);
65+ --color-surface-3: var(--surface-3);
66+ --color-ink-1: var(--ink-1);
67+ --color-ink-2: var(--ink-2);
68+ --color-ink-3: var(--ink-3);
69+ --color-hairline: var(--hairline);
70+ --color-axis: var(--axis);
71+ --color-border: var(--border);
72+ --color-accent: var(--accent);
73+ --color-accent-ink: var(--accent-ink);
74+ --color-accent-soft: var(--accent-soft);
75+ --color-ok: var(--ok);
76+ --color-ok-ink: var(--ok-ink);
77+ --color-ok-soft: var(--ok-soft);
78+ --color-warn: var(--warn);
79+ --color-warn-ink: var(--warn-ink);
80+ --color-warn-soft: var(--warn-soft);
81+ --color-serious: var(--serious);
82+ --color-serious-ink: var(--serious-ink);
83+ --color-serious-soft: var(--serious-soft);
84+ --color-crit: var(--crit);
85+ --color-crit-ink: var(--crit-ink);
86+ --color-crit-soft: var(--crit-soft);
87+ --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
88+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
89+}
90+
91+/* ------------------------------------------------------------------ base */
92+html, body { height: 100%; }
93+body {
94+ background: var(--surface-0);
95+ color: var(--ink-1);
96+ font-family: var(--font-sans);
97+ font-size: 14px;
98+ line-height: 1.45;
99+ -webkit-font-smoothing: antialiased;
100+ text-rendering: optimizeLegibility;
101+}
102+* { border-color: var(--border); }
103+:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
104+::selection { background: var(--accent-soft); }
105+a { color: inherit; }
106+code, pre, kbd, .mono { font-family: var(--font-mono); }
107+.tnum { font-variant-numeric: tabular-nums; }
108+
109+/* ------------------------------------------------------------------ components */
110+@layer components {
111+ .card { @apply rounded-lg border border-border bg-surface-1 p-4; }
112+ .card-title { @apply mb-3 flex items-center justify-between gap-2 text-sm font-semibold text-ink-1; }
113+ .muted { @apply text-ink-3; }
114+ .label { @apply mb-1 block text-xs font-medium text-ink-2; }
115+ .input {
116+ @apply w-full rounded-md border border-border bg-surface-1 px-2.5 py-1.5 text-sm text-ink-1 placeholder:text-ink-3;
117+ @apply focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:opacity-60;
118+ }
119+ select.input { @apply pr-7; }
120+ .btn {
121+ @apply inline-flex items-center justify-center gap-1.5 rounded-md border border-border bg-surface-1 px-3 py-1.5 text-sm font-medium text-ink-1;
122+ @apply hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-50 active:translate-y-px;
123+ }
124+ .btn-primary { @apply border-transparent bg-accent text-white hover:bg-accent hover:brightness-110; }
125+ .btn-danger { @apply border-transparent bg-crit text-white hover:brightness-110; }
126+ .btn-sm { @apply px-2 py-1 text-xs; }
127+ .btn-ghost { @apply border-transparent bg-transparent; }
128+ .pill { @apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap; }
129+ .table-wrap { @apply -mx-4 overflow-x-auto px-4 sm:mx-0 sm:px-0; }
130+ .table { @apply w-full border-collapse text-sm; }
131+ .table th { @apply sticky top-0 z-[1] border-b border-hairline bg-surface-2 px-2.5 py-2 text-left text-xs font-semibold text-ink-2 whitespace-nowrap; }
132+ .table td { @apply border-b border-hairline px-2.5 py-1.5 align-top text-ink-1; }
133+ .table tbody tr:hover td { background: var(--surface-2); }
134+ .table .num { @apply text-right tnum; }
135+ .kbd { @apply rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-ink-2; }
136+ .banner { @apply rounded-md border px-3 py-2 text-sm; }
137+ .nav-link { @apply flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm text-ink-2 hover:bg-surface-3 hover:text-ink-1; }
138+ .nav-link[aria-current="page"] { @apply bg-accent-soft text-accent-ink font-medium; }
139+ .section-title { @apply text-lg font-semibold tracking-tight text-ink-1; }
140+ .skeleton { @apply animate-pulse rounded bg-surface-3; }
141+}
142+
143+/* recharts chrome */
144+.recharts-cartesian-grid line { stroke: var(--hairline); }
145+.recharts-cartesian-axis line, .recharts-cartesian-axis-tick-line { stroke: var(--axis); }
146+.recharts-text { fill: var(--ink-3); font-size: 11px; }
147+.recharts-tooltip-cursor { stroke: var(--axis); }
148+
149+/* dialog backdrop */
150+dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
151+
152+/* hide number spinners for dense forms */
153+input[type="number"]::-webkit-inner-spin-button { opacity: 0.4; }
added apps/web/src/app/ingestion/page.tsx +350 −0
@@ -0,0 +1,350 @@
1+"use client";
2+import { useMemo, useState, type FormEvent } from "react";
3+import { Plus, RefreshCw, RotateCcw, XCircle } from "lucide-react";
4+import { api, type Dataset, type HealthReport, type Job } from "@/lib/api";
5+import { useAsync, useInterval } from "@/lib/hooks";
6+import { useToast } from "@/lib/toast";
7+import { useAuth } from "@/lib/auth";
8+import { daysAgo, fmtDuration, fmtInt, fmtTs, splitList, today } from "@/lib/format";
9+import { Banner, Card, Empty, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";
10+import { DataTable } from "@/components/DataTable";
11+import { JsonView } from "@/components/JsonView";
12+import { ConfirmDialog, Modal } from "@/components/ConfirmDialog";
13+
14+const KINDS = ["incremental", "metadata", "backfill", "fundamentals", "derive", "compaction", "quality", "backup", "gc"] as const;
15+type Kind = (typeof KINDS)[number];
16+const STATUSES = ["", "queued", "running", "succeeded", "failed", "dead", "cancelled"];
17+
18+const KIND_HELP: Record<Kind, string> = {
19+ incremental: "Fetch the vendor's latest day or week of bars and append (idempotent).",
20+ metadata: "Refresh the instrument master, corporate actions and vendor checkpoints.",
21+ backfill: "Full-history download for a ticker range (first letter). Heavy on disk and bandwidth.",
22+ fundamentals: "Ingest point-in-time fundamentals for the given tickers (or the tracked list).",
23+ derive: "Rebuild derived datasets (5m / 1d) from 1-minute bars.",
24+ compaction: "Merge delta files into base files and publish a new dataset version.",
25+ quality: "Run the quality checks and record anomalies.",
26+ backup: "pg_dump + rsync of the lake metadata to the backup target.",
27+ gc: "Garbage-collect retired files and expired exports.",
28+};
29+
30+export default function IngestionPage() {
31+ const { isOwner } = useAuth();
32+ const toast = useToast();
33+ const [status, setStatus] = useState("");
34+ const [kind, setKind] = useState("");
35+ const jobs = useAsync((s) => api.get<{ data: Job[] }>("/admin/jobs", { status, kind, limit: 300 }, s), [status, kind], { enabled: isOwner });
36+ const dead = useAsync((s) => api.get<{ data: Job[] }>("/admin/jobs", { status: "dead", limit: 100 }, s), [], { enabled: isOwner });
37+ const failed = useAsync((s) => api.get<{ data: Job[] }>("/admin/jobs", { status: "failed", limit: 100 }, s), [], { enabled: isOwner });
38+ const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);
39+ const health = useAsync((s) => api.get<HealthReport>("/health", undefined, s), []);
40+ const [inspect, setInspect] = useState<Job | null>(null);
41+ const [showForm, setShowForm] = useState(false);
42+
43+ const reloadAll = () => {
44+ jobs.reload();
45+ dead.reload();
46+ failed.reload();
47+ };
48+ const hasLive = (jobs.data?.data ?? []).some((j) => j.status === "queued" || j.status === "running");
49+ useInterval(reloadAll, hasLive ? 5000 : 30000);
50+
51+ async function act(j: Job, action: "retry" | "cancel") {
52+ try {
53+ await api.post(`/admin/jobs/${j.id}/${action}`);
54+ toast.success(`Job ${action === "retry" ? "re-queued" : "cancelled"}`, j.id);
55+ reloadAll();
56+ } catch (e) {
57+ toast.error(e);
58+ }
59+ }
60+
61+ const counts = useMemo(() => {
62+ const m: Record<string, number> = {};
63+ for (const j of jobs.data?.data ?? []) m[j.status] = (m[j.status] ?? 0) + 1;
64+ return m;
65+ }, [jobs.data]);
66+
67+ if (!isOwner) return <Banner tone="warn">Ingestion controls require the owner role.</Banner>;
68+
69+ const columns = [
70+ { key: "kind", header: "Kind", render: (j: Job) => <span className="font-medium">{j.kind}</span> },
71+ { key: "dataset_id", header: "Dataset", mono: true, hideSm: true, render: (j: Job) => <span className="font-mono text-xs">{j.dataset_id ?? "—"}</span> },
72+ { key: "status", header: "Status", render: (j: Job) => <StatusBadge status={j.status} /> },
73+ { key: "attempts", header: "Att.", num: true, hideSm: true, render: (j: Job) => `${j.attempts ?? 0}/${j.max_attempts ?? "—"}` },
74+ { key: "created_at", header: "Created", hideSm: true, render: (j: Job) => <span className="tnum text-xs">{fmtTs(j.created_at)}</span> },
75+ { key: "duration", header: "Duration", render: (j: Job) => <span className="tnum text-xs">{j.started_at ? fmtDuration(j.started_at, j.finished_at) : "—"}</span> },
76+ { key: "progress", header: "Progress", render: (j: Job) => (j.progress ? <JsonView value={j.progress} collapsed /> : <span className="text-ink-3">—</span>) },
77+ { key: "last_error", header: "Error", render: (j: Job) => (j.last_error ? <span className="line-clamp-2 text-xs text-crit-ink">{j.last_error}</span> : <span className="text-ink-3">—</span>) },
78+ {
79+ key: "actions",
80+ header: "",
81+ render: (j: Job) => (
82+ <div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
83+ {(j.status === "failed" || j.status === "dead" || j.status === "cancelled") && (
84+ <button className="btn btn-sm" onClick={() => void act(j, "retry")} title="Retry">
85+ <RotateCcw className="h-3.5 w-3.5" /> Retry
86+ </button>
87+ )}
88+ {j.status === "queued" && (
89+ <button className="btn btn-sm" onClick={() => void act(j, "cancel")} title="Cancel">
90+ <XCircle className="h-3.5 w-3.5" /> Cancel
91+ </button>
92+ )}
93+ </div>
94+ ),
95+ },
96+ ];
97+
98+ return (
99+ <>
100+ <PageHeader
101+ title="Ingestion"
102+ subtitle={<>Job queue · {Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(" · ") || "no jobs"} · refresh every {hasLive ? 5 : 30} s</>}
103+ actions={
104+ <>
105+ <button className="btn btn-sm" onClick={reloadAll}>
106+ <RefreshCw className="h-3.5 w-3.5" /> Refresh
107+ </button>
108+ <button className="btn btn-sm btn-primary" onClick={() => setShowForm(true)}>
109+ <Plus className="h-3.5 w-3.5" /> Enqueue job
110+ </button>
111+ </>
112+ }
113+ />
114+
115+ {health.data?.components.ingest_worker && health.data.components.ingest_worker !== "running" && (
116+ <Banner tone="crit" className="mb-4">
117+ The ingest worker is <strong>{health.data.components.ingest_worker}</strong> — queued jobs will not start until it holds the lake lock.
118+ </Banner>
119+ )}
120+
121+ {(dead.data?.data.length ?? 0) + (failed.data?.data.length ?? 0) > 0 && (
122+ <Card title={`Dead-letter & failed (${(dead.data?.data.length ?? 0) + (failed.data?.data.length ?? 0)})`} className="mb-4 border-crit/40">
123+ <DataTable rows={[...(dead.data?.data ?? []), ...(failed.data?.data ?? [])]} rowKey={(j) => j.id} mobile="scroll" columns={columns} onRowClick={setInspect} />
124+ </Card>
125+ )}
126+
127+ <Card
128+ title="Jobs"
129+ actions={
130+ <div className="flex gap-2">
131+ <select className="input w-auto py-1 text-xs" value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Status filter">
132+ {STATUSES.map((s) => (
133+ <option key={s} value={s}>
134+ {s || "any status"}
135+ </option>
136+ ))}
137+ </select>
138+ <select className="input w-auto py-1 text-xs" value={kind} onChange={(e) => setKind(e.target.value)} aria-label="Kind filter">
139+ <option value="">any kind</option>
140+ {KINDS.map((k) => (
141+ <option key={k} value={k}>
142+ {k}
143+ </option>
144+ ))}
145+ </select>
146+ </div>
147+ }
148+ >
149+ {jobs.error && <ErrorState error={jobs.error} retry={jobs.reload} />}
150+ <DataTable rows={jobs.data?.data} loading={jobs.loading} rowKey={(j) => j.id} mobile="scroll" columns={columns} onRowClick={setInspect} empty="No jobs match." footer={<span>{fmtInt(jobs.data?.data.length ?? 0)} jobs (max 300)</span>} />
151+ </Card>
152+
153+ <Modal open={!!inspect} title={inspect ? `${inspect.kind} · ${inspect.id}` : ""} onClose={() => setInspect(null)} wide>
154+ {inspect && (
155+ <div className="grid gap-3 md:grid-cols-2">
156+ <div className="md:col-span-2 flex flex-wrap items-center gap-2 text-sm">
157+ <StatusBadge status={inspect.status} />
158+ <span className="font-mono text-xs">{inspect.dataset_id}</span>
159+ <span className="text-xs text-ink-3">priority {inspect.priority} · attempts {inspect.attempts}/{inspect.max_attempts} · by {inspect.created_by ?? "—"}</span>
160+ </div>
161+ <div className="text-xs">
162+ <div className="text-ink-3">created</div>
163+ {fmtTs(inspect.created_at)}
164+ <div className="mt-1 text-ink-3">started</div>
165+ {fmtTs(inspect.started_at)}
166+ <div className="mt-1 text-ink-3">finished</div>
167+ {fmtTs(inspect.finished_at)}
168+ <div className="mt-1 text-ink-3">next run</div>
169+ {fmtTs(inspect.next_run_at)}
170+ </div>
171+ <div>
172+ <div className="mb-1 text-xs text-ink-3">params</div>
173+ <JsonView value={inspect.params ?? {}} />
174+ </div>
175+ <div>
176+ <div className="mb-1 text-xs text-ink-3">progress</div>
177+ <JsonView value={inspect.progress ?? {}} />
178+ </div>
179+ <div>
180+ <div className="mb-1 text-xs text-ink-3">result</div>
181+ <JsonView value={inspect.result ?? {}} />
182+ </div>
183+ {inspect.last_error && (
184+ <div className="md:col-span-2">
185+ <div className="mb-1 text-xs text-ink-3">last_error</div>
186+ <pre className="overflow-auto rounded-md border border-crit/40 bg-crit-soft p-2 text-xs text-crit-ink whitespace-pre-wrap">{inspect.last_error}</pre>
187+ </div>
188+ )}
189+ </div>
190+ )}
191+ </Modal>
192+
193+ <Modal open={showForm} title="Enqueue a job" onClose={() => setShowForm(false)}>
194+ <EnqueueForm
195+ datasets={datasets.data?.data ?? []}
196+ lake={health.data?.components.lake}
197+ onDone={() => {
198+ setShowForm(false);
199+ reloadAll();
200+ }}
201+ />
202+ </Modal>
203+ </>
204+ );
205+}
206+
207+function EnqueueForm({ datasets, lake, onDone }: { datasets: Dataset[]; lake?: HealthReport["components"]["lake"]; onDone: () => void }) {
208+ const toast = useToast();
209+ const [kind, setKind] = useState<Kind>("incremental");
210+ const [datasetId, setDatasetId] = useState("market_bars.stock.1m.raw");
211+ const [period, setPeriod] = useState<"day" | "week">("day");
212+ const [letter, setLetter] = useState("A");
213+ const [tickers, setTickers] = useState("");
214+ const [start, setStart] = useState(daysAgo(30));
215+ const [end, setEnd] = useState(today());
216+ const [priority, setPriority] = useState(50);
217+ const [extra, setExtra] = useState("{}");
218+ const [confirm, setConfirm] = useState(false);
219+ const [busy, setBusy] = useState(false);
220+
221+ const needsDataset = ["incremental", "backfill", "derive", "compaction", "quality"].includes(kind);
222+
223+ function buildParams(): Record<string, unknown> {
224+ let base: Record<string, unknown> = {};
225+ try {
226+ base = extra.trim() ? (JSON.parse(extra) as Record<string, unknown>) : {};
227+ } catch {
228+ throw new Error("Extra params must be valid JSON");
229+ }
230+ if (kind === "incremental") base = { period, ...base };
231+ if (kind === "backfill") base = { ticker_range: letter.toUpperCase(), ...base };
232+ if (kind === "fundamentals") {
233+ const t = splitList(tickers);
234+ if (t.length) base = { tickers: t, ...base };
235+ }
236+ if (kind === "quality" || kind === "derive") base = { start, end, ...base };
237+ return base;
238+ }
239+
240+ async function submit(e?: FormEvent) {
241+ e?.preventDefault();
242+ if (kind === "backfill" && !confirm) {
243+ setConfirm(true);
244+ return;
245+ }
246+ setBusy(true);
247+ try {
248+ const params = buildParams();
249+ const out = await api.post<{ id: string }>("/admin/jobs", { kind, dataset_id: needsDataset || kind === "fundamentals" ? datasetId || null : null, params, priority });
250+ toast.success("Job enqueued", `${kind} · ${out.id}`);
251+ onDone();
252+ } catch (err) {
253+ toast.error(err);
254+ } finally {
255+ setBusy(false);
256+ setConfirm(false);
257+ }
258+ }
259+
260+ return (
261+ <form onSubmit={submit} className="flex flex-col gap-3">
262+ <Field label="Kind">
263+ <select className="input" value={kind} onChange={(e) => setKind(e.target.value as Kind)}>
264+ {KINDS.map((k) => (
265+ <option key={k} value={k}>
266+ {k}
267+ </option>
268+ ))}
269+ </select>
270+ </Field>
271+ <p className="text-xs text-ink-2">{KIND_HELP[kind]}</p>
272+ {needsDataset && (
273+ <Field label="Dataset">
274+ <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>
275+ {!datasets.some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}
276+ {datasets.map((d) => (
277+ <option key={d.id} value={d.id}>
278+ {d.id}
279+ </option>
280+ ))}
281+ </select>
282+ </Field>
283+ )}
284+ {kind === "incremental" && (
285+ <Field label="Period">
286+ <select className="input" value={period} onChange={(e) => setPeriod(e.target.value as "day" | "week")}>
287+ <option value="day">latest day</option>
288+ <option value="week">latest week</option>
289+ </select>
290+ </Field>
291+ )}
292+ {kind === "backfill" && (
293+ <Field label="Ticker range (first letter)" hint="One letter A–Z or 0 (numeric tickers). One backfill per letter.">
294+ <select className="input" value={letter} onChange={(e) => setLetter(e.target.value)}>
295+ {"ABCDEFGHIJKLMNOPQRSTUVWXYZ0".split("").map((l) => (
296+ <option key={l} value={l}>
297+ {l}
298+ </option>
299+ ))}
300+ </select>
301+ </Field>
302+ )}
303+ {kind === "fundamentals" && (
304+ <Field label="Tickers" hint="Comma / newline separated. Leave empty to use the tracked list from Settings.">
305+ <textarea className="input min-h-20 font-mono uppercase" value={tickers} onChange={(e) => setTickers(e.target.value)} placeholder="AAPL, MSFT" />
306+ </Field>
307+ )}
308+ {(kind === "quality" || kind === "derive") && (
309+ <div className="grid grid-cols-2 gap-2">
310+ <Field label="Start">
311+ <input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
312+ </Field>
313+ <Field label="End">
314+ <input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
315+ </Field>
316+ </div>
317+ )}
318+ <div className="grid grid-cols-2 gap-2">
319+ <Field label="Priority" hint="lower runs first (default 50)">
320+ <input type="number" className="input" min={1} max={100} value={priority} onChange={(e) => setPriority(Number(e.target.value) || 50)} />
321+ </Field>
322+ <Field label="Extra params (JSON)">
323+ <input className="input font-mono text-xs" value={extra} onChange={(e) => setExtra(e.target.value)} />
324+ </Field>
325+ </div>
326+ <div className="flex justify-end gap-2 pt-1">
327+ <button className="btn btn-primary" disabled={busy}>
328+ {busy ? "Enqueuing…" : kind === "backfill" ? "Review & enqueue" : "Enqueue"}
329+ </button>
330+ </div>
331+ <ConfirmDialog
332+ open={confirm}
333+ title={`Start backfill for tickers “${letter.toUpperCase()}…”?`}
334+ confirmLabel="Start backfill"
335+ danger
336+ busy={busy}
337+ onCancel={() => setConfirm(false)}
338+ onConfirm={() => void submit()}
339+ >
340+ <p>A backfill downloads the full 1-minute history for every ticker starting with <strong>{letter.toUpperCase()}</strong>. This can take hours and add tens of GB.</p>
341+ {lake && (
342+ <p className="mt-2 rounded-md border border-warn/50 bg-warn-soft p-2 text-xs text-warn-ink">
343+ Disk budget: <strong>{lake.free_gb} GB free</strong> · curated {lake.curated_gb} GB of a {lake.soft_limit_gb} GB soft limit · hard minimum {lake.hard_min_free_gb} GB free. The API refuses backfills under {(lake.hard_min_free_gb * 1.5).toFixed(0)} GB free.
344+ </p>
345+ )}
346+ </ConfirmDialog>
347+ </form>
348+ );
349+}
350+
added apps/web/src/app/keys/page.tsx +209 −0
@@ -0,0 +1,209 @@
1+"use client";
2+import { useMemo, useState, type FormEvent } from "react";
3+import { KeyRound, Plus, Trash2 } from "lucide-react";
4+import { api, type ApiKey, type UsageRow } from "@/lib/api";
5+import { useAsync } from "@/lib/hooks";
6+import { useAuth } from "@/lib/auth";
7+import { useToast } from "@/lib/toast";
8+import { fmtBytes, fmtCompact, fmtInt, fmtRelative, fmtTs } from "@/lib/format";
9+import { Banner, Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";
10+import { DataTable } from "@/components/DataTable";
11+import { ConfirmDialog, Modal } from "@/components/ConfirmDialog";
12+import { CopyButton } from "@/components/CopyButton";
13+
14+const SCOPES: { id: string; help: string }[] = [
15+ { id: "read:market", help: "bars, sessions, coverage, catalog, instruments" },
16+ { id: "read:fundamentals", help: "point-in-time fundamentals" },
17+ { id: "query", help: "SQL playground (DuckDB, read-only)" },
18+ { id: "export", help: "async Parquet/CSV exports" },
19+ { id: "admin", help: "jobs, keys, storage, settings — owner only" },
20+];
21+
22+export default function KeysPage() {
23+ const { isOwner } = useAuth();
24+ const toast = useToast();
25+ const { data, error, loading, reload } = useAsync((s) => api.get<{ data: ApiKey[]; usage: UsageRow[] }>("/admin/keys", undefined, s), [], { enabled: isOwner });
26+ const [create, setCreate] = useState(false);
27+ const [revoke, setRevoke] = useState<ApiKey | null>(null);
28+ const [busy, setBusy] = useState(false);
29+ const [token, setToken] = useState<{ token: string; name: string } | null>(null);
30+
31+ const usageByPrincipal = useMemo(() => {
32+ const m = new Map<string, { requests: number; rows: number; bytes: number; last: string }>();
33+ for (const u of data?.usage ?? []) {
34+ const e = m.get(u.principal) ?? { requests: 0, rows: 0, bytes: 0, last: u.day };
35+ e.requests += Number(u.requests);
36+ e.rows += Number(u.rows_returned);
37+ e.bytes += Number(u.bytes_exported);
38+ if (u.day > e.last) e.last = u.day;
39+ m.set(u.principal, e);
40+ }
41+ return m;
42+ }, [data]);
43+
44+ if (!isOwner) return <Banner tone="warn">API keys are managed by the owner.</Banner>;
45+
46+ async function doRevoke() {
47+ if (!revoke) return;
48+ setBusy(true);
49+ try {
50+ await api.del(`/admin/keys/${revoke.id}`);
51+ toast.success("Key revoked", revoke.name);
52+ setRevoke(null);
53+ reload();
54+ } catch (e) {
55+ toast.error(e);
56+ } finally {
57+ setBusy(false);
58+ }
59+ }
60+
61+ return (
62+ <>
63+ <PageHeader
64+ title="API keys"
65+ subtitle="Bearer tokens for the SDK and scripts. Usage is aggregated per day (last 14 days)."
66+ actions={
67+ <button className="btn btn-sm btn-primary" onClick={() => setCreate(true)}>
68+ <Plus className="h-3.5 w-3.5" /> New key
69+ </button>
70+ }
71+ />
72+ {error && <ErrorState error={error} retry={reload} />}
73+ <Card>
74+ <DataTable
75+ rows={data?.data}
76+ loading={loading}
77+ rowKey={(k) => k.id}
78+ mobile="cards"
79+ columns={[
80+ { key: "name", header: "Name", render: (k) => (<div><div className="font-medium">{k.name}</div><div className="font-mono text-[11px] text-ink-3">{k.prefix}…</div></div>) },
81+ { key: "scopes", header: "Scopes", render: (k) => <div className="flex flex-wrap gap-1">{k.scopes.map((s) => <span key={s} className="pill bg-surface-2 text-ink-2">{s}</span>)}</div> },
82+ { key: "rate_limit_per_minute", header: "Rate/min", num: true, hideSm: true, render: (k) => fmtInt(k.rate_limit_per_minute) },
83+ { key: "last_used_at", header: "Last used", render: (k) => <span className="tnum text-xs" title={fmtTs(k.last_used_at)}>{k.last_used_at ? fmtRelative(k.last_used_at) : "never"}</span> },
84+ {
85+ key: "usage",
86+ header: "Usage (14 d)",
87+ render: (k) => {
88+ const u = usageByPrincipal.get(`key:${k.prefix}`) ?? usageByPrincipal.get(k.prefix) ?? usageByPrincipal.get(k.name);
89+ return u ? <span className="tnum text-xs">{fmtInt(u.requests)} req · {fmtCompact(u.rows)} rows · {fmtBytes(u.bytes)}</span> : <span className="text-ink-3">—</span>;
90+ },
91+ },
92+ { key: "expires_at", header: "Expires", hideSm: true, render: (k) => <span className="tnum text-xs">{k.expires_at ? fmtTs(k.expires_at) : "never"}</span> },
93+ { key: "status", header: "Status", render: (k) => <StatusBadge status={k.revoked_at ? "error" : k.expires_at && new Date(k.expires_at) < new Date() ? "expired" : "active"} label={k.revoked_at ? "revoked" : k.expires_at && new Date(k.expires_at) < new Date() ? "expired" : "active"} /> },
94+ { key: "actions", header: "", render: (k) => (!k.revoked_at ? (<button className="btn btn-sm" onClick={() => setRevoke(k)}><Trash2 className="h-3.5 w-3.5" /> Revoke</button>) : null) },
95+ ]}
96+ empty="No API keys yet."
97+ />
98+ </Card>
99+
100+ <Card title="Daily usage (all principals, 14 days)" className="mt-4">
101+ <DataTable
102+ rows={data?.usage}
103+ loading={loading}
104+ rowKey={(u) => `${u.principal}-${u.day}`}
105+ mobile="scroll"
106+ maxHeight="20rem"
107+ columns={[
108+ { key: "day", header: "Day", render: (u) => <span className="tnum">{u.day}</span> },
109+ { key: "principal", header: "Principal", mono: true },
110+ { key: "requests", header: "Requests", num: true, render: (u) => fmtInt(u.requests) },
111+ { key: "rows_returned", header: "Rows", num: true, render: (u) => fmtInt(u.rows_returned) },
112+ { key: "bytes_exported", header: "Exported", num: true, render: (u) => fmtBytes(u.bytes_exported) },
113+ ]}
114+ empty="No usage recorded."
115+ />
116+ </Card>
117+
118+ <Modal open={create} title="Create API key" onClose={() => setCreate(false)}>
119+ <CreateKeyForm
120+ onCreated={(t, name) => {
121+ setCreate(false);
122+ setToken({ token: t, name });
123+ reload();
124+ }}
125+ />
126+ </Modal>
127+
128+ <Modal open={!!token} title="Key created — copy it now" onClose={() => setToken(null)}>
129+ {token && (
130+ <div className="flex flex-col gap-3">
131+ <Banner tone="warn">This token is shown <strong>once</strong>. Store it in a password manager; the server keeps only a hash.</Banner>
132+ <div className="flex items-center gap-2 rounded-md border border-border bg-surface-2 p-2">
133+ <KeyRound className="h-4 w-4 shrink-0 text-ink-3" />
134+ <code className="flex-1 break-all text-xs">{token.token}</code>
135+ <CopyButton text={token.token} />
136+ </div>
137+ <pre className="overflow-auto rounded-md border border-hairline bg-surface-2 p-2 text-xs">{`export FINDATA_API_KEY="${token.token}"\ncurl -H "Authorization: Bearer $FINDATA_API_KEY" "${typeof window !== "undefined" ? window.location.origin : ""}/api/v1/health"`}</pre>
138+ <button className="btn btn-primary self-end" onClick={() => setToken(null)}>Done</button>
139+ </div>
140+ )}
141+ </Modal>
142+
143+ <ConfirmDialog open={!!revoke} title={`Revoke “${revoke?.name}”?`} confirmLabel="Revoke" danger busy={busy} onCancel={() => setRevoke(null)} onConfirm={() => void doRevoke()}>
144+ Every client using this key will start receiving 401 immediately. This cannot be undone.
145+ </ConfirmDialog>
146+ </>
147+ );
148+}
149+
150+function CreateKeyForm({ onCreated }: { onCreated: (token: string, name: string) => void }) {
151+ const toast = useToast();
152+ const [name, setName] = useState("");
153+ const [scopes, setScopes] = useState<string[]>(["read:market"]);
154+ const [rate, setRate] = useState(600);
155+ const [expires, setExpires] = useState("");
156+ const [busy, setBusy] = useState(false);
157+
158+ async function submit(e: FormEvent) {
159+ e.preventDefault();
160+ setBusy(true);
161+ try {
162+ const out = await api.post<{ id: string; token: string }>("/admin/keys", {
163+ name: name.trim(),
164+ scopes,
165+ rate_limit_per_minute: rate,
166+ expires_at: expires ? new Date(expires).toISOString() : null,
167+ });
168+ onCreated(out.token, name.trim());
169+ } catch (err) {
170+ toast.error(err);
171+ } finally {
172+ setBusy(false);
173+ }
174+ }
175+
176+ return (
177+ <form onSubmit={submit} className="flex flex-col gap-3">
178+ <Field label="Name">
179+ <input className="input" value={name} onChange={(e) => setName(e.target.value)} maxLength={60} required placeholder="research-laptop" autoFocus />
180+ </Field>
181+ <fieldset>
182+ <legend className="label">Scopes</legend>
183+ <div className="flex flex-col gap-1.5">
184+ {SCOPES.map((s) => (
185+ <label key={s.id} className="flex items-start gap-2 text-sm">
186+ <input type="checkbox" className="mt-0.5" checked={scopes.includes(s.id)} onChange={(e) => setScopes((cur) => (e.target.checked ? [...cur, s.id] : cur.filter((x) => x !== s.id)))} />
187+ <span>
188+ <code className="text-xs">{s.id}</code> <span className="text-xs text-ink-3">— {s.help}</span>
189+ </span>
190+ </label>
191+ ))}
192+ </div>
193+ </fieldset>
194+ <div className="grid grid-cols-2 gap-2">
195+ <Field label="Rate limit / minute">
196+ <input type="number" className="input" min={1} max={10000} value={rate} onChange={(e) => setRate(Number(e.target.value) || 600)} />
197+ </Field>
198+ <Field label="Expires (optional)">
199+ <input type="datetime-local" className="input" value={expires} onChange={(e) => setExpires(e.target.value)} />
200+ </Field>
201+ </div>
202+ <div className="flex justify-end pt-1">
203+ <button className="btn btn-primary" disabled={busy || !name.trim() || scopes.length === 0}>
204+ {busy ? "Creating…" : "Create key"}
205+ </button>
206+ </div>
207+ </form>
208+ );
209+}
added apps/web/src/app/layout.tsx +32 −0
@@ -0,0 +1,32 @@
1+import type { Metadata, Viewport } from "next";
2+import "./globals.css";
3+import { Providers } from "@/components/Providers";
4+import { Shell } from "@/components/Shell";
5+
6+export const metadata: Metadata = {
7+ title: { default: "FinData OS", template: "%s · FinData OS" },
8+ description: "FinData OS — private financial data lakehouse console",
9+ robots: { index: false, follow: false },
10+ icons: { icon: "/favicon.svg" },
11+};
12+
13+export const viewport: Viewport = {
14+ width: "device-width",
15+ initialScale: 1,
16+ themeColor: [
17+ { media: "(prefers-color-scheme: light)", color: "#f4f4f1" },
18+ { media: "(prefers-color-scheme: dark)", color: "#0d0d0d" },
19+ ],
20+};
21+
22+export default function RootLayout({ children }: { children: React.ReactNode }) {
23+ return (
24+ <html lang="en" suppressHydrationWarning>
25+ <body>
26+ <Providers>
27+ <Shell>{children}</Shell>
28+ </Providers>
29+ </body>
30+ </html>
31+ );
32+}
added apps/web/src/app/login/page.tsx +170 −0
@@ -0,0 +1,170 @@
1+"use client";
2+import { useEffect, useState, type FormEvent } from "react";
3+import { useRouter } from "next/navigation";
4+import { KeyRound, LockKeyhole } from "lucide-react";
5+import { api, isApiError, setCsrf, type LoginOut } from "@/lib/api";
6+import { useAuth } from "@/lib/auth";
7+
8+export default function LoginPage() {
9+ const { me, loading, refresh } = useAuth();
10+ const router = useRouter();
11+ const [email, setEmail] = useState("");
12+ const [password, setPassword] = useState("");
13+ const [code, setCode] = useState("");
14+ const [step, setStep] = useState<"password" | "mfa">("password");
15+ const [busy, setBusy] = useState(false);
16+ const [error, setError] = useState<string | null>(null);
17+
18+ // Session exists but MFA not passed → go straight to the code step.
19+ useEffect(() => {
20+ if (!loading && me && !me.mfa_passed) setStep("mfa");
21+ }, [loading, me]);
22+
23+ async function submitPassword(e: FormEvent) {
24+ e.preventDefault();
25+ setBusy(true);
26+ setError(null);
27+ try {
28+ const out = await api.post<LoginOut>("/auth/login", { email: email.trim(), password });
29+ setCsrf(out.csrf_token);
30+ if (out.mfa_required) {
31+ setStep("mfa");
32+ setPassword("");
33+ } else {
34+ await refresh();
35+ router.replace("/");
36+ }
37+ } catch (err) {
38+ setError(describe(err));
39+ } finally {
40+ setBusy(false);
41+ }
42+ }
43+
44+ async function submitCode(e: FormEvent) {
45+ e.preventDefault();
46+ setBusy(true);
47+ setError(null);
48+ try {
49+ const out = await api.post<{ ok: boolean; csrf_token: string }>("/auth/mfa", { code: code.replace(/\s+/g, "") });
50+ setCsrf(out.csrf_token);
51+ await refresh();
52+ router.replace("/");
53+ } catch (err) {
54+ setError(describe(err));
55+ setCode("");
56+ } finally {
57+ setBusy(false);
58+ }
59+ }
60+
61+ return (
62+ <div className="flex min-h-dvh items-center justify-center bg-surface-0 p-4">
63+ <div className="w-full max-w-sm">
64+ <div className="mb-6 flex items-center gap-3">
65+ <svg viewBox="0 0 32 32" className="h-9 w-9" aria-hidden>
66+ <rect width="32" height="32" rx="7" fill="var(--accent)" />
67+ <path d="M8 22V10h4v12zm6 0V14h4v8zm6 0v-5h4v5z" fill="#fff" />
68+ </svg>
69+ <div>
70+ <div className="text-lg font-semibold tracking-tight">FinData OS</div>
71+ <div className="text-xs text-ink-2">Private console — authorised users only</div>
72+ </div>
73+ </div>
74+
75+ <div className="card">
76+ {step === "password" ? (
77+ <form onSubmit={submitPassword} className="flex flex-col gap-3" aria-busy={busy}>
78+ <h1 className="flex items-center gap-2 text-base font-semibold">
79+ <LockKeyhole className="h-4 w-4 text-ink-2" /> Sign in
80+ </h1>
81+ <label className="block">
82+ <span className="label">E-mail</span>
83+ <input className="input" type="email" autoComplete="username" required value={email} onChange={(e) => setEmail(e.target.value)} autoFocus />
84+ </label>
85+ <label className="block">
86+ <span className="label">Password</span>
87+ <input className="input" type="password" autoComplete="current-password" required value={password} onChange={(e) => setPassword(e.target.value)} />
88+ </label>
89+ {error && (
90+ <p role="alert" className="banner border-crit/60 bg-crit-soft text-crit-ink">
91+ {error}
92+ </p>
93+ )}
94+ <button className="btn btn-primary mt-1" disabled={busy || !email || !password}>
95+ {busy ? "Signing in…" : "Continue"}
96+ </button>
97+ </form>
98+ ) : (
99+ <form onSubmit={submitCode} className="flex flex-col gap-3" aria-busy={busy}>
100+ <h1 className="flex items-center gap-2 text-base font-semibold">
101+ <KeyRound className="h-4 w-4 text-ink-2" /> Two-factor code
102+ </h1>
103+ <p className="text-sm text-ink-2">Enter the 6-digit code from your authenticator app{me?.email ? ` for ${me.email}` : ""}.</p>
104+ <label className="block">
105+ <span className="label">One-time code</span>
106+ <input
107+ className="input text-center font-mono text-lg tracking-[0.4em]"
108+ inputMode="numeric"
109+ autoComplete="one-time-code"
110+ pattern="[0-9 ]{6,8}"
111+ required
112+ autoFocus
113+ value={code}
114+ onChange={(e) => setCode(e.target.value)}
115+ />
116+ </label>
117+ {error && (
118+ <p role="alert" className="banner border-crit/60 bg-crit-soft text-crit-ink">
119+ {error}
120+ </p>
121+ )}
122+ <button className="btn btn-primary mt-1" disabled={busy || code.replace(/\s+/g, "").length < 6}>
123+ {busy ? "Verifying…" : "Verify"}
124+ </button>
125+ <button
126+ type="button"
127+ className="btn btn-ghost btn-sm self-center"
128+ onClick={async () => {
129+ try {
130+ await api.post("/auth/logout");
131+ } catch {
132+ /* ignore */
133+ }
134+ setCsrf(null);
135+ await refresh();
136+ setStep("password");
137+ setError(null);
138+ }}
139+ >
140+ Use a different account
141+ </button>
142+ </form>
143+ )}
144+ </div>
145+ <p className="mt-4 text-center text-[11px] text-ink-3">Sessions use an HttpOnly cookie; every change is CSRF-protected and recorded in the audit log.</p>
146+ </div>
147+ </div>
148+ );
149+}
150+
151+function describe(err: unknown): string {
152+ if (isApiError(err)) {
153+ switch (err.code) {
154+ case "invalid_credentials":
155+ return "Invalid e-mail or password.";
156+ case "invalid_code":
157+ return "That code did not match. Codes rotate every 30 seconds — check your device clock and try again.";
158+ case "rate_limited":
159+ return "Too many attempts. Wait a minute and try again.";
160+ case "csrf":
161+ return "Session token mismatch. Reload the page and sign in again.";
162+ case "unauthenticated":
163+ return "Your session expired. Sign in again.";
164+ default:
165+ return `${err.message} (${err.code}${err.request_id ? `, request ${err.request_id}` : ""})`;
166+ }
167+ }
168+ if (err instanceof TypeError) return "Cannot reach the API. Is the server running?";
169+ return err instanceof Error ? err.message : "Unexpected error";
170+}
added apps/web/src/app/page.tsx +156 −0
@@ -0,0 +1,156 @@
1+"use client";
2+import Link from "next/link";
3+import { RefreshCw } from "lucide-react";
4+import { api, type HealthSummary } from "@/lib/api";
5+import { useAsync, useInterval } from "@/lib/hooks";
6+import { fmtCompact, fmtGB, fmtInt, fmtRelative, fmtTs } from "@/lib/format";
7+import { Banner, Card, ErrorState, KpiTile, PageHeader, SeverityBadge, Skeleton, StatusBadge } from "@/components/ui";
8+import { DataTable } from "@/components/DataTable";
9+import { Job } from "@/lib/api";
10+import { useAuth } from "@/lib/auth";
11+
12+export default function OverviewPage() {
13+ const { isOwner } = useAuth();
14+ const { data, error, loading, reload } = useAsync((signal) => api.get<HealthSummary>("/health/summary", undefined, signal), []);
15+ useInterval(reload, 60_000);
16+
17+ const h = data?.health;
18+ const lake = h?.components.lake;
19+ const jobs = data?.jobs ?? {};
20+ const anomalies = data?.anomalies_7d ?? {};
21+ const anomaliesTotal = Object.values(anomalies).reduce((a, b) => a + b, 0);
22+ const primaryDs = h?.datasets?.find((d) => d.dataset_id === "market_bars.stock.1m.raw") ?? h?.datasets?.[0];
23+ const dead = jobs.dead ?? 0;
24+ const freeTone = lake ? (lake.free_gb < lake.hard_min_free_gb ? "crit" : lake.free_gb < lake.hard_min_free_gb * 2 ? "warn" : undefined) : undefined;
25+
26+ return (
27+ <>
28+ <PageHeader
29+ title="Overview"
30+ subtitle={h ? <>Health checked {fmtRelative(h.checked_at)} · auto-refresh every 60 s</> : "Lakehouse status at a glance"}
31+ actions={
32+ <>
33+ {h && <StatusBadge status={h.status} label={h.status === "ok" ? "all systems ok" : h.status} />}
34+ <button className="btn btn-sm" onClick={reload} disabled={loading}>
35+ <RefreshCw className={loading ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} /> Refresh
36+ </button>
37+ </>
38+ }
39+ />
40+
41+ <Banner tone="warn" className="mb-4">
42+ <strong>FirstRate Data — private use only.</strong> Raw bars and tables may not be redistributed (no public API, CSV or tables). Derived results may be published with attribution.
43+ </Banner>
44+
45+ {error && <ErrorState error={error} retry={reload} />}
46+ {!data && !error && <Skeleton rows={6} />}
47+
48+ {data && (
49+ <>
50+ {h && h.alerts.length > 0 && (
51+ <Card title={`Alerts (${h.alerts.length})`} className="mb-4">
52+ <ul className="flex flex-col gap-1.5">
53+ {h.alerts.map((a, i) => (
54+ <li key={i} className="flex flex-wrap items-center gap-2 text-sm">
55+ <SeverityBadge severity={a.severity} />
56+ <code className="text-xs text-ink-3">{a.code}</code>
57+ <span>{a.message}</span>
58+ </li>
59+ ))}
60+ </ul>
61+ </Card>
62+ )}
63+
64+ <div className="mb-4 grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
65+ <KpiTile label="1-min symbols" value={fmtInt(data.bars_1m.symbols)} sub={`${fmtInt(data.instruments.active)} active instruments`} href="/symbols/" />
66+ <KpiTile label="Sessions" value={fmtInt(data.bars_1m.sessions)} sub="with 1-min bars" href="/coverage/" />
67+ <KpiTile label="1-min bars" value={fmtCompact(data.bars_1m.bars)} sub={data.bars_1m.bars ? fmtInt(data.bars_1m.bars) : undefined} href="/bars/" />
68+ <KpiTile label="Last session" value={data.bars_1m.last ?? "—"} sub={primaryDs?.days_since_last_session !== null && primaryDs?.days_since_last_session !== undefined ? `${primaryDs.days_since_last_session} d ago` : undefined} />
69+ <KpiTile
70+ label="Vendor lag"
71+ value={primaryDs?.vendor_lag_days === null || primaryDs?.vendor_lag_days === undefined ? "—" : `${primaryDs.vendor_lag_days} d`}
72+ sub={primaryDs?.vendor_last_update ? `vendor ${primaryDs.vendor_last_update}` : "no vendor checkpoint"}
73+ tone={primaryDs?.vendor_lag_days ? "warn" : undefined}
74+ />
75+ <KpiTile
76+ label="Lake free"
77+ value={lake ? fmtGB(lake.free_gb, 0) : "—"}
78+ sub={lake ? `curated ${fmtGB(lake.curated_gb, 0)} / budget ${lake.soft_limit_gb} GB · min free ${lake.hard_min_free_gb} GB` : undefined}
79+ tone={freeTone}
80+ href={isOwner ? "/storage/" : undefined}
81+ />
82+ <KpiTile label="Jobs" value={`${fmtInt(jobs.queued ?? 0)} / ${fmtInt(jobs.running ?? 0)} / ${fmtInt(dead)}`} sub="queued / running / dead" tone={dead ? "crit" : undefined} href={isOwner ? "/ingestion/" : undefined} />
83+ <KpiTile label="Fundamentals" value={fmtInt(data.fundamentals.entities)} sub={`${fmtCompact(data.fundamentals.facts)} facts`} href="/fundamentals/" />
84+ <KpiTile label="Anomalies (7 d)" value={fmtInt(anomaliesTotal)} sub={Object.entries(anomalies).map(([k, v]) => `${k} ${v}`).join(" · ") || "none"} tone={anomalies.error ? "crit" : anomaliesTotal ? "warn" : undefined} href="/quality/" />
85+ <KpiTile label="Datasets" value={fmtInt(data.datasets.active)} sub={`${data.datasets.planned} planned · ${data.datasets.pending} pending · ${data.datasets.unsupported} unsupported`} href="/datasets/" />
86+ </div>
87+
88+ <div className="grid gap-4 lg:grid-cols-[1fr_minmax(280px,0.6fr)]">
89+ <Card title="Recent jobs" actions={isOwner && <Link href="/ingestion/" className="text-xs text-accent-ink hover:underline">All jobs →</Link>}>
90+ <DataTable<Job>
91+ rows={data.recent_jobs}
92+ rowKey={(r) => r.id}
93+ mobile="scroll"
94+ columns={[
95+ { key: "kind", header: "Kind", render: (r) => <span className="font-medium">{r.kind}</span> },
96+ { key: "dataset_id", header: "Dataset", mono: true, hideSm: true },
97+ { key: "status", header: "Status", render: (r) => <StatusBadge status={r.status} /> },
98+ { key: "started_at", header: "Started", render: (r) => <span className="tnum text-xs">{fmtTs(r.started_at)}</span>, hideSm: true },
99+ { key: "finished_at", header: "Finished", render: (r) => <span className="tnum text-xs">{fmtTs(r.finished_at)}</span>, hideSm: true },
100+ { key: "last_error", header: "Error", render: (r) => (r.last_error ? <span className="text-xs text-crit-ink break-words">{r.last_error}</span> : <span className="text-ink-3">—</span>) },
101+ ]}
102+ />
103+ </Card>
104+ <Card title="Components">
105+ <ul className="flex flex-col gap-2 text-sm">
106+ <li className="flex items-center justify-between gap-2">
107+ <span>Postgres</span>
108+ <StatusBadge status={h?.components.postgres?.startsWith("error") ? "error" : h?.components.postgres} label={h?.components.postgres} />
109+ </li>
110+ <li className="flex items-center justify-between gap-2">
111+ <span>Ingest worker</span>
112+ <StatusBadge status={h?.components.ingest_worker === "running" ? "ok" : "error"} label={h?.components.ingest_worker ?? "unknown"} />
113+ </li>
114+ <li className="flex items-center justify-between gap-2">
115+ <span>Backup</span>
116+ <span className="text-right text-xs">
117+ {h?.components.backup?.last ? (
118+ <>
119+ <StatusBadge status={h.components.backup.verified ? "verified" : "warning"} label={h.components.backup.verified ? "verified" : "unverified"} />{" "}
120+ <span className="text-ink-2">{fmtRelative(h.components.backup.last)}</span>
121+ </>
122+ ) : (
123+ <StatusBadge status="warning" label="none yet" />
124+ )}
125+ </span>
126+ </li>
127+ <li className="flex items-center justify-between gap-2">
128+ <span>SSD SMART</span>
129+ <StatusBadge status={h?.components.ssd_smart === "Verified" ? "ok" : h?.components.ssd_smart === "unavailable" || h?.components.ssd_smart === "Not Supported" ? "neutral" : "error"} label={h?.components.ssd_smart ?? "unknown"} />
130+ </li>
131+ </ul>
132+ {h?.datasets && h.datasets.length > 0 && (
133+ <div className="mt-4">
134+ <div className="mb-1 text-xs font-semibold text-ink-2">Dataset freshness</div>
135+ <ul className="flex flex-col gap-1 text-xs">
136+ {h.datasets.map((d) => (
137+ <li key={d.dataset_id} className="flex flex-wrap items-center justify-between gap-1">
138+ <Link href={`/datasets/detail/?id=${encodeURIComponent(d.dataset_id)}`} className="font-mono text-accent-ink hover:underline">
139+ {d.dataset_id}
140+ </Link>
141+ <span className="tnum text-ink-2">
142+ v{d.version ?? "—"} · {d.last_session ?? "no sessions"}
143+ {d.vendor_lag_days ? <span className="ml-1 text-warn-ink">· lag {d.vendor_lag_days} d</span> : null}
144+ </span>
145+ </li>
146+ ))}
147+ </ul>
148+ </div>
149+ )}
150+ </Card>
151+ </div>
152+ </>
153+ )}
154+ </>
155+ );
156+}
added apps/web/src/app/playground/page.tsx +183 −0
@@ -0,0 +1,183 @@
1+"use client";
2+import { useEffect, useRef, useState, type KeyboardEvent } from "react";
3+import { Download, Play } from "lucide-react";
4+import clsx from "clsx";
5+import { api, type QueryOut } from "@/lib/api";
6+import { useAsync, useLocalStorage, useQueryParam } from "@/lib/hooks";
7+import { useToast } from "@/lib/toast";
8+import { downloadText, fmtInt, toCsv } from "@/lib/format";
9+import { Banner, Card, Empty, ErrorState, PageHeader } from "@/components/ui";
10+
11+const DISPLAY_CAP = 1000;
12+
13+export default function PlaygroundPage() {
14+ const qSymbol = useQueryParam("symbol");
15+ const [sql, setSql] = useLocalStorage("findata.playground.sql", "SELECT symbol_as_reported, count(*) bars FROM market_bars_stock_1m_raw GROUP BY 1 ORDER BY 2 DESC LIMIT 20");
16+ const [maxRows, setMaxRows] = useState(1000);
17+ const [out, setOut] = useState<QueryOut | null>(null);
18+ const [busy, setBusy] = useState(false);
19+ const [error, setError] = useState<unknown>(null);
20+ const toast = useToast();
21+ const ta = useRef<HTMLTextAreaElement>(null);
22+ const views = useAsync((s) => api.get<{ views: { name: string; files: number }[]; examples: string[] }>("/query/views", undefined, s), []);
23+
24+ useEffect(() => {
25+ if (qSymbol) setSql(`SELECT session_date, min(low) lo, max(high) hi, sum(volume) vol\nFROM market_bars_stock_1m_raw\nWHERE symbol_as_reported = '${qSymbol.toUpperCase().replace(/'/g, "")}'\nGROUP BY 1 ORDER BY 1 DESC LIMIT 100`);
26+ // eslint-disable-next-line react-hooks/exhaustive-deps
27+ }, [qSymbol]);
28+
29+ async function run() {
30+ if (!sql.trim()) return;
31+ setBusy(true);
32+ setError(null);
33+ try {
34+ const o = await api.post<QueryOut>("/query", { sql, max_rows: maxRows });
35+ setOut(o);
36+ } catch (e) {
37+ setError(e);
38+ setOut(null);
39+ } finally {
40+ setBusy(false);
41+ }
42+ }
43+
44+ function onKey(e: KeyboardEvent<HTMLTextAreaElement>) {
45+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
46+ e.preventDefault();
47+ void run();
48+ }
49+ if (e.key === "Tab") {
50+ e.preventDefault();
51+ const el = e.currentTarget;
52+ const s = el.selectionStart, en = el.selectionEnd;
53+ const next = sql.slice(0, s) + " " + sql.slice(en);
54+ setSql(next);
55+ requestAnimationFrame(() => el.setSelectionRange(s + 2, s + 2));
56+ }
57+ }
58+
59+ const shown = out ? out.rows.slice(0, DISPLAY_CAP) : [];
60+
61+ return (
62+ <>
63+ <PageHeader title="SQL playground" subtitle="DuckDB over the curated Parquet files — read-only, one statement, sandboxed. Results are logged to the audit trail." />
64+ <div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
65+ <div className="flex flex-col gap-4">
66+ <Card>
67+ <textarea
68+ ref={ta}
69+ className="input min-h-40 resize-y font-mono text-[13px] leading-relaxed"
70+ spellCheck={false}
71+ value={sql}
72+ onChange={(e) => setSql(e.target.value)}
73+ onKeyDown={onKey}
74+ aria-label="SQL"
75+ />
76+ <div className="mt-2 flex flex-wrap items-center gap-2">
77+ <button className="btn btn-primary" onClick={() => void run()} disabled={busy || !sql.trim()}>
78+ <Play className="h-3.5 w-3.5" /> {busy ? "Running…" : "Run"}
79+ </button>
80+ <span className="text-xs text-ink-3">
81+ <kbd className="kbd">⌘</kbd>/<kbd className="kbd">Ctrl</kbd> + <kbd className="kbd">Enter</kbd>
82+ </span>
83+ <label className="ml-auto flex items-center gap-2 text-xs text-ink-2">
84+ max rows
85+ <input type="number" className="input w-24 py-1" min={1} max={100000} value={maxRows} onChange={(e) => setMaxRows(Math.max(1, Math.min(100000, Number(e.target.value) || 1000)))} />
86+ </label>
87+ </div>
88+ </Card>
89+
90+ {error && <ErrorState error={error} retry={() => void run()} />}
91+
92+ {out && (
93+ <Card
94+ title={
95+ <span className="flex flex-wrap items-center gap-2 text-sm">
96+ Results
97+ <span className="pill bg-surface-2 text-ink-2 tnum">{fmtInt(out.meta.rows)} rows</span>
98+ <span className="pill bg-surface-2 text-ink-2 tnum">{fmtInt(out.meta.elapsed_ms)} ms</span>
99+ {out.meta.truncated && <span className="pill bg-warn-soft text-warn-ink">truncated at max_rows</span>}
100+ {out.rows.length > DISPLAY_CAP && <span className="pill bg-warn-soft text-warn-ink">showing first {fmtInt(DISPLAY_CAP)}</span>}
101+ </span>
102+ }
103+ actions={
104+ <button className="btn btn-sm" onClick={() => downloadText(`query-${Date.now()}.csv`, toCsv(out.columns.map((c) => c.name), out.rows))} disabled={!out.rows.length}>
105+ <Download className="h-3.5 w-3.5" /> CSV ({fmtInt(out.rows.length)} rows)
106+ </button>
107+ }
108+ >
109+ {out.rows.length === 0 ? (
110+ <Empty>Query returned no rows.</Empty>
111+ ) : (
112+ <div className="table-wrap" style={{ maxHeight: "60dvh", overflowY: "auto" }}>
113+ <table className="table">
114+ <thead>
115+ <tr>
116+ {out.columns.map((c) => (
117+ <th key={c.name}>
118+ {c.name}
119+ <div className="font-normal text-[10px] text-ink-3">{c.type}</div>
120+ </th>
121+ ))}
122+ </tr>
123+ </thead>
124+ <tbody>
125+ {shown.map((r, i) => (
126+ <tr key={i}>
127+ {out.columns.map((c) => {
128+ const v = r[c.name];
129+ const isNum = typeof v === "number";
130+ return (
131+ <td key={c.name} className={clsx("font-mono text-xs whitespace-nowrap", isNum && "num")}>
132+ {v === null || v === undefined ? <span className="text-ink-3">NULL</span> : typeof v === "object" ? JSON.stringify(v) : String(v)}
133+ </td>
134+ );
135+ })}
136+ </tr>
137+ ))}
138+ </tbody>
139+ </table>
140+ </div>
141+ )}
142+ <div className="mt-2 text-[11px] text-ink-3">engine: {out.meta.engine}</div>
143+ </Card>
144+ )}
145+ </div>
146+
147+ <div className="flex flex-col gap-4">
148+ <Card title="Views">
149+ {views.error && <ErrorState error={views.error} retry={views.reload} />}
150+ {views.data && views.data.views.length === 0 && <Empty>No views (no curated files yet).</Empty>}
151+ <ul className="flex flex-col gap-1 text-xs">
152+ {views.data?.views.map((v) => (
153+ <li key={v.name} className="flex items-center justify-between gap-2">
154+ <button className="font-mono text-accent-ink hover:underline text-left break-all" onClick={() => setSql(`SELECT * FROM ${v.name} LIMIT 100`)} title="Insert SELECT">
155+ {v.name}
156+ </button>
157+ <span className="tnum text-ink-3 whitespace-nowrap">{fmtInt(v.files)} files</span>
158+ </li>
159+ ))}
160+ </ul>
161+ </Card>
162+ <Card title="Examples">
163+ <ul className="flex flex-col gap-2">
164+ {views.data?.examples.map((ex, i) => (
165+ <li key={i}>
166+ <button className="w-full rounded-md border border-hairline bg-surface-2 p-2 text-left font-mono text-[11px] leading-snug text-ink-2 hover:border-accent/60 whitespace-pre-wrap break-words" onClick={() => setSql(ex)}>
167+ {ex}
168+ </button>
169+ </li>
170+ ))}
171+ </ul>
172+ </Card>
173+ <Banner tone="accent">
174+ <div className="text-xs">
175+ Bars are stamped at <strong>bar open</strong> in UTC. Columns: <code>timestamp_utc, session_date, open, high, low, close, volume, vwap, trade_count, quality_flags, symbol_as_reported, instrument_id</code>. Stored data is <strong>raw</strong>; adjustments live in <code>corporate_actions</code>.
176+ </div>
177+ </Banner>
178+ <p className="text-[11px] text-ink-3" onClick={() => toast.info("Query results are private", "FirstRate Data terms forbid redistribution of raw bars.")}>License: frd-private — no redistribution of raw bars.</p>
179+ </div>
180+ </div>
181+ </>
182+ );
183+}
added apps/web/src/app/quality/page.tsx +148 −0
@@ -0,0 +1,148 @@
1+"use client";
2+import Link from "next/link";
3+import { useEffect, useState } from "react";
4+import { api, type Anomaly, type Dataset, type QualityOut } from "@/lib/api";
5+import { useAsync, useDebounced, useQueryParam } from "@/lib/hooks";
6+import { fmtInt, fmtTs } from "@/lib/format";
7+import { Card, Empty, ErrorState, Field, PageHeader, SeverityBadge, Skeleton, KV } from "@/components/ui";
8+import { DataTable } from "@/components/DataTable";
9+import { JsonView } from "@/components/JsonView";
10+import { CategoryBars } from "@/components/charts";
11+
12+const DEFAULT_DS = "market_bars.stock.1m.raw";
13+
14+export default function QualityPage() {
15+ const qDs = useQueryParam("dataset_id");
16+ const [datasetId, setDatasetId] = useState(DEFAULT_DS);
17+ const [code, setCode] = useState("");
18+ const [severity, setSeverity] = useState("");
19+ const [symbol, setSymbol] = useState("");
20+ const dsym = useDebounced(symbol.trim().toUpperCase(), 300);
21+ useEffect(() => {
22+ if (qDs) setDatasetId(qDs);
23+ }, [qDs]);
24+
25+ const datasets = useAsync((s) => api.get<{ data: Dataset[] }>("/catalog/datasets", undefined, s), []);
26+ const q = useAsync((s) => api.get<QualityOut>("/quality", { dataset_id: datasetId }, s), [datasetId]);
27+ const an = useAsync((s) => api.get<{ data: Anomaly[] }>("/quality/anomalies", { dataset_id: datasetId, code, severity, symbol: dsym, limit: 500 }, s), [datasetId, code, severity, dsym]);
28+
29+ const latest = q.data?.latest;
30+ const byCode = (q.data?.anomalies_by_code_30d ?? []).map((r) => ({ name: r.code, value: Number(r.n) }));
31+
32+ return (
33+ <>
34+ <PageHeader
35+ title="Quality"
36+ subtitle="Validation reports and anomalies recorded at ingestion"
37+ actions={
38+ <Field label="Dataset" className="w-72">
39+ <select className="input font-mono text-xs" value={datasetId} onChange={(e) => setDatasetId(e.target.value)}>
40+ {!(datasets.data?.data ?? []).some((d) => d.id === datasetId) && <option value={datasetId}>{datasetId}</option>}
41+ {(datasets.data?.data ?? []).filter((d) => d.status === "active").map((d) => (
42+ <option key={d.id} value={d.id}>
43+ {d.id}
44+ </option>
45+ ))}
46+ </select>
47+ </Field>
48+ }
49+ />
50+ {q.error && <ErrorState error={q.error} retry={q.reload} />}
51+ <div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
52+ <Card title="Latest report">
53+ {q.loading && !q.data ? (
54+ <Skeleton rows={5} />
55+ ) : latest ? (
56+ <>
57+ <KV
58+ items={[
59+ ["Report id", <code key="id" className="text-xs">{String(latest.id ?? "—")}</code>],
60+ ["Created", fmtTs(latest.created_at as string)],
61+ ["Rows checked", fmtInt(latest.rows_checked as number)],
62+ ["Anomalies", fmtInt(latest.anomalies as number)],
63+ ]}
64+ />
65+ <div className="mt-3 text-xs text-ink-3">summary</div>
66+ <JsonView value={latest.summary ?? {}} maxHeight="16rem" />
67+ </>
68+ ) : (
69+ <Empty>No quality report yet for this dataset — enqueue a <code>quality</code> job from Ingestion.</Empty>
70+ )}
71+ </Card>
72+ <Card title="Anomalies by code (30 days)">
73+ {byCode.length ? <CategoryBars data={byCode} /> : <Empty>No anomalies in the last 30 days.</Empty>}
74+ <ul className="mt-3 grid gap-1 text-xs sm:grid-cols-2">
75+ {Object.entries(q.data?.codes ?? {}).map(([c, d]) => (
76+ <li key={c} className="flex gap-2">
77+ <button className={`font-mono shrink-0 ${code === c ? "text-accent-ink underline" : "text-ink-1 hover:underline"}`} onClick={() => setCode(code === c ? "" : c)}>
78+ {c}
79+ </button>
80+ <span className="text-ink-3">{d}</span>
81+ </li>
82+ ))}
83+ </ul>
84+ </Card>
85+ </div>
86+
87+ <Card title={`Reports (${q.data?.reports.length ?? 0})`} className="mt-4">
88+ <DataTable
89+ rows={q.data?.reports}
90+ loading={q.loading}
91+ rowKey={(r) => r.id}
92+ mobile="scroll"
93+ maxHeight="18rem"
94+ columns={[
95+ { key: "created_at", header: "Created", render: (r) => <span className="tnum text-xs">{fmtTs(r.created_at)}</span> },
96+ { key: "rows_checked", header: "Rows checked", num: true, render: (r) => fmtInt(r.rows_checked) },
97+ { key: "anomalies", header: "Anomalies", num: true, render: (r) => fmtInt(r.anomalies) },
98+ { key: "summary", header: "Summary", render: (r) => <JsonView value={r.summary} collapsed /> },
99+ ]}
100+ empty="No reports."
101+ />
102+ </Card>
103+
104+ <Card
105+ title="Anomalies"
106+ className="mt-4"
107+ actions={
108+ <div className="flex flex-wrap gap-2">
109+ <input className="input w-28 py-1 font-mono text-xs uppercase" placeholder="symbol" value={symbol} onChange={(e) => setSymbol(e.target.value)} aria-label="Symbol" />
110+ <select className="input w-auto py-1 text-xs" value={severity} onChange={(e) => setSeverity(e.target.value)} aria-label="Severity">
111+ <option value="">any severity</option>
112+ <option value="info">info</option>
113+ <option value="warning">warning</option>
114+ <option value="error">error</option>
115+ </select>
116+ <select className="input w-auto py-1 font-mono text-xs" value={code} onChange={(e) => setCode(e.target.value)} aria-label="Code">
117+ <option value="">any code</option>
118+ {Object.keys(q.data?.codes ?? {}).map((c) => (
119+ <option key={c} value={c}>
120+ {c}
121+ </option>
122+ ))}
123+ </select>
124+ </div>
125+ }
126+ >
127+ {an.error && <ErrorState error={an.error} retry={an.reload} />}
128+ <DataTable
129+ rows={an.data?.data}
130+ loading={an.loading}
131+ rowKey={(r, i) => String(r.id ?? i)}
132+ mobile="scroll"
133+ maxHeight="60dvh"
134+ columns={[
135+ { key: "created_at", header: "Recorded", render: (r) => <span className="tnum text-xs">{fmtTs(r.created_at)}</span> },
136+ { key: "severity", header: "Severity", render: (r) => <SeverityBadge severity={r.severity} /> },
137+ { key: "code", header: "Code", mono: true },
138+ { key: "symbol", header: "Symbol", render: (r) => (r.symbol ? <Link href={`/symbols/detail/?symbol=${encodeURIComponent(r.symbol)}`} className="font-mono text-accent-ink hover:underline">{r.symbol}</Link> : "—") },
139+ { key: "session_date", header: "Session", hideSm: true, render: (r) => <span className="tnum">{r.session_date ?? "—"}</span> },
140+ { key: "detail", header: "Detail", render: (r) => <JsonView value={r.detail} collapsed /> },
141+ ]}
142+ empty="No anomalies match."
143+ footer={<span>{fmtInt(an.data?.data.length ?? 0)} rows (max 500)</span>}
144+ />
145+ </Card>
146+ </>
147+ );
148+}
added apps/web/src/app/storage/page.tsx +149 −0
@@ -0,0 +1,149 @@
1+"use client";
2+import Link from "next/link";
3+import { useMemo } from "react";
4+import { RefreshCw } from "lucide-react";
5+import { api, type StorageOut } from "@/lib/api";
6+import { useAsync } from "@/lib/hooks";
7+import { useAuth } from "@/lib/auth";
8+import { fmtBytes, fmtCompact, fmtGB, fmtInt, fmtRelative, fmtTs } from "@/lib/format";
9+import { Banner, Card, Empty, ErrorState, KV, PageHeader, Skeleton, StatusBadge } from "@/components/ui";
10+import { DataTable } from "@/components/DataTable";
11+import { Meter, SegmentedBar } from "@/components/charts";
12+import { JsonView } from "@/components/JsonView";
13+
14+export default function StoragePage() {
15+ const { isOwner } = useAuth();
16+ const { data, error, loading, reload } = useAsync((s) => api.get<StorageOut>("/admin/storage", undefined, s), [], { enabled: isOwner });
17+
18+ const perDataset = useMemo(() => {
19+ const m = new Map<string, { dataset_id: string; base_files: number; base_bytes: number; base_rows: number; delta_files: number; delta_bytes: number; delta_rows: number; other_files: number; other_bytes: number }>();
20+ for (const r of data?.per_dataset ?? []) {
21+ const e = m.get(r.dataset_id) ?? { dataset_id: r.dataset_id, base_files: 0, base_bytes: 0, base_rows: 0, delta_files: 0, delta_bytes: 0, delta_rows: 0, other_files: 0, other_bytes: 0 };
22+ const b = Number(r.bytes ?? 0), rows = Number(r.rows ?? 0), f = Number(r.files ?? 0);
23+ if (r.layout === "base") { e.base_files += f; e.base_bytes += b; e.base_rows += rows; }
24+ else if (r.layout === "delta") { e.delta_files += f; e.delta_bytes += b; e.delta_rows += rows; }
25+ else { e.other_files += f; e.other_bytes += b; }
26+ m.set(r.dataset_id, e);
27+ }
28+ return Array.from(m.values()).sort((a, b) => b.base_bytes + b.delta_bytes - (a.base_bytes + a.delta_bytes));
29+ }, [data]);
30+
31+ if (!isOwner) return <Banner tone="warn">Storage details require the owner role.</Banner>;
32+
33+ const vol = data?.volume;
34+ const used = vol ? vol.total_gb - vol.free_gb : 0;
35+ const freeTone = vol && data ? (vol.free_gb < data.budget.hard_min_free_gb ? "crit" : vol.free_gb < data.budget.hard_min_free_gb * 2 ? "warn" : "accent") : "accent";
36+ const curatedGb = (data?.curated_bytes ?? 0) / 1e9;
37+ const bytesPerBar = useMemo(() => {
38+ const rows = perDataset.reduce((a, d) => a + d.base_rows + d.delta_rows, 0);
39+ const bytes = perDataset.reduce((a, d) => a + d.base_bytes + d.delta_bytes, 0);
40+ return rows > 0 ? bytes / rows : null;
41+ }, [perDataset]);
42+
43+ return (
44+ <>
45+ <PageHeader
46+ title="Storage"
47+ subtitle={data ? <>Lake root <code className="text-xs">{data.lake_root}</code></> : "Lake volume, budget and footprint"}
48+ actions={
49+ <button className="btn btn-sm" onClick={reload} disabled={loading}>
50+ <RefreshCw className={loading ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} /> Refresh
51+ </button>
52+ }
53+ />
54+ {error && <ErrorState error={error} retry={reload} />}
55+ {loading && !data && <Skeleton rows={6} />}
56+ {data && vol && (
57+ <>
58+ <div className="grid gap-4 lg:grid-cols-2">
59+ <Card title="Volume">
60+ <div className="mb-1 flex items-baseline justify-between">
61+ <span className="text-2xl font-semibold">{fmtGB(vol.free_gb, 0)} free</span>
62+ <span className="text-xs text-ink-2">of {fmtGB(vol.total_gb, 0)}</span>
63+ </div>
64+ <Meter value={used} max={vol.total_gb} tone={freeTone} label="Volume used" marks={[{ at: vol.total_gb - data.budget.hard_min_free_gb, label: `min free ${data.budget.hard_min_free_gb} GB` }]} className="mb-8 mt-2" />
65+ <KV
66+ items={[
67+ ["Used", `${fmtGB(used, 1)} (${((used / vol.total_gb) * 100).toFixed(0)} %)`],
68+ ["Hard minimum free", `${data.budget.hard_min_free_gb} GB`],
69+ ["Status", <StatusBadge key="s" status={freeTone === "crit" ? "error" : freeTone === "warn" ? "warning" : "ok"} label={freeTone === "crit" ? "disk critical" : freeTone === "warn" ? "disk low" : "healthy"} />],
70+ ]}
71+ />
72+ </Card>
73+ <Card title="Curated budget">
74+ <div className="mb-1 flex items-baseline justify-between">
75+ <span className="text-2xl font-semibold">{fmtGB(curatedGb, 1)}</span>
76+ <span className="text-xs text-ink-2">soft limit {data.budget.soft_limit_gb} GB</span>
77+ </div>
78+ <Meter value={curatedGb} max={Math.max(data.budget.soft_limit_gb, curatedGb)} tone={curatedGb > data.budget.soft_limit_gb ? "warn" : "accent"} label="Curated bytes vs budget" marks={[{ at: data.budget.soft_limit_gb, label: `budget ${data.budget.soft_limit_gb} GB` }]} className="mb-8 mt-2" />
79+ <SegmentedBar
80+ parts={[
81+ { label: `curated ${fmtBytes(data.curated_bytes)}`, value: data.curated_bytes, color: "var(--series-1)" },
82+ { label: `raw ${fmtBytes(data.raw_bytes)}`, value: data.raw_bytes, color: "var(--series-2)" },
83+ { label: `retired ${fmtBytes(data.retired_bytes)}`, value: data.retired_bytes, color: "var(--series-3)" },
84+ { label: `exports ${fmtBytes(data.exports_bytes)}`, value: data.exports_bytes, color: "var(--seq-200)" },
85+ { label: `postgres ${fmtBytes(data.postgres_bytes)}`, value: data.postgres_bytes, color: "var(--ink-3)" },
86+ ]}
87+ />
88+ </Card>
89+ </div>
90+
91+ <Card title={`Per dataset (${perDataset.length})`} className="mt-4">
92+ <DataTable
93+ rows={perDataset}
94+ rowKey={(r) => r.dataset_id}
95+ mobile="scroll"
96+ columns={[
97+ { key: "dataset_id", header: "Dataset", render: (r) => <Link href={`/datasets/detail/?id=${encodeURIComponent(r.dataset_id)}`} className="font-mono text-xs text-accent-ink hover:underline">{r.dataset_id}</Link> },
98+ { key: "base_bytes", header: "Base bytes", num: true, render: (r) => fmtBytes(r.base_bytes) },
99+ { key: "base_rows", header: "Base rows", num: true, render: (r) => <span title={fmtInt(r.base_rows)}>{fmtCompact(r.base_rows)}</span> },
100+ { key: "base_files", header: "Base files", num: true, hideSm: true, render: (r) => fmtInt(r.base_files) },
101+ { key: "delta_bytes", header: "Delta bytes", num: true, render: (r) => fmtBytes(r.delta_bytes) },
102+ { key: "delta_rows", header: "Delta rows", num: true, render: (r) => <span title={fmtInt(r.delta_rows)}>{fmtCompact(r.delta_rows)}</span> },
103+ { key: "delta_files", header: "Delta files", num: true, hideSm: true, render: (r) => fmtInt(r.delta_files) },
104+ { key: "other", header: "Other", num: true, hideSm: true, render: (r) => (r.other_files ? `${fmtInt(r.other_files)} · ${fmtBytes(r.other_bytes)}` : "—") },
105+ { key: "ratio", header: "Delta share", num: true, render: (r) => { const t = r.base_bytes + r.delta_bytes; return t ? `${((r.delta_bytes / t) * 100).toFixed(0)} %` : "—"; } },
106+ ]}
107+ empty="No lake files registered."
108+ />
109+ <p className="mt-2 text-xs text-ink-3">A high delta share means a <code>compaction</code> job would help (merges deltas into base files and publishes a new version).</p>
110+ </Card>
111+
112+ <div className="mt-4 grid gap-4 lg:grid-cols-3">
113+ <Card title="Raw archives">
114+ <DataTable rows={data.raw_archives} rowKey={(r) => r.status} mobile="scroll" columns={[{ key: "status", header: "Status", render: (r) => <StatusBadge status={r.status} /> }, { key: "n", header: "Archives", num: true, render: (r) => fmtInt(r.n) }, { key: "bytes", header: "Bytes", num: true, render: (r) => fmtBytes(r.bytes) }]} empty="No raw archives." />
115+ </Card>
116+ <Card title="Exports">
117+ <DataTable rows={data.exports} rowKey={(r) => r.status} mobile="scroll" columns={[{ key: "status", header: "Status", render: (r) => <StatusBadge status={r.status} /> }, { key: "n", header: "Count", num: true, render: (r) => fmtInt(r.n) }, { key: "bytes", header: "Bytes", num: true, render: (r) => fmtBytes(r.bytes) }]} empty="No exports." />
118+ <div className="mt-2 text-xs text-ink-3">Retired files: {fmtInt(data.retired_files.n)} · {fmtBytes(data.retired_files.bytes)} (reclaimed by <code>gc</code>)</div>
119+ </Card>
120+ <Card title="Postgres & backup">
121+ <KV
122+ items={[
123+ ["Database size", fmtBytes(data.postgres_bytes)],
124+ ["Last backup", data.last_backup ? `${fmtTs(data.last_backup.updated_at)} (${fmtRelative(data.last_backup.updated_at)})` : "none recorded"],
125+ ["Verified", data.last_backup ? <StatusBadge key="v" status={data.last_backup.value?.verified ? "verified" : "warning"} label={data.last_backup.value?.verified ? "yes" : "no"} /> : "—"],
126+ ]}
127+ />
128+ {data.last_backup && <div className="mt-2"><JsonView value={data.last_backup.value} collapsed /></div>}
129+ {!data.last_backup && <Empty>Enqueue a <code>backup</code> job from Ingestion.</Empty>}
130+ </Card>
131+ </div>
132+
133+ <Card title="Capacity note" className="mt-4">
134+ <p className="text-sm">
135+ Reference model: <strong>{data.capacity_model.bytes_per_1m_bar_curated} bytes</strong> per curated 1-minute bar.
136+ {bytesPerBar !== null && (
137+ <>
138+ {" "}Measured now: <strong>{bytesPerBar.toFixed(1)} bytes/row</strong> across all datasets. At this density the remaining budget ({fmtGB(Math.max(0, data.budget.soft_limit_gb - curatedGb), 0)}) holds about{" "}
139+ <strong>{fmtCompact(Math.max(0, (data.budget.soft_limit_gb - curatedGb) * 1e9) / bytesPerBar)}</strong> more bars.
140+ </>
141+ )}
142+ </p>
143+ <p className="mt-1 text-xs text-ink-3">{data.capacity_model.note}</p>
144+ </Card>
145+ </>
146+ )}
147+ </>
148+ );
149+}
added apps/web/src/app/symbols/detail/page.tsx +136 −0
@@ -0,0 +1,136 @@
1+"use client";
2+import Link from "next/link";
3+import { ArrowLeft } from "lucide-react";
4+import { api, type InstrumentDetail } from "@/lib/api";
5+import { useAsync, useQueryParam } from "@/lib/hooks";
6+import { fmtCompact, fmtInt } from "@/lib/format";
7+import { Card, Empty, ErrorState, KV, PageHeader, Skeleton, StatusBadge } from "@/components/ui";
8+import { DataTable } from "@/components/DataTable";
9+import { JsonView } from "@/components/JsonView";
10+
11+export default function SymbolDetailPage() {
12+ const symbol = useQueryParam("symbol")?.toUpperCase() ?? null;
13+ const { data, error, loading, reload } = useAsync((s) => api.get<InstrumentDetail>(`/instruments/${encodeURIComponent(symbol!)}`, undefined, s), [symbol], { enabled: !!symbol });
14+ const inst = data?.instrument;
15+
16+ return (
17+ <>
18+ <Link href="/symbols/" className="mb-2 inline-flex items-center gap-1 text-xs text-ink-2 hover:text-ink-1">
19+ <ArrowLeft className="h-3.5 w-3.5" /> Symbols
20+ </Link>
21+ <PageHeader
22+ title={symbol ?? "Symbol"}
23+ subtitle={inst?.name ?? (data && !inst ? "Unknown instrument (no master record) — coverage may still exist" : undefined)}
24+ actions={
25+ symbol && (
26+ <>
27+ {inst && <StatusBadge status={inst.status} />}
28+ <Link href={`/bars/?symbol=${encodeURIComponent(symbol)}`} className="btn btn-sm btn-primary">
29+ Bars
30+ </Link>
31+ <Link href={`/coverage/?symbol=${encodeURIComponent(symbol)}`} className="btn btn-sm">
32+ Sessions
33+ </Link>
34+ <Link href={`/fundamentals/?ticker=${encodeURIComponent(symbol)}`} className="btn btn-sm">
35+ Fundamentals
36+ </Link>
37+ <Link href={`/playground/?symbol=${encodeURIComponent(symbol)}`} className="btn btn-sm">
38+ SQL
39+ </Link>
40+ </>
41+ )
42+ }
43+ />
44+ {!symbol && <Empty>Missing <code>?symbol=</code> parameter.</Empty>}
45+ {error && <ErrorState error={error} retry={reload} />}
46+ {loading && !data && <Skeleton rows={8} />}
47+ {data && (
48+ <div className="grid gap-4 lg:grid-cols-2">
49+ <Card title="Instrument">
50+ {inst ? (
51+ <KV
52+ items={[
53+ ["Instrument id", <code key="i" className="text-xs">{inst.instrument_id}</code>],
54+ ["Asset class", inst.asset_class],
55+ ["Currency", inst.currency ?? "—"],
56+ ["First date", inst.first_date ?? "—"],
57+ ["Last date", inst.last_date ?? "—"],
58+ ...Object.entries(inst)
59+ .filter(([k]) => !["instrument_id", "symbol", "name", "asset_class", "status", "first_date", "last_date", "currency"].includes(k))
60+ .map(([k, v]) => [k, typeof v === "object" && v !== null ? <JsonView key={k} value={v} collapsed /> : String(v ?? "—")] as [string, React.ReactNode]),
61+ ]}
62+ />
63+ ) : (
64+ <Empty>No master record.</Empty>
65+ )}
66+ </Card>
67+ <Card title="Fundamentals">
68+ {data.fundamentals && data.fundamentals.facts > 0 ? (
69+ <KV
70+ items={[
71+ ["Facts (current)", fmtInt(data.fundamentals.facts)],
72+ ["Last fiscal period", data.fundamentals.last_period ?? "—"],
73+ ["Last filing", data.fundamentals.last_filing ?? "—"],
74+ ]}
75+ />
76+ ) : (
77+ <Empty>
78+ No fundamentals ingested for <code>frd:{symbol}</code>.
79+ </Empty>
80+ )}
81+ </Card>
82+ <Card title={`Coverage per dataset (${data.coverage.length})`} className="lg:col-span-2">
83+ <DataTable
84+ rows={data.coverage}
85+ rowKey={(c) => c.dataset_id}
86+ mobile="scroll"
87+ columns={[
88+ {
89+ key: "dataset_id",
90+ header: "Dataset",
91+ render: (c) => (
92+ <Link href={`/datasets/detail/?id=${encodeURIComponent(c.dataset_id)}`} className="font-mono text-xs text-accent-ink hover:underline">
93+ {c.dataset_id}
94+ </Link>
95+ ),
96+ },
97+ { key: "sessions", header: "Sessions", num: true, render: (c) => fmtInt(c.sessions) },
98+ { key: "bars", header: "Bars", num: true, render: (c) => <span title={fmtInt(c.bars)}>{fmtCompact(c.bars)}</span> },
99+ { key: "d0", header: "From" },
100+ { key: "d1", header: "To" },
101+ ]}
102+ empty="No bars for this symbol yet."
103+ />
104+ </Card>
105+ <Card title={`Corporate actions (${data.corporate_actions.length})`}>
106+ <DataTable
107+ rows={data.corporate_actions}
108+ rowKey={(a, i) => `${a.action_type}-${a.ex_date}-${i}`}
109+ mobile="scroll"
110+ maxHeight="24rem"
111+ columns={[
112+ { key: "ex_date", header: "Ex-date", render: (a) => <span className="tnum">{a.ex_date}</span> },
113+ { key: "action_type", header: "Type", render: (a) => <StatusBadge status="neutral" label={a.action_type} icon={false} /> },
114+ { key: "value", header: "Value", render: (a) => <JsonView value={a.value} collapsed /> },
115+ ]}
116+ empty="No splits or dividends recorded."
117+ />
118+ </Card>
119+ <Card title={`Aliases (${data.aliases.length})`}>
120+ {data.aliases.length === 0 ? (
121+ <Empty>No aliases.</Empty>
122+ ) : (
123+ <ul className="flex flex-col gap-1">
124+ {data.aliases.map((a, i) => (
125+ <li key={i}>
126+ <JsonView value={a} collapsed />
127+ </li>
128+ ))}
129+ </ul>
130+ )}
131+ </Card>
132+ </div>
133+ )}
134+ </>
135+ );
136+}
added apps/web/src/app/symbols/page.tsx +122 −0
@@ -0,0 +1,122 @@
1+"use client";
2+import Link from "next/link";
3+import { useEffect, useState } from "react";
4+import { Search } from "lucide-react";
5+import { api, type Instrument } from "@/lib/api";
6+import { useAsync, useDebounced } from "@/lib/hooks";
7+import { useToast } from "@/lib/toast";
8+import { fmtInt } from "@/lib/format";
9+import { Card, ErrorState, Field, PageHeader, StatusBadge } from "@/components/ui";
10+import { DataTable } from "@/components/DataTable";
11+
12+interface Out {
13+ data: Instrument[];
14+ meta: { total_instruments: number; active: number };
15+ next_cursor: string | null;
16+}
17+
18+export default function SymbolsPage() {
19+ const [q, setQ] = useState("");
20+ const [status, setStatus] = useState("");
21+ const [assetClass, setAssetClass] = useState("");
22+ const dq = useDebounced(q.trim(), 300);
23+ const [rows, setRows] = useState<Instrument[]>([]);
24+ const [cursor, setCursor] = useState<string | null>(null);
25+ const [more, setMore] = useState(false);
26+ const toast = useToast();
27+
28+ const { data, error, loading, reload } = useAsync((s) => api.get<Out>("/instruments", { query: dq, status, asset_class: assetClass, limit: 100 }, s), [dq, status, assetClass]);
29+
30+ useEffect(() => {
31+ if (data) {
32+ setRows(data.data);
33+ setCursor(data.next_cursor);
34+ }
35+ }, [data]);
36+
37+ async function loadMore() {
38+ if (!cursor) return;
39+ setMore(true);
40+ try {
41+ const out = await api.get<Out>("/instruments", { query: dq, status, asset_class: assetClass, limit: 100, cursor });
42+ setRows((r) => [...r, ...out.data]);
43+ setCursor(out.next_cursor);
44+ } catch (e) {
45+ toast.error(e);
46+ } finally {
47+ setMore(false);
48+ }
49+ }
50+
51+ return (
52+ <>
53+ <PageHeader title="Symbols" subtitle={data ? <>{fmtInt(data.meta.total_instruments)} instruments · {fmtInt(data.meta.active)} active</> : "Instrument master"} />
54+ <Card className="mb-4">
55+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
56+ <Field label="Search" className="flex-1">
57+ <div className="relative">
58+ <Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-ink-3" />
59+ <input className="input pl-8" placeholder="Symbol prefix or name…" value={q} onChange={(e) => setQ(e.target.value)} autoFocus />
60+ </div>
61+ </Field>
62+ <Field label="Status" className="sm:w-40">
63+ <select className="input" value={status} onChange={(e) => setStatus(e.target.value)}>
64+ <option value="">any</option>
65+ <option value="active">active</option>
66+ <option value="delisted">delisted</option>
67+ <option value="inactive">inactive</option>
68+ </select>
69+ </Field>
70+ <Field label="Asset class" className="sm:w-40">
71+ <select className="input" value={assetClass} onChange={(e) => setAssetClass(e.target.value)}>
72+ <option value="">any</option>
73+ <option value="stock">stock</option>
74+ <option value="etf">etf</option>
75+ <option value="index">index</option>
76+ <option value="crypto">crypto</option>
77+ <option value="fx">fx</option>
78+ <option value="futures">futures</option>
79+ </select>
80+ </Field>
81+ </div>
82+ </Card>
83+ {error && <ErrorState error={error} retry={reload} />}
84+ <Card>
85+ <DataTable
86+ rows={rows}
87+ loading={loading}
88+ rowKey={(r) => r.instrument_id}
89+ mobile="scroll"
90+ columns={[
91+ {
92+ key: "symbol",
93+ header: "Symbol",
94+ render: (r) => (
95+ <Link href={`/symbols/detail/?symbol=${encodeURIComponent(r.symbol)}`} className="font-mono font-semibold text-accent-ink hover:underline">
96+ {r.symbol}
97+ </Link>
98+ ),
99+ },
100+ { key: "name", header: "Name", render: (r) => <span className="line-clamp-1">{r.name ?? "—"}</span> },
101+ { key: "asset_class", header: "Class", hideSm: true },
102+ { key: "status", header: "Status", render: (r) => <StatusBadge status={r.status} /> },
103+ { key: "first_date", header: "First", hideSm: true, render: (r) => <span className="tnum">{r.first_date ?? "—"}</span> },
104+ { key: "last_date", header: "Last", hideSm: true, render: (r) => <span className="tnum">{r.last_date ?? "—"}</span> },
105+ { key: "currency", header: "Ccy", hideSm: true },
106+ ]}
107+ empty={dq ? `No instrument matches “${dq}”.` : "No instruments yet — run a metadata job."}
108+ footer={
109+ <>
110+ <span>{fmtInt(rows.length)} shown</span>
111+ {cursor && (
112+ <button className="btn btn-sm" onClick={loadMore} disabled={more}>
113+ {more ? "Loading…" : "Load more"}
114+ </button>
115+ )}
116+ </>
117+ }
118+ />
119+ </Card>
120+ </>
121+ );
122+}
added apps/web/src/components/ConfirmDialog.tsx +61 −0
@@ -0,0 +1,61 @@
1+"use client";
2+import { useEffect, useRef, type ReactNode } from "react";
3+import clsx from "clsx";
4+
5+interface Props {
6+ open: boolean;
7+ title: string;
8+ children?: ReactNode;
9+ confirmLabel?: string;
10+ danger?: boolean;
11+ busy?: boolean;
12+ onConfirm: () => void;
13+ onCancel: () => void;
14+}
15+
16+export function ConfirmDialog({ open, title, children, confirmLabel = "Confirm", danger, busy, onConfirm, onCancel }: Props) {
17+ const ref = useRef<HTMLDialogElement>(null);
18+ useEffect(() => {
19+ const d = ref.current;
20+ if (!d) return;
21+ if (open && !d.open) d.showModal();
22+ if (!open && d.open) d.close();
23+ }, [open]);
24+ return (
25+ <dialog ref={ref} onClose={onCancel} onCancel={onCancel} className="m-auto w-[min(92vw,28rem)] rounded-lg border border-border bg-surface-1 p-0 text-ink-1 shadow-xl">
26+ <div className="p-4">
27+ <h2 className="text-base font-semibold">{title}</h2>
28+ {children && <div className="mt-2 text-sm text-ink-2">{children}</div>}
29+ </div>
30+ <div className="flex justify-end gap-2 border-t border-hairline px-4 py-3">
31+ <button className="btn" onClick={onCancel} disabled={busy}>
32+ Cancel
33+ </button>
34+ <button className={clsx("btn", danger ? "btn-danger" : "btn-primary")} onClick={onConfirm} disabled={busy}>
35+ {busy ? "Working…" : confirmLabel}
36+ </button>
37+ </div>
38+ </dialog>
39+ );
40+}
41+
42+export function Modal({ open, title, children, onClose, wide }: { open: boolean; title: string; children: ReactNode; onClose: () => void; wide?: boolean }) {
43+ const ref = useRef<HTMLDialogElement>(null);
44+ useEffect(() => {
45+ const d = ref.current;
46+ if (!d) return;
47+ if (open && !d.open) d.showModal();
48+ if (!open && d.open) d.close();
49+ }, [open]);
50+ return (
51+ <dialog ref={ref} onClose={onClose} onCancel={onClose} className={clsx("m-auto rounded-lg border border-border bg-surface-1 p-0 text-ink-1 shadow-xl", wide ? "w-[min(96vw,56rem)]" : "w-[min(92vw,32rem)]")}>
52+ <div className="flex items-center justify-between border-b border-hairline px-4 py-3">
53+ <h2 className="text-base font-semibold">{title}</h2>
54+ <button className="btn btn-ghost btn-sm" onClick={onClose} aria-label="Close">
55+ ✕
56+ </button>
57+ </div>
58+ <div className="max-h-[80dvh] overflow-y-auto p-4">{children}</div>
59+ </dialog>
60+ );
61+}
added apps/web/src/components/CopyButton.tsx +25 −0
@@ -0,0 +1,25 @@
1+"use client";
2+import { useState } from "react";
3+import { Check, Copy } from "lucide-react";
4+
5+export function CopyButton({ text, label = "Copy", className }: { text: string; label?: string; className?: string }) {
6+ const [ok, setOk] = useState(false);
7+ return (
8+ <button
9+ type="button"
10+ className={className ?? "btn btn-sm"}
11+ onClick={async () => {
12+ try {
13+ await navigator.clipboard.writeText(text);
14+ setOk(true);
15+ setTimeout(() => setOk(false), 1500);
16+ } catch {
17+ /* clipboard denied */
18+ }
19+ }}
20+ >
21+ {ok ? <Check className="h-3.5 w-3.5 text-ok" /> : <Copy className="h-3.5 w-3.5" />}
22+ {ok ? "Copied" : label}
23+ </button>
24+ );
25+}
added apps/web/src/components/DataTable.tsx +88 −0
@@ -0,0 +1,88 @@
1+"use client";
2+import clsx from "clsx";
3+import type { ReactNode } from "react";
4+import { Empty, Skeleton } from "./ui";
5+
6+export interface Column<T> {
7+ key: string;
8+ header: ReactNode;
9+ render?: (row: T, i: number) => ReactNode;
10+ num?: boolean;
11+ className?: string;
12+ /** hide below `sm` to save width on phones; still visible in the card view */
13+ hideSm?: boolean;
14+ mono?: boolean;
15+}
16+
17+interface Props<T> {
18+ columns: Column<T>[];
19+ rows: T[] | null | undefined;
20+ rowKey: (row: T, i: number) => string | number;
21+ loading?: boolean;
22+ empty?: ReactNode;
23+ /** Card layout under 640px (default) or always horizontal scroll */
24+ mobile?: "cards" | "scroll";
25+ maxHeight?: string;
26+ onRowClick?: (row: T) => void;
27+ footer?: ReactNode;
28+}
29+
30+function cell<T>(c: Column<T>, row: T, i: number): ReactNode {
31+ if (c.render) return c.render(row, i);
32+ const v = (row as Record<string, unknown>)[c.key];
33+ if (v === null || v === undefined) return <span className="text-ink-3">—</span>;
34+ if (typeof v === "object") return <code className="text-xs">{JSON.stringify(v)}</code>;
35+ return String(v);
36+}
37+
38+export function DataTable<T>({ columns, rows, rowKey, loading, empty, mobile = "cards", maxHeight, onRowClick, footer }: Props<T>) {
39+ if (loading && !rows) return <Skeleton rows={5} />;
40+ if (!rows || rows.length === 0) return <Empty>{empty ?? "No rows."}</Empty>;
41+ return (
42+ <div className={clsx(loading && "opacity-60 transition-opacity")}>
43+ {/* Cards (mobile) */}
44+ {mobile === "cards" && (
45+ <ul className="flex flex-col gap-2 sm:hidden">
46+ {rows.map((r, i) => (
47+ <li key={rowKey(r, i)} className={clsx("rounded-md border border-border bg-surface-1 p-3", onRowClick && "cursor-pointer active:bg-surface-2")} onClick={onRowClick ? () => onRowClick(r) : undefined}>
48+ <dl className="grid grid-cols-[minmax(0,40%)_1fr] gap-x-3 gap-y-1 text-sm">
49+ {columns.map((c) => (
50+ <div key={c.key} className="contents">
51+ <dt className="truncate text-xs text-ink-3">{c.header}</dt>
52+ <dd className={clsx("min-w-0 break-words", c.mono && "font-mono text-xs", c.num && "tnum")}>{cell(c, r, i)}</dd>
53+ </div>
54+ ))}
55+ </dl>
56+ </li>
57+ ))}
58+ </ul>
59+ )}
60+ {/* Table */}
61+ <div className={clsx("table-wrap", mobile === "cards" && "hidden sm:block")} style={maxHeight ? { maxHeight, overflowY: "auto" } : undefined}>
62+ <table className="table">
63+ <thead>
64+ <tr>
65+ {columns.map((c) => (
66+ <th key={c.key} className={clsx(c.num && "text-right", c.hideSm && "hidden md:table-cell", c.className)}>
67+ {c.header}
68+ </th>
69+ ))}
70+ </tr>
71+ </thead>
72+ <tbody>
73+ {rows.map((r, i) => (
74+ <tr key={rowKey(r, i)} className={clsx(onRowClick && "cursor-pointer")} onClick={onRowClick ? () => onRowClick(r) : undefined}>
75+ {columns.map((c) => (
76+ <td key={c.key} className={clsx(c.num && "num", c.mono && "font-mono text-xs", c.hideSm && "hidden md:table-cell", c.className)}>
77+ {cell(c, r, i)}
78+ </td>
79+ ))}
80+ </tr>
81+ ))}
82+ </tbody>
83+ </table>
84+ </div>
85+ {footer && <div className="mt-2 flex items-center justify-between gap-2 text-xs text-ink-3">{footer}</div>}
86+ </div>
87+ );
88+}
added apps/web/src/components/DateRange.tsx +35 −0
@@ -0,0 +1,35 @@
1+"use client";
2+import clsx from "clsx";
3+import { daysAgo, today } from "@/lib/format";
4+import { Field } from "./ui";
5+
6+interface Props {
7+ start: string;
8+ end: string;
9+ onChange: (start: string, end: string) => void;
10+ presets?: number[]; // days
11+ className?: string;
12+}
13+
14+export function DateRange({ start, end, onChange, presets = [7, 30, 90, 120, 365], className }: Props) {
15+ return (
16+ <div className={clsx("flex flex-wrap items-end gap-2", className)}>
17+ <Field label="Start" className="w-[10.5rem]">
18+ <input type="date" className="input" value={start} max={end || undefined} onChange={(e) => onChange(e.target.value, end)} />
19+ </Field>
20+ <Field label="End" className="w-[10.5rem]">
21+ <input type="date" className="input" value={end} min={start || undefined} onChange={(e) => onChange(start, e.target.value)} />
22+ </Field>
23+ <div className="flex flex-wrap gap-1">
24+ {presets.map((d) => {
25+ const active = start === daysAgo(d) && end === today();
26+ return (
27+ <button key={d} type="button" className={clsx("btn btn-sm", active && "border-accent bg-accent-soft text-accent-ink")} onClick={() => onChange(daysAgo(d), today())}>
28+ {d} d
29+ </button>
30+ );
31+ })}
32+ </div>
33+ </div>
34+ );
35+}
added apps/web/src/components/JsonView.tsx +28 −0
@@ -0,0 +1,28 @@
1+"use client";
2+import { useState } from "react";
3+import clsx from "clsx";
4+
5+export function JsonView({ value, collapsed = false, className, maxHeight = "20rem" }: { value: unknown; collapsed?: boolean; className?: string; maxHeight?: string }) {
6+ const [open, setOpen] = useState(!collapsed);
7+ if (value === null || value === undefined) return <span className="text-ink-3">—</span>;
8+ const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
9+ const short = text.length > 80 ? text.slice(0, 80) + "…" : text;
10+ if (!open)
11+ return (
12+ <button className="font-mono text-xs text-ink-2 underline-offset-2 hover:underline text-left break-all" onClick={() => setOpen(true)} title="Expand">
13+ {short.replace(/\s+/g, " ")}
14+ </button>
15+ );
16+ return (
17+ <div className={clsx("relative", className)}>
18+ <pre className="overflow-auto rounded-md border border-hairline bg-surface-2 p-2 font-mono text-xs leading-relaxed text-ink-1" style={{ maxHeight }}>
19+ {text}
20+ </pre>
21+ {collapsed && (
22+ <button className="absolute right-1 top-1 btn btn-ghost btn-sm" onClick={() => setOpen(false)}>
23+ collapse
24+ </button>
25+ )}
26+ </div>
27+ );
28+}
added apps/web/src/components/Providers.tsx +16 −0
@@ -0,0 +1,16 @@
1+"use client";
2+import type { ReactNode } from "react";
3+import { AuthProvider } from "@/lib/auth";
4+import { ToastProvider } from "@/lib/toast";
5+import { Toaster } from "./Toast";
6+
7+export function Providers({ children }: { children: ReactNode }) {
8+ return (
9+ <ToastProvider>
10+ <AuthProvider>
11+ {children}
12+ <Toaster />
13+ </AuthProvider>
14+ </ToastProvider>
15+ );
16+}
added apps/web/src/components/Shell.tsx +160 −0
@@ -0,0 +1,160 @@
1+"use client";
2+import { useEffect, useState, type ReactNode } from "react";
3+import Link from "next/link";
4+import { usePathname } from "next/navigation";
5+import clsx from "clsx";
6+import { LogOut, Menu, MoreHorizontal, X } from "lucide-react";
7+import { useAuth } from "@/lib/auth";
8+import { MOBILE_TABS, NAV } from "./nav";
9+
10+function normalize(p: string | null): string {
11+ if (!p) return "/";
12+ const s = p.endsWith("/") ? p : p + "/";
13+ return s;
14+}
15+
16+function isCurrent(href: string, path: string): boolean {
17+ if (href === "/") return path === "/";
18+ return path.startsWith(href);
19+}
20+
21+export function Shell({ children }: { children: ReactNode }) {
22+ const { me, loading, logout, isOwner } = useAuth();
23+ const pathname = normalize(usePathname());
24+ const [open, setOpen] = useState(false);
25+ const isLogin = pathname === "/login/";
26+
27+ useEffect(() => setOpen(false), [pathname]);
28+
29+ if (isLogin) return <main className="min-h-dvh">{children}</main>;
30+
31+ if (loading || !me || !me.mfa_passed) {
32+ return (
33+ <main className="flex min-h-dvh items-center justify-center p-6">
34+ <div className="flex items-center gap-3 text-ink-2">
35+ <span className="h-3 w-3 animate-pulse rounded-full bg-accent" aria-hidden />
36+ {loading ? "Checking session…" : "Redirecting to sign-in…"}
37+ </div>
38+ </main>
39+ );
40+ }
41+
42+ const items = NAV.filter((n) => !n.owner || isOwner);
43+ const groups: { key: "data" | "ops" | "system"; label: string }[] = [
44+ { key: "data", label: "Data" },
45+ { key: "ops", label: "Operations" },
46+ { key: "system", label: "System" },
47+ ];
48+ const tabs = items.filter((n) => MOBILE_TABS.includes(n.href)).slice(0, 4);
49+
50+ const NavList = (
51+ <nav className="flex flex-col gap-4" aria-label="Main">
52+ {groups.map((g) => {
53+ const gi = items.filter((n) => n.group === g.key);
54+ if (!gi.length) return null;
55+ return (
56+ <div key={g.key}>
57+ <div className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-ink-3">{g.label}</div>
58+ <ul className="flex flex-col gap-0.5">
59+ {gi.map((n) => (
60+ <li key={n.href}>
61+ <Link href={n.href} className="nav-link" aria-current={isCurrent(n.href, pathname) ? "page" : undefined}>
62+ <n.icon className="h-4 w-4 shrink-0" aria-hidden />
63+ <span>{n.label}</span>
64+ </Link>
65+ </li>
66+ ))}
67+ </ul>
68+ </div>
69+ );
70+ })}
71+ </nav>
72+ );
73+
74+ const Account = (
75+ <div className="border-t border-hairline pt-3">
76+ <div className="truncate px-2.5 text-xs text-ink-2" title={me.email ?? ""}>
77+ {me.email}
78+ </div>
79+ <div className="flex items-center justify-between px-2.5">
80+ <span className="pill bg-surface-2 text-ink-2">{me.role}</span>
81+ <button className="btn btn-ghost btn-sm" onClick={() => void logout()}>
82+ <LogOut className="h-3.5 w-3.5" /> Sign out
83+ </button>
84+ </div>
85+ </div>
86+ );
87+
88+ return (
89+ <div className="min-h-dvh lg:grid lg:grid-cols-[232px_1fr]">
90+ {/* Sidebar (desktop) */}
91+ <aside className="sticky top-0 hidden h-dvh flex-col gap-4 overflow-y-auto border-r border-hairline bg-surface-1 p-3 lg:flex">
92+ <Link href="/" className="flex items-center gap-2 px-2.5 py-1">
93+ <Logo />
94+ <span className="text-sm font-semibold tracking-tight">FinData OS</span>
95+ </Link>
96+ <div className="flex-1">{NavList}</div>
97+ {Account}
98+ </aside>
99+
100+ {/* Top bar (mobile/tablet) */}
101+ <header className="sticky top-0 z-30 flex items-center justify-between border-b border-hairline bg-surface-1/95 px-3 py-2 backdrop-blur lg:hidden">
102+ <Link href="/" className="flex items-center gap-2">
103+ <Logo />
104+ <span className="text-sm font-semibold">FinData OS</span>
105+ </Link>
106+ <button className="btn btn-ghost px-2" aria-label="Open menu" aria-expanded={open} onClick={() => setOpen(true)}>
107+ <Menu className="h-5 w-5" />
108+ </button>
109+ </header>
110+
111+ {/* Drawer */}
112+ {open && (
113+ <div className="fixed inset-0 z-40 lg:hidden" role="dialog" aria-modal="true" aria-label="Navigation">
114+ <button className="absolute inset-0 bg-black/45" aria-label="Close menu" onClick={() => setOpen(false)} />
115+ <div className="absolute inset-y-0 right-0 flex w-[82%] max-w-xs flex-col gap-4 overflow-y-auto bg-surface-1 p-3 shadow-xl">
116+ <div className="flex items-center justify-between px-2.5">
117+ <span className="text-sm font-semibold">Menu</span>
118+ <button className="btn btn-ghost px-2" aria-label="Close" onClick={() => setOpen(false)}>
119+ <X className="h-5 w-5" />
120+ </button>
121+ </div>
122+ <div className="flex-1">{NavList}</div>
123+ {Account}
124+ </div>
125+ </div>
126+ )}
127+
128+ {/* Content */}
129+ <main className="min-w-0 px-3 pb-20 pt-3 sm:px-5 sm:pt-4 lg:px-8 lg:pb-8 lg:pt-6">
130+ <div className="mx-auto max-w-[1400px]">{children}</div>
131+ </main>
132+
133+ {/* Bottom tab bar (mobile) */}
134+ <nav className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-hairline bg-surface-1/95 pb-[env(safe-area-inset-bottom)] backdrop-blur lg:hidden" aria-label="Quick">
135+ {tabs.map((n) => {
136+ const cur = isCurrent(n.href, pathname);
137+ return (
138+ <Link key={n.href} href={n.href} className={clsx("flex flex-col items-center gap-0.5 py-2 text-[11px]", cur ? "text-accent-ink" : "text-ink-2")} aria-current={cur ? "page" : undefined}>
139+ <n.icon className="h-5 w-5" aria-hidden />
140+ {n.label.split(" ")[0]}
141+ </Link>
142+ );
143+ })}
144+ <button className="flex flex-col items-center gap-0.5 py-2 text-[11px] text-ink-2" onClick={() => setOpen(true)}>
145+ <MoreHorizontal className="h-5 w-5" aria-hidden />
146+ More
147+ </button>
148+ </nav>
149+ </div>
150+ );
151+}
152+
153+function Logo() {
154+ return (
155+ <svg viewBox="0 0 32 32" className="h-6 w-6" aria-hidden>
156+ <rect width="32" height="32" rx="7" fill="var(--accent)" />
157+ <path d="M8 22V10h4v12zm6 0V14h4v8zm6 0v-5h4v5z" fill="#fff" />
158+ </svg>
159+ );
160+}
added apps/web/src/components/Toast.tsx +40 −0
@@ -0,0 +1,40 @@
1+"use client";
2+import { AlertTriangle, CheckCircle2, Info, X, XCircle } from "lucide-react";
3+import clsx from "clsx";
4+import { useToast, type ToastKind } from "@/lib/toast";
5+
6+const ICON: Record<ToastKind, typeof Info> = { info: Info, success: CheckCircle2, warning: AlertTriangle, error: XCircle };
7+const STYLE: Record<ToastKind, string> = {
8+ info: "border-accent/40 bg-surface-1",
9+ success: "border-ok/50 bg-surface-1",
10+ warning: "border-warn/60 bg-surface-1",
11+ error: "border-crit/60 bg-surface-1",
12+};
13+const ICON_STYLE: Record<ToastKind, string> = { info: "text-accent", success: "text-ok", warning: "text-warn-ink", error: "text-crit" };
14+
15+export function Toaster() {
16+ const { toasts, dismiss } = useToast();
17+ if (!toasts.length) return null;
18+ return (
19+ <div className="pointer-events-none fixed inset-x-0 bottom-16 z-50 flex flex-col items-center gap-2 px-3 sm:bottom-4 sm:items-end sm:pr-4" aria-live="polite">
20+ {toasts.map((t) => {
21+ const Icon = ICON[t.kind];
22+ return (
23+ <div key={t.id} role="status" className={clsx("pointer-events-auto flex w-full max-w-sm items-start gap-2 rounded-lg border p-3 shadow-lg", STYLE[t.kind])}>
24+ <Icon className={clsx("mt-0.5 h-4 w-4 shrink-0", ICON_STYLE[t.kind])} aria-hidden />
25+ <div className="min-w-0 flex-1">
26+ <div className="text-sm font-medium break-words">{t.title}</div>
27+ {t.detail && <div className="text-xs text-ink-2 break-words">{t.detail}</div>}
28+ {t.requestId && (
29+ <div className="mt-0.5 font-mono text-[11px] text-ink-3">request_id {t.requestId}</div>
30+ )}
31+ </div>
32+ <button className="btn btn-ghost btn-sm -mr-1 -mt-1 px-1" onClick={() => dismiss(t.id)} aria-label="Dismiss">
33+ <X className="h-3.5 w-3.5" />
34+ </button>
35+ </div>
36+ );
37+ })}
38+ </div>
39+ );
40+}
added apps/web/src/components/charts.tsx +274 −0
@@ -0,0 +1,274 @@
1+"use client";
2+import { useMemo, useState } from "react";
3+import clsx from "clsx";
4+import { Area, Bar, BarChart, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
5+import { fmtCompact, fmtInt, fmtNum, fmtPct } from "@/lib/format";
6+
7+/* Tooltip box styled with tokens (values lead, labels follow). */
8+function TipBox({ title, rows }: { title: string; rows: { label: string; value: string; color?: string }[] }) {
9+ return (
10+ <div className="rounded-md border border-border bg-surface-1 px-2.5 py-2 text-xs shadow-md">
11+ <div className="mb-1 text-ink-3">{title}</div>
12+ {rows.map((r) => (
13+ <div key={r.label} className="flex items-center justify-between gap-4">
14+ <span className="flex items-center gap-1.5 text-ink-2">
15+ {r.color && <span className="inline-block h-0.5 w-3 rounded" style={{ background: r.color }} aria-hidden />}
16+ {r.label}
17+ </span>
18+ <span className="font-semibold tnum text-ink-1">{r.value}</span>
19+ </div>
20+ ))}
21+ </div>
22+ );
23+}
24+
25+type TipPayload = { payload?: Record<string, unknown>; dataKey?: string | number; value?: unknown }[];
26+
27+// ---------------------------------------------------------------- price (close) line
28+export function CloseLineChart({ data, height = 240 }: { data: { t: string; close: number; label: string }[]; height?: number }) {
29+ if (!data.length) return null;
30+ return (
31+ <div className="viz" style={{ height }}>
32+ <ResponsiveContainer width="100%" height="100%">
33+ <ComposedChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 0 }}>
34+ <CartesianGrid vertical={false} />
35+ <XAxis dataKey="label" tickLine={false} minTickGap={32} />
36+ <YAxis domain={["auto", "auto"]} tickLine={false} axisLine={false} width={56} tickFormatter={(v) => fmtNum(Number(v), 2)} />
37+ <Tooltip
38+ cursor={{ stroke: "var(--axis)" }}
39+ content={({ active, payload }: { active?: boolean; payload?: TipPayload }) => {
40+ if (!active || !payload?.length) return null;
41+ const p = payload[0].payload as { t: string; close: number; open?: number; high?: number; low?: number; volume?: number };
42+ return (
43+ <TipBox
44+ title={p.t}
45+ rows={[
46+ { label: "close", value: fmtNum(p.close, 4), color: "var(--series-1)" },
47+ ...(p.open !== undefined ? [{ label: "open", value: fmtNum(p.open, 4) }] : []),
48+ ...(p.high !== undefined ? [{ label: "high", value: fmtNum(p.high, 4) }] : []),
49+ ...(p.low !== undefined ? [{ label: "low", value: fmtNum(p.low, 4) }] : []),
50+ ...(p.volume !== undefined ? [{ label: "volume", value: fmtInt(p.volume) }] : []),
51+ ]}
52+ />
53+ );
54+ }}
55+ />
56+ <Area type="monotone" dataKey="close" stroke="none" fill="var(--series-1)" fillOpacity={0.1} isAnimationActive={false} />
57+ <Line type="monotone" dataKey="close" stroke="var(--series-1)" strokeWidth={2} dot={false} activeDot={{ r: 4, stroke: "var(--surface-1)", strokeWidth: 2 }} isAnimationActive={false} />
58+ </ComposedChart>
59+ </ResponsiveContainer>
60+ </div>
61+ );
62+}
63+
64+// ---------------------------------------------------------------- volume bars
65+export function VolumeBarChart({ data, height = 110, color = "var(--series-1)", valueKey = "volume", title = "volume" }: { data: { label: string; [k: string]: unknown }[]; height?: number; color?: string; valueKey?: string; title?: string }) {
66+ if (!data.length) return null;
67+ return (
68+ <div style={{ height }}>
69+ <ResponsiveContainer width="100%" height="100%">
70+ <BarChart data={data} margin={{ top: 4, right: 12, bottom: 4, left: 0 }} barCategoryGap={2}>
71+ <CartesianGrid vertical={false} />
72+ <XAxis dataKey="label" tickLine={false} minTickGap={32} hide />
73+ <YAxis tickLine={false} axisLine={false} width={56} tickFormatter={(v) => fmtCompact(Number(v))} />
74+ <Tooltip
75+ cursor={{ fill: "var(--surface-3)" }}
76+ content={({ active, payload }: { active?: boolean; payload?: TipPayload }) => {
77+ if (!active || !payload?.length) return null;
78+ const p = payload[0].payload as Record<string, unknown>;
79+ return <TipBox title={String(p.t ?? p.label)} rows={[{ label: title, value: fmtInt(Number(p[valueKey])), color }]} />;
80+ }}
81+ />
82+ <Bar dataKey={valueKey} fill={color} maxBarSize={24} radius={[3, 3, 0, 0]} isAnimationActive={false} />
83+ </BarChart>
84+ </ResponsiveContainer>
85+ </div>
86+ );
87+}
88+
89+// ---------------------------------------------------------------- generic horizontal bars (counts by category)
90+export function CategoryBars({ data, height, color = "var(--series-1)", format = fmtInt }: { data: { name: string; value: number }[]; height?: number; color?: string; format?: (n: number) => string }) {
91+ if (!data.length) return null;
92+ const h = height ?? Math.max(80, data.length * 28 + 16);
93+ return (
94+ <div style={{ height: h }}>
95+ <ResponsiveContainer width="100%" height="100%">
96+ <BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 4, left: 8 }} barCategoryGap={4}>
97+ <CartesianGrid horizontal={false} />
98+ <XAxis type="number" tickLine={false} axisLine={false} tickFormatter={(v) => fmtCompact(Number(v))} />
99+ <YAxis type="category" dataKey="name" width={130} tickLine={false} axisLine={false} tick={{ fontSize: 11 }} />
100+ <Tooltip
101+ cursor={{ fill: "var(--surface-3)" }}
102+ content={({ active, payload }: { active?: boolean; payload?: TipPayload }) => {
103+ if (!active || !payload?.length) return null;
104+ const p = payload[0].payload as { name: string; value: number };
105+ return <TipBox title={p.name} rows={[{ label: "count", value: format(p.value), color }]} />;
106+ }}
107+ />
108+ <Bar dataKey="value" fill={color} maxBarSize={20} radius={[0, 3, 3, 0]} isAnimationActive={false} label={{ position: "right", fill: "var(--ink-2)", fontSize: 11, formatter: (v: unknown) => format(Number(v)) }} />
109+ </BarChart>
110+ </ResponsiveContainer>
111+ </div>
112+ );
113+}
114+
115+// ---------------------------------------------------------------- completeness heatmap (calendar cells)
116+const SEQ = ["--seq-100", "--seq-200", "--seq-300", "--seq-400", "--seq-500", "--seq-600", "--seq-700"];
117+export function seqColor(x: number | null | undefined): string {
118+ if (x === null || x === undefined || Number.isNaN(x)) return "var(--surface-3)";
119+ const i = Math.min(SEQ.length - 1, Math.max(0, Math.floor(Math.max(0, Math.min(1, x)) * SEQ.length)));
120+ return `var(${SEQ[i]})`;
121+}
122+
123+export interface HeatCell {
124+ session_date: string;
125+ completeness: number | null;
126+ symbols: number;
127+ bars: number;
128+}
129+
130+export function CompletenessHeatmap({ cells, start, end }: { cells: HeatCell[]; start: string; end: string }) {
131+ const [hover, setHover] = useState<HeatCell | null>(null);
132+ // Build week columns Mon..Fri (sessions are weekdays). Missing day = no data.
133+ const { weeks, byDate } = useMemo(() => {
134+ const map = new Map(cells.map((c) => [c.session_date, c]));
135+ const s = new Date(start + "T00:00:00Z");
136+ const e = new Date(end + "T00:00:00Z");
137+ // align start to Monday
138+ const dow = (s.getUTCDay() + 6) % 7; // Mon=0
139+ s.setUTCDate(s.getUTCDate() - dow);
140+ const weeks: string[][] = [];
141+ for (let d = new Date(s); d <= e; ) {
142+ const w: string[] = [];
143+ for (let i = 0; i < 7; i++) {
144+ w.push(d.toISOString().slice(0, 10));
145+ d.setUTCDate(d.getUTCDate() + 1);
146+ }
147+ weeks.push(w.slice(0, 5)); // Mon–Fri
148+ }
149+ return { weeks, byDate: map };
150+ }, [cells, start, end]);
151+
152+ const cell = 14;
153+ const gap = 2;
154+ const w = weeks.length * (cell + gap) + 28;
155+ const h = 5 * (cell + gap) + 18;
156+ const days = ["Mon", "Tue", "Wed", "Thu", "Fri"];
157+
158+ return (
159+ <div>
160+ <div className="overflow-x-auto">
161+ <svg width={w} height={h} role="img" aria-label="Completeness per session" className="block">
162+ {days.map((d, i) => (
163+ <text key={d} x={0} y={18 + i * (cell + gap) + cell - 3} fontSize={9} fill="var(--ink-3)">
164+ {d}
165+ </text>
166+ ))}
167+ {weeks.map((wk, wi) => {
168+ const first = wk[0];
169+ const showMonth = first.slice(8, 10) <= "07";
170+ return (
171+ <g key={wi} transform={`translate(${28 + wi * (cell + gap)}, 0)`}>
172+ {showMonth && (
173+ <text x={0} y={9} fontSize={9} fill="var(--ink-3)">
174+ {first.slice(0, 7)}
175+ </text>
176+ )}
177+ {wk.map((date, di) => {
178+ const c = byDate.get(date);
179+ const inRange = date >= start && date <= end;
180+ if (!inRange) return null;
181+ return (
182+ <rect
183+ key={date}
184+ x={0}
185+ y={18 + di * (cell + gap)}
186+ width={cell}
187+ height={cell}
188+ rx={2}
189+ fill={c ? seqColor(c.completeness ?? (c.bars > 0 ? 1 : 0)) : "var(--surface-2)"}
190+ stroke={hover?.session_date === date ? "var(--ink-1)" : "none"}
191+ strokeWidth={1.5}
192+ tabIndex={c ? 0 : -1}
193+ onMouseEnter={() => c && setHover(c)}
194+ onFocus={() => c && setHover(c)}
195+ onMouseLeave={() => setHover(null)}
196+ onBlur={() => setHover(null)}
197+ >
198+ <title>{c ? `${date}: ${fmtPct(c.completeness)} · ${fmtInt(c.symbols)} symbols · ${fmtInt(c.bars)} bars` : `${date}: no data`}</title>
199+ </rect>
200+ );
201+ })}
202+ </g>
203+ );
204+ })}
205+ </svg>
206+ </div>
207+ <div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-ink-3">
208+ <div className="flex items-center gap-1">
209+ <span>0 %</span>
210+ {SEQ.map((s) => (
211+ <span key={s} className="inline-block h-3 w-3 rounded-sm" style={{ background: `var(${s})` }} aria-hidden />
212+ ))}
213+ <span>100 %</span>
214+ <span className="ml-2 inline-block h-3 w-3 rounded-sm bg-surface-2 border border-hairline" aria-hidden /> <span>no data</span>
215+ </div>
216+ <div className="tnum min-h-[1.2em]">
217+ {hover ? (
218+ <>
219+ <span className="font-medium text-ink-1">{hover.session_date}</span> · {fmtPct(hover.completeness)} · {fmtInt(hover.symbols)} symbols · {fmtCompact(hover.bars)} bars
220+ </>
221+ ) : (
222+ "hover a cell"
223+ )}
224+ </div>
225+ </div>
226+ </div>
227+ );
228+}
229+
230+// ---------------------------------------------------------------- meter (storage gauge)
231+export function Meter({ value, max, marks = [], tone = "accent", label, className }: { value: number; max: number; marks?: { at: number; label: string }[]; tone?: "accent" | "warn" | "crit" | "ok"; label?: string; className?: string }) {
232+ const pct = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
233+ const fill = { accent: "var(--accent)", warn: "var(--warn)", crit: "var(--crit)", ok: "var(--ok)" }[tone];
234+ const track = { accent: "var(--accent-soft)", warn: "var(--warn-soft)", crit: "var(--crit-soft)", ok: "var(--ok-soft)" }[tone];
235+ return (
236+ <div className={clsx("relative", className)} role="meter" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max} aria-label={label}>
237+ <div className="h-3 w-full overflow-hidden rounded-full" style={{ background: track }}>
238+ <div className="h-full rounded-full" style={{ width: `${pct * 100}%`, background: fill }} />
239+ </div>
240+ {marks.map((m) => {
241+ const x = max > 0 ? Math.max(0, Math.min(1, m.at / max)) : 0;
242+ return (
243+ <div key={m.label} className="absolute top-[-4px] flex flex-col items-center" style={{ left: `${x * 100}%`, transform: "translateX(-50%)" }}>
244+ <div className="h-5 w-px bg-ink-2" />
245+ <div className="mt-0.5 whitespace-nowrap text-[10px] text-ink-3">{m.label}</div>
246+ </div>
247+ );
248+ })}
249+ </div>
250+ );
251+}
252+
253+// ---------------------------------------------------------------- segmented bar (one whole split in parts)
254+export function SegmentedBar({ parts, total, height = 8 }: { parts: { label: string; value: number; color: string }[]; total?: number; height?: number }) {
255+ const sum = total ?? parts.reduce((a, p) => a + p.value, 0);
256+ if (sum <= 0) return <div className="h-2 w-full rounded-full bg-surface-3" />;
257+ return (
258+ <div>
259+ <div className="flex w-full gap-0.5 overflow-hidden rounded-full" style={{ height }}>
260+ {parts.filter((p) => p.value > 0).map((p) => (
261+ <div key={p.label} style={{ width: `${(p.value / sum) * 100}%`, background: p.color }} title={`${p.label}: ${fmtPct(p.value / sum)}`} />
262+ ))}
263+ </div>
264+ <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">
265+ {parts.map((p) => (
266+ <li key={p.label} className="flex items-center gap-1.5">
267+ <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: p.color }} aria-hidden />
268+ {p.label}
269+ </li>
270+ ))}
271+ </ul>
272+ </div>
273+ );
274+}
added apps/web/src/components/nav.ts +32 −0
@@ -0,0 +1,32 @@
1+import {
2+ Activity, BarChart3, BookOpen, CalendarRange, Database, Download, FileSearch, HardDrive,
3+ KeyRound, LayoutDashboard, ListTree, ScrollText, Settings, ShieldCheck, Terminal, type LucideIcon,
4+} from "lucide-react";
5+
6+export interface NavItem {
7+ href: string;
8+ label: string;
9+ icon: LucideIcon;
10+ owner?: boolean; // owner/admin only
11+ group: "data" | "ops" | "system";
12+}
13+
14+export const NAV: NavItem[] = [
15+ { href: "/", label: "Overview", icon: LayoutDashboard, group: "data" },
16+ { href: "/datasets/", label: "Datasets", icon: Database, group: "data" },
17+ { href: "/symbols/", label: "Symbols", icon: ListTree, group: "data" },
18+ { href: "/coverage/", label: "Coverage", icon: CalendarRange, group: "data" },
19+ { href: "/bars/", label: "Bars", icon: BarChart3, group: "data" },
20+ { href: "/fundamentals/", label: "Fundamentals", icon: FileSearch, group: "data" },
21+ { href: "/playground/", label: "SQL playground", icon: Terminal, group: "data" },
22+ { href: "/exports/", label: "Exports", icon: Download, group: "data" },
23+ { href: "/quality/", label: "Quality", icon: ShieldCheck, group: "ops" },
24+ { href: "/ingestion/", label: "Ingestion", icon: Activity, group: "ops", owner: true },
25+ { href: "/storage/", label: "Storage", icon: HardDrive, group: "ops", owner: true },
26+ { href: "/keys/", label: "API keys", icon: KeyRound, group: "system", owner: true },
27+ { href: "/audit/", label: "Audit log", icon: ScrollText, group: "system", owner: true },
28+ { href: "/settings/", label: "Settings", icon: Settings, group: "system" },
29+ { href: "/docs/", label: "Docs", icon: BookOpen, group: "system" },
30+];
31+
32+export const MOBILE_TABS = ["/", "/datasets/", "/bars/", "/ingestion/", "/exports/"];
added apps/web/src/components/ui.tsx +220 −0
@@ -0,0 +1,220 @@
1+"use client";
2+import clsx from "clsx";
3+import { AlertTriangle, CheckCircle2, CircleDashed, Info, XCircle, type LucideIcon } from "lucide-react";
4+import Link from "next/link";
5+import type { ReactNode } from "react";
6+import { isApiError } from "@/lib/api";
7+
8+// ---------------------------------------------------------------- page header
9+export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {
10+ return (
11+ <div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
12+ <div>
13+ <h1 className="section-title">{title}</h1>
14+ {subtitle && <p className="mt-0.5 text-sm text-ink-2">{subtitle}</p>}
15+ </div>
16+ {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
17+ </div>
18+ );
19+}
20+
21+export function Card({ title, children, className, actions, id }: { title?: ReactNode; children: ReactNode; className?: string; actions?: ReactNode; id?: string }) {
22+ return (
23+ <section className={clsx("card", className)} id={id}>
24+ {(title || actions) && (
25+ <div className="card-title">
26+ <div>{title}</div>
27+ {actions && <div className="flex items-center gap-2">{actions}</div>}
28+ </div>
29+ )}
30+ {children}
31+ </section>
32+ );
33+}
34+
35+// ---------------------------------------------------------------- status badge
36+type Tone = "ok" | "warn" | "serious" | "crit" | "neutral" | "accent";
37+
38+const TONE: Record<Tone, string> = {
39+ ok: "bg-ok-soft text-ok-ink",
40+ warn: "bg-warn-soft text-warn-ink",
41+ serious: "bg-serious-soft text-serious-ink",
42+ crit: "bg-crit-soft text-crit-ink",
43+ neutral: "bg-surface-2 text-ink-2",
44+ accent: "bg-accent-soft text-accent-ink",
45+};
46+const TONE_ICON: Record<Tone, LucideIcon> = { ok: CheckCircle2, warn: AlertTriangle, serious: AlertTriangle, crit: XCircle, neutral: CircleDashed, accent: Info };
47+
48+export function toneFor(status: string | null | undefined): Tone {
49+ switch ((status ?? "").toLowerCase()) {
50+ case "ok":
51+ case "active":
52+ case "succeeded":
53+ case "ready":
54+ case "running":
55+ case "verified":
56+ case "allowed":
57+ case "success":
58+ return status?.toLowerCase() === "running" ? "accent" : "ok";
59+ case "warning":
60+ case "planned":
61+ case "queued":
62+ case "paused":
63+ case "pending_configuration":
64+ case "pending":
65+ case "stale":
66+ case "expired":
67+ case "cancelled":
68+ return "warn";
69+ case "degraded":
70+ case "failed":
71+ case "error":
72+ case "dead":
73+ case "denied":
74+ case "unsupported":
75+ case "critical":
76+ return "crit";
77+ case "serious":
78+ case "high":
79+ return "serious";
80+ default:
81+ return "neutral";
82+ }
83+}
84+
85+export function StatusBadge({ status, label, icon = true, className }: { status: string | null | undefined; label?: string; icon?: boolean; className?: string }) {
86+ const tone = toneFor(status);
87+ const Icon = TONE_ICON[tone];
88+ const text = label ?? (status ?? "unknown").replace(/_/g, " ");
89+ return (
90+ <span className={clsx("pill", TONE[tone], className)}>
91+ {icon && <Icon className="h-3 w-3" aria-hidden />}
92+ {text}
93+ </span>
94+ );
95+}
96+
97+export function SeverityBadge({ severity }: { severity: string | null | undefined }) {
98+ const s = (severity ?? "").toLowerCase();
99+ const tone: Tone = s === "error" || s === "critical" || s === "high" ? "crit" : s === "warning" || s === "medium" ? "warn" : s === "info" || s === "low" ? "accent" : "neutral";
100+ const Icon = TONE_ICON[tone];
101+ return (
102+ <span className={clsx("pill", TONE[tone])}>
103+ <Icon className="h-3 w-3" aria-hidden />
104+ {s || "—"}
105+ </span>
106+ );
107+}
108+
109+// ---------------------------------------------------------------- KPI tile
110+export function KpiTile({ label, value, sub, tone, href, className }: { label: string; value: ReactNode; sub?: ReactNode; tone?: Tone; href?: string; className?: string }) {
111+ const body = (
112+ <>
113+ <div className="text-xs text-ink-2">{label}</div>
114+ <div className={clsx("mt-1 text-2xl font-semibold tracking-tight", tone === "crit" ? "text-crit-ink" : tone === "warn" ? "text-warn-ink" : tone === "ok" ? "text-ok-ink" : "text-ink-1")}>
115+ {value}
116+ </div>
117+ {sub && <div className="mt-0.5 text-xs text-ink-3">{sub}</div>}
118+ </>
119+ );
120+ const cls = clsx("card min-w-0", href && "transition hover:border-accent/60", className);
121+ return href ? (
122+ <Link href={href} className={cls}>
123+ {body}
124+ </Link>
125+ ) : (
126+ <div className={cls}>{body}</div>
127+ );
128+}
129+
130+// ---------------------------------------------------------------- misc
131+export function Banner({ tone = "accent", children, className }: { tone?: Tone; children: ReactNode; className?: string }) {
132+ const Icon = TONE_ICON[tone];
133+ const border = { ok: "border-ok/50", warn: "border-warn/60", serious: "border-serious/60", crit: "border-crit/60", neutral: "border-border", accent: "border-accent/40" }[tone];
134+ return (
135+ <div className={clsx("banner flex items-start gap-2", TONE[tone].split(" ")[0], border, className)}>
136+ <Icon className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
137+ <div className="min-w-0 flex-1">{children}</div>
138+ </div>
139+ );
140+}
141+
142+export function ErrorState({ error, retry }: { error: unknown; retry?: () => void }) {
143+ const msg = isApiError(error) ? `${error.message} (${error.code}, HTTP ${error.status})` : error instanceof Error ? error.message : "Unknown error";
144+ const rid = isApiError(error) ? error.request_id : null;
145+ const forbidden = isApiError(error) && error.status === 403;
146+ return (
147+ <div className="banner flex items-start gap-2 border-crit/60 bg-crit-soft text-crit-ink">
148+ <XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
149+ <div className="min-w-0 flex-1">
150+ <div className="font-medium">{forbidden ? "Not allowed" : "Could not load"}</div>
151+ <div className="text-xs break-words">{msg}</div>
152+ {rid && <div className="mt-0.5 font-mono text-[11px] opacity-80">request_id {rid}</div>}
153+ </div>
154+ {retry && (
155+ <button className="btn btn-sm" onClick={retry}>
156+ Retry
157+ </button>
158+ )}
159+ </div>
160+ );
161+}
162+
163+export function Empty({ children = "Nothing here yet." }: { children?: ReactNode }) {
164+ return <div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-ink-3">{children}</div>;
165+}
166+
167+export function Skeleton({ rows = 4, className }: { rows?: number; className?: string }) {
168+ return (
169+ <div className={clsx("flex flex-col gap-2", className)} aria-busy="true" aria-label="Loading">
170+ {Array.from({ length: rows }).map((_, i) => (
171+ <div key={i} className="skeleton h-4" style={{ width: `${90 - (i % 3) * 15}%` }} />
172+ ))}
173+ </div>
174+ );
175+}
176+
177+export function Field({ label, children, hint, className }: { label: string; children: ReactNode; hint?: ReactNode; className?: string }) {
178+ return (
179+ <label className={clsx("block min-w-0", className)}>
180+ <span className="label">{label}</span>
181+ {children}
182+ {hint && <span className="mt-0.5 block text-[11px] text-ink-3">{hint}</span>}
183+ </label>
184+ );
185+}
186+
187+export function KV({ items, className }: { items: [ReactNode, ReactNode][]; className?: string }) {
188+ return (
189+ <dl className={clsx("grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm", className)}>
190+ {items.map(([k, v], i) => (
191+ <div key={i} className="contents">
192+ <dt className="text-ink-2 whitespace-nowrap">{k}</dt>
193+ <dd className="min-w-0 break-words text-ink-1">{v ?? "—"}</dd>
194+ </div>
195+ ))}
196+ </dl>
197+ );
198+}
199+
200+export function Mono({ children, className }: { children: ReactNode; className?: string }) {
201+ return <span className={clsx("font-mono text-[12.5px]", className)}>{children}</span>;
202+}
203+
204+export function Tabs<T extends string>({ value, onChange, items }: { value: T; onChange: (v: T) => void; items: { value: T; label: string }[] }) {
205+ return (
206+ <div role="tablist" className="inline-flex rounded-md border border-border bg-surface-2 p-0.5">
207+ {items.map((it) => (
208+ <button
209+ key={it.value}
210+ role="tab"
211+ aria-selected={value === it.value}
212+ className={clsx("rounded px-2.5 py-1 text-xs font-medium", value === it.value ? "bg-surface-1 text-ink-1 shadow-sm" : "text-ink-2 hover:text-ink-1")}
213+ onClick={() => onChange(it.value)}
214+ >
215+ {it.label}
216+ </button>
217+ ))}
218+ </div>
219+ );
220+}
added apps/web/src/lib/api.ts +462 −0
@@ -0,0 +1,462 @@
1+/**
2+ * Fetch wrapper for the FinData OS API (same origin, prefix /api/v1).
3+ * - cookie session (`credentials: same-origin`)
4+ * - X-CSRF-Token on every non-GET request (kept in memory + sessionStorage)
5+ * - error normalization: throws ApiError {code, message, request_id, status, extra}
6+ */
7+export const API_PREFIX = "/api/v1";
8+
9+const CSRF_KEY = "findata.csrf";
10+let csrfToken: string | null = null;
11+
12+export function getCsrf(): string | null {
13+ if (csrfToken) return csrfToken;
14+ if (typeof window !== "undefined") {
15+ try {
16+ csrfToken = window.sessionStorage.getItem(CSRF_KEY);
17+ } catch {
18+ csrfToken = null;
19+ }
20+ }
21+ return csrfToken;
22+}
23+
24+export function setCsrf(token: string | null | undefined) {
25+ csrfToken = token ?? null;
26+ if (typeof window === "undefined") return;
27+ try {
28+ if (token) window.sessionStorage.setItem(CSRF_KEY, token);
29+ else window.sessionStorage.removeItem(CSRF_KEY);
30+ } catch {
31+ /* storage unavailable */
32+ }
33+}
34+
35+export class ApiError extends Error {
36+ code: string;
37+ status: number;
38+ request_id: string | null;
39+ extra: Record<string, unknown>;
40+ constructor(status: number, code: string, message: string, request_id: string | null, extra: Record<string, unknown> = {}) {
41+ super(message);
42+ this.name = "ApiError";
43+ this.status = status;
44+ this.code = code;
45+ this.request_id = request_id;
46+ this.extra = extra;
47+ }
48+}
49+
50+export function isApiError(e: unknown): e is ApiError {
51+ return e instanceof ApiError;
52+}
53+
54+export type Query = Record<string, string | number | boolean | null | undefined>;
55+
56+export function qs(params?: Query): string {
57+ if (!params) return "";
58+ const u = new URLSearchParams();
59+ for (const [k, v] of Object.entries(params)) {
60+ if (v === undefined || v === null || v === "") continue;
61+ u.set(k, String(v));
62+ }
63+ const s = u.toString();
64+ return s ? `?${s}` : "";
65+}
66+
67+interface RequestOptions {
68+ method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
69+ body?: unknown;
70+ params?: Query;
71+ signal?: AbortSignal;
72+}
73+
74+async function parseError(res: Response): Promise<ApiError> {
75+ let payload: unknown = null;
76+ try {
77+ payload = await res.json();
78+ } catch {
79+ /* not JSON */
80+ }
81+ const err = (payload as { error?: Record<string, unknown> } | null)?.error;
82+ if (err && typeof err === "object") {
83+ const { code, message, request_id, ...extra } = err as Record<string, unknown>;
84+ return new ApiError(res.status, String(code ?? "http_error"), String(message ?? res.statusText), (request_id as string) ?? null, extra);
85+ }
86+ return new ApiError(res.status, "http_error", res.statusText || `HTTP ${res.status}`, res.headers.get("x-request-id"));
87+}
88+
89+export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
90+ const method = opts.method ?? "GET";
91+ const headers: Record<string, string> = { Accept: "application/json" };
92+ if (opts.body !== undefined) headers["Content-Type"] = "application/json";
93+ if (method !== "GET") {
94+ const t = getCsrf();
95+ if (t) headers["X-CSRF-Token"] = t;
96+ }
97+ const url = (path.startsWith("/api/") ? path : API_PREFIX + path) + qs(opts.params);
98+ const res = await fetch(url, {
99+ method,
100+ headers,
101+ credentials: "same-origin",
102+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
103+ signal: opts.signal,
104+ });
105+ if (!res.ok) throw await parseError(res);
106+ if (res.status === 204) return undefined as T;
107+ const text = await res.text();
108+ if (!text) return undefined as T;
109+ return JSON.parse(text) as T;
110+}
111+
112+export const api = {
113+ get: <T>(path: string, params?: Query, signal?: AbortSignal) => request<T>(path, { params, signal }),
114+ post: <T>(path: string, body?: unknown, params?: Query) => request<T>(path, { method: "POST", body, params }),
115+ put: <T>(path: string, body?: unknown) => request<T>(path, { method: "PUT", body }),
116+ del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
117+};
118+
119+// ---------------------------------------------------------------- types (mirroring the FastAPI routers)
120+
121+export interface Me {
122+ kind: "user" | "api_key" | "anonymous";
123+ email: string | null;
124+ role: string | null;
125+ scopes: string[];
126+ mfa_passed: boolean;
127+ totp_enabled: boolean | null;
128+ csrf_token: string | null;
129+}
130+
131+export interface LoginOut {
132+ ok: boolean;
133+ mfa_required: boolean;
134+ csrf_token: string;
135+ email: string;
136+ role: string;
137+}
138+
139+export interface HealthAlert {
140+ severity: "info" | "warning" | "error";
141+ code: string;
142+ message: string;
143+}
144+
145+export interface HealthReport {
146+ status: "ok" | "warning" | "degraded";
147+ checked_at?: string;
148+ alerts: HealthAlert[];
149+ components: {
150+ postgres?: string;
151+ lake?: { root: string; free_gb: number; curated_gb: number; raw_gb: number; soft_limit_gb: number; hard_min_free_gb: number };
152+ ingest_worker?: string;
153+ backup?: { last: string | null; age_hours?: number; verified?: boolean | null };
154+ ssd_smart?: string;
155+ };
156+ datasets?: {
157+ dataset_id: string;
158+ version: number | null;
159+ last_session: string | null;
160+ days_since_last_session: number | null;
161+ vendor_last_update: string | null;
162+ vendor_lag_days: number | null;
163+ }[];
164+}
165+
166+export interface HealthSummary {
167+ datasets: { active: number; planned: number; unsupported: number; pending: number };
168+ instruments: { n: number; active: number };
169+ bars_1m: { symbols: number; sessions: number; bars: number | null; last: string | null };
170+ jobs: Record<string, number>;
171+ recent_jobs: Job[];
172+ fundamentals: { entities: number; facts: number };
173+ anomalies_7d: Record<string, number>;
174+ health: HealthReport;
175+}
176+
177+export type DatasetStatus = "active" | "planned" | "pending_configuration" | "unsupported" | "paused";
178+
179+export interface Dataset {
180+ id: string;
181+ name: string;
182+ description: string | null;
183+ kind: string;
184+ asset_class: string | null;
185+ interval: string | null;
186+ adjustment: string | null;
187+ license_class: string;
188+ license_text: string;
189+ status: DatasetStatus;
190+ timestamp_convention: string | null;
191+ timezone: string | null;
192+ current_version: number | null;
193+ snapshot_id?: string | null;
194+ file_count?: number | null;
195+ row_count?: number | null;
196+ bytes?: number | null;
197+ version_created_at?: string | null;
198+ first_session?: string | null;
199+ last_session?: string | null;
200+ symbols?: number | null;
201+ source_name?: string;
202+ source_id?: string;
203+ vendor?: string;
204+ product?: string;
205+ notes?: string | null;
206+ [k: string]: unknown;
207+}
208+
209+export interface DatasetVersion {
210+ version: number;
211+ snapshot_id: string | null;
212+ file_count: number | null;
213+ row_count: number | null;
214+ bytes: number | null;
215+ created_at: string;
216+ note: string | null;
217+}
218+
219+export interface DatasetDetail {
220+ data: Dataset;
221+ versions: DatasetVersion[];
222+ files: { layout: string; n: number; rows: number | null; bytes: number | null; d0: string | null; d1: string | null }[];
223+ checkpoints: { key: string; value: unknown; updated_at: string }[];
224+}
225+
226+export interface Instrument {
227+ instrument_id: string;
228+ symbol: string;
229+ name: string | null;
230+ asset_class: string;
231+ status: string;
232+ first_date: string | null;
233+ last_date: string | null;
234+ currency: string | null;
235+ [k: string]: unknown;
236+}
237+
238+export interface InstrumentDetail {
239+ instrument: Instrument | null;
240+ aliases: Record<string, unknown>[];
241+ corporate_actions: { action_type: string; ex_date: string; value: unknown }[];
242+ coverage: { dataset_id: string; sessions: number; d0: string; d1: string; bars: number }[];
243+ fundamentals: { facts: number; last_period: string | null; last_filing: string | null } | null;
244+}
245+
246+export interface Bar {
247+ timestamp_utc: string;
248+ session_date: string;
249+ open: number;
250+ high: number;
251+ low: number;
252+ close: number;
253+ volume: number;
254+ vwap?: number | null;
255+ trade_count?: number | null;
256+ quality_flags: string[];
257+ adjustment_type?: string;
258+ revision_id?: number;
259+ [k: string]: unknown;
260+}
261+
262+export interface BarsOut {
263+ data: Bar[];
264+ meta: {
265+ symbol: string;
266+ interval: string;
267+ adjustment: string;
268+ start: string;
269+ end: string;
270+ rows: number;
271+ rows_matching: number;
272+ adjustment_notes: string[];
273+ timestamp_convention: string;
274+ license: string;
275+ version?: number;
276+ files_scanned?: number;
277+ [k: string]: unknown;
278+ };
279+ next_cursor: string | null;
280+}
281+
282+export interface SessionRow {
283+ session_date: string;
284+ bar_count: number;
285+ expected_bars: number | null;
286+ first_ts: string | null;
287+ last_ts: string | null;
288+ flags: string[] | null;
289+ symbol?: string;
290+}
291+
292+export interface Heatmap {
293+ sessions: { session_date: string; symbols: number; bars: number; completeness: number | null; non_trading_rows: number }[];
294+ top_symbols: { symbol: string; sessions: number; bars: number; completeness: number | null }[];
295+ meta: { dataset_id: string; start: string; end: string };
296+}
297+
298+export interface QualityOut {
299+ latest: Record<string, unknown> | null;
300+ anomalies_by_code_30d: { code: string; severity: string; n: number; rows: number | null }[];
301+ reports: { id: string; created_at: string; rows_checked: number; anomalies: number; summary: unknown }[];
302+ codes: Record<string, string>;
303+}
304+
305+export interface Anomaly {
306+ id?: string;
307+ code: string;
308+ severity: string;
309+ symbol: string | null;
310+ session_date?: string | null;
311+ detail: Record<string, unknown> | null;
312+ created_at: string;
313+ [k: string]: unknown;
314+}
315+
316+export interface QueryOut {
317+ columns: { name: string; type: string }[];
318+ rows: Record<string, unknown>[];
319+ meta: { truncated?: boolean; rows: number; elapsed_ms: number; views: string[]; engine: string; [k: string]: unknown };
320+}
321+
322+export interface ExportRow {
323+ id: string;
324+ status: "queued" | "running" | "ready" | "failed" | "expired";
325+ request: { dataset_id: string; symbols: string[]; start: string; end: string; adjustment: string; format: string; version?: number };
326+ format: string;
327+ bytes: number | null;
328+ row_count: number | null;
329+ created_at: string;
330+ finished_at: string | null;
331+ expires_at: string | null;
332+ error: string | null;
333+ snapshot: unknown;
334+ download?: string;
335+}
336+
337+export interface Fact {
338+ entity_id: string;
339+ metric: string;
340+ value: number | null;
341+ unit: string;
342+ currency: string | null;
343+ fiscal_period_start: string | null;
344+ fiscal_period_end: string;
345+ fiscal_period_type: string;
346+ fiscal_year: number | null;
347+ fiscal_period: string | null;
348+ filing_date: string | null;
349+ published_at: string | null;
350+ available_at: string;
351+ observed_at: string;
352+ source_document: string | null;
353+ source_id: string;
354+ revision_id: number;
355+ restatement_flag: boolean;
356+ superseded_at: string | null;
357+}
358+
359+export interface FundamentalsOut {
360+ data: Fact[];
361+ entity: Record<string, unknown> | null;
362+ meta: { as_of: string; rows: number; facts_hidden_by_as_of: number; pit_policy: string; license: string };
363+}
364+
365+export interface PeriodsOut {
366+ periods: {
367+ fiscal_period_end: string;
368+ fiscal_period_type: string;
369+ fiscal_year: number | null;
370+ fiscal_period: string | null;
371+ filing_date: string | null;
372+ available_at: string;
373+ metrics: number;
374+ max_revision: number;
375+ restated: boolean;
376+ }[];
377+ earnings: { event_date: string; eps_estimate: number | null; eps_reported: number | null; release_time: string | null }[];
378+}
379+
380+export interface Job {
381+ id: string;
382+ kind: string;
383+ dataset_id: string | null;
384+ params?: Record<string, unknown>;
385+ priority?: number;
386+ status: string;
387+ attempts?: number;
388+ max_attempts?: number;
389+ next_run_at?: string | null;
390+ created_at?: string;
391+ started_at: string | null;
392+ finished_at: string | null;
393+ last_error: string | null;
394+ progress?: Record<string, unknown> | null;
395+ result?: Record<string, unknown> | null;
396+ created_by?: string | null;
397+}
398+
399+export interface ApiKey {
400+ id: string;
401+ name: string;
402+ prefix: string;
403+ scopes: string[];
404+ rate_limit_per_minute: number;
405+ expires_at: string | null;
406+ last_used_at: string | null;
407+ revoked_at: string | null;
408+ created_at: string;
409+ email: string;
410+}
411+
412+export interface UsageRow {
413+ principal: string;
414+ day: string;
415+ requests: number;
416+ rows_returned: number;
417+ bytes_exported: number;
418+}
419+
420+export interface AuditRow {
421+ id?: string;
422+ at: string;
423+ action: string;
424+ actor?: string | null;
425+ principal?: string | null;
426+ target?: string | null;
427+ outcome?: string | null;
428+ ip?: string | null;
429+ request_id?: string | null;
430+ detail?: Record<string, unknown> | null;
431+ [k: string]: unknown;
432+}
433+
434+export interface StorageOut {
435+ lake_root: string;
436+ volume: { total_gb: number; free_gb: number };
437+ budget: { soft_limit_gb: number; hard_min_free_gb: number };
438+ curated_bytes: number;
439+ raw_bytes: number;
440+ retired_bytes: number;
441+ exports_bytes: number;
442+ per_dataset: { dataset_id: string; layout: string; files: number; bytes: number | null; rows: number | null }[];
443+ raw_archives: { status: string; n: number; bytes: number | null }[];
444+ exports: { status: string; n: number; bytes: number }[];
445+ retired_files: { n: number; bytes: number };
446+ postgres_bytes: number;
447+ last_backup: { value: Record<string, unknown>; updated_at: string } | null;
448+ capacity_model: { bytes_per_1m_bar_curated: number; note: string };
449+}
450+
451+export interface SettingsOut {
452+ settings: { key: string; value: unknown; updated_at: string }[];
453+ env: {
454+ env: string;
455+ lake: string;
456+ public_url: string;
457+ backup_target: string;
458+ vendor_configured: boolean;
459+ sec_edgar_configured: boolean;
460+ ai_providers: { anthropic: boolean; openai: boolean };
461+ };
462+}
added apps/web/src/lib/auth.tsx +99 −0
@@ -0,0 +1,99 @@
1+"use client";
2+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
3+import { usePathname, useRouter } from "next/navigation";
4+import { api, isApiError, setCsrf, type Me } from "./api";
5+
6+interface AuthState {
7+ me: Me | null;
8+ loading: boolean;
9+ refresh: () => Promise<Me | null>;
10+ logout: () => Promise<void>;
11+ isOwner: boolean;
12+ hasScope: (s: string) => boolean;
13+}
14+
15+const Ctx = createContext<AuthState | null>(null);
16+
17+const PUBLIC_PATHS = ["/login"];
18+
19+function isPublic(path: string | null): boolean {
20+ if (!path) return false;
21+ const p = path.replace(/\/+$/, "") || "/";
22+ return PUBLIC_PATHS.includes(p);
23+}
24+
25+export function AuthProvider({ children }: { children: ReactNode }) {
26+ const [me, setMe] = useState<Me | null>(null);
27+ const [loading, setLoading] = useState(true);
28+ const pathname = usePathname();
29+ const router = useRouter();
30+
31+ const refresh = useCallback(async (): Promise<Me | null> => {
32+ try {
33+ const m = await api.get<Me>("/auth/me");
34+ setCsrf(m.csrf_token);
35+ setMe(m);
36+ return m;
37+ } catch (e) {
38+ if (isApiError(e) && e.status === 401) {
39+ setCsrf(null);
40+ setMe(null);
41+ return null;
42+ }
43+ // network/other error: keep previous state, but stop loading
44+ setMe(null);
45+ return null;
46+ } finally {
47+ setLoading(false);
48+ }
49+ }, []);
50+
51+ useEffect(() => {
52+ void refresh();
53+ }, [refresh]);
54+
55+ // Redirect rules: signed out → /login/ ; MFA pending → /login/ (step 2) ; signed in on /login → /
56+ useEffect(() => {
57+ if (loading) return;
58+ const pub = isPublic(pathname);
59+ if (!me && !pub) {
60+ const next = pathname && pathname !== "/" ? `?next=${encodeURIComponent(pathname)}` : "";
61+ router.replace(`/login/${next}`);
62+ } else if (me && !me.mfa_passed && !pub) {
63+ router.replace("/login/");
64+ } else if (me && me.mfa_passed && pub) {
65+ const next = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("next") : null;
66+ router.replace(next && next.startsWith("/") ? next : "/");
67+ }
68+ }, [loading, me, pathname, router]);
69+
70+ const logout = useCallback(async () => {
71+ try {
72+ await api.post("/auth/logout");
73+ } catch {
74+ /* ignore */
75+ }
76+ setCsrf(null);
77+ setMe(null);
78+ router.replace("/login/");
79+ }, [router]);
80+
81+ const value = useMemo<AuthState>(
82+ () => ({
83+ me,
84+ loading,
85+ refresh,
86+ logout,
87+ isOwner: me?.role === "owner" || (me?.scopes ?? []).includes("admin"),
88+ hasScope: (s: string) => (me?.scopes ?? []).includes(s) || (me?.scopes ?? []).includes("admin"),
89+ }),
90+ [me, loading, refresh, logout],
91+ );
92+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
93+}
94+
95+export function useAuth(): AuthState {
96+ const c = useContext(Ctx);
97+ if (!c) throw new Error("useAuth outside AuthProvider");
98+ return c;
99+}
added apps/web/src/lib/format.ts +148 −0
@@ -0,0 +1,148 @@
1+/** Formatting helpers — bytes, thin-space grouped numbers, ISO UTC timestamps. */
2+
3+const THIN = " "; // narrow no-break space
4+
5+export function fmtInt(n: number | null | undefined): string {
6+ if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
7+ const v = Math.round(Number(n));
8+ const neg = v < 0;
9+ const s = Math.abs(v).toString();
10+ const parts: string[] = [];
11+ for (let i = s.length; i > 0; i -= 3) parts.unshift(s.slice(Math.max(0, i - 3), i));
12+ return (neg ? "-" : "") + parts.join(THIN);
13+}
14+
15+export function fmtNum(n: number | null | undefined, digits = 2): string {
16+ if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
17+ const v = Number(n);
18+ if (Number.isInteger(v) && digits === 0) return fmtInt(v);
19+ const [i, f] = v.toFixed(digits).split(".");
20+ return fmtInt(Number(i)) + (f ? "." + f : "");
21+}
22+
23+/** Compact 1 284 / 12.9K / 4.2M / 1.3B */
24+export function fmtCompact(n: number | null | undefined): string {
25+ if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
26+ const v = Number(n);
27+ const a = Math.abs(v);
28+ if (a < 10_000) return fmtInt(v);
29+ if (a < 1e6) return (v / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
30+ if (a < 1e9) return (v / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
31+ if (a < 1e12) return (v / 1e9).toFixed(2).replace(/\.?0+$/, "") + "B";
32+ return (v / 1e12).toFixed(2).replace(/\.?0+$/, "") + "T";
33+}
34+
35+export function fmtBytes(b: number | null | undefined, digits = 1): string {
36+ if (b === null || b === undefined || Number.isNaN(Number(b))) return "—";
37+ const v = Number(b);
38+ if (v < 1e3) return `${fmtInt(v)} B`;
39+ if (v < 1e6) return `${(v / 1e3).toFixed(digits)} kB`;
40+ if (v < 1e9) return `${(v / 1e6).toFixed(digits)} MB`;
41+ if (v < 1e12) return `${(v / 1e9).toFixed(digits)} GB`;
42+ return `${(v / 1e12).toFixed(digits)} TB`;
43+}
44+
45+export function fmtGB(gb: number | null | undefined, digits = 1): string {
46+ if (gb === null || gb === undefined) return "—";
47+ return `${Number(gb).toFixed(digits)} GB`;
48+}
49+
50+export function fmtPct(x: number | null | undefined, digits = 1): string {
51+ if (x === null || x === undefined || Number.isNaN(Number(x))) return "—";
52+ return `${(Number(x) * 100).toFixed(digits)} %`;
53+}
54+
55+const pad = (n: number) => String(n).padStart(2, "0");
56+
57+/** ISO UTC → `YYYY-MM-DD HH:mm:ssZ` */
58+export function fmtTs(iso: string | null | undefined): string {
59+ if (!iso) return "—";
60+ const d = new Date(iso);
61+ if (Number.isNaN(d.getTime())) return String(iso);
62+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}Z`;
63+}
64+
65+/** Short time-of-day `HH:mm` (UTC) */
66+export function fmtTime(iso: string | null | undefined): string {
67+ if (!iso) return "—";
68+ const d = new Date(iso);
69+ if (Number.isNaN(d.getTime())) return String(iso);
70+ return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
71+}
72+
73+export function fmtDate(iso: string | null | undefined): string {
74+ if (!iso) return "—";
75+ return String(iso).slice(0, 10);
76+}
77+
78+export function fmtRelative(iso: string | null | undefined): string {
79+ if (!iso) return "—";
80+ const d = new Date(iso).getTime();
81+ if (Number.isNaN(d)) return String(iso);
82+ const s = Math.round((Date.now() - d) / 1000);
83+ const abs = Math.abs(s);
84+ const suffix = s >= 0 ? "ago" : "from now";
85+ if (abs < 60) return `${abs} s ${suffix}`;
86+ if (abs < 3600) return `${Math.round(abs / 60)} min ${suffix}`;
87+ if (abs < 86400) return `${(abs / 3600).toFixed(1)} h ${suffix}`;
88+ return `${Math.round(abs / 86400)} d ${suffix}`;
89+}
90+
91+export function fmtDuration(startIso: string | null | undefined, endIso: string | null | undefined): string {
92+ if (!startIso) return "—";
93+ const a = new Date(startIso).getTime();
94+ const b = endIso ? new Date(endIso).getTime() : Date.now();
95+ const s = Math.max(0, Math.round((b - a) / 1000));
96+ if (s < 60) return `${s} s`;
97+ if (s < 3600) return `${Math.floor(s / 60)} min ${s % 60} s`;
98+ return `${(s / 3600).toFixed(1)} h`;
99+}
100+
101+export function isoDate(d: Date): string {
102+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
103+}
104+
105+export function daysAgo(n: number): string {
106+ const d = new Date();
107+ d.setUTCDate(d.getUTCDate() - n);
108+ return isoDate(d);
109+}
110+
111+export function today(): string {
112+ return isoDate(new Date());
113+}
114+
115+/** datetime-local value (local tz) for `now` */
116+export function nowLocalInput(): string {
117+ const d = new Date();
118+ const off = d.getTimezoneOffset();
119+ const local = new Date(d.getTime() - off * 60_000);
120+ return local.toISOString().slice(0, 16);
121+}
122+
123+export function toCsv(columns: string[], rows: Record<string, unknown>[]): string {
124+ const esc = (v: unknown) => {
125+ if (v === null || v === undefined) return "";
126+ const s = typeof v === "object" ? JSON.stringify(v) : String(v);
127+ return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
128+ };
129+ const lines = [columns.map(esc).join(",")];
130+ for (const r of rows) lines.push(columns.map((c) => esc(r[c])).join(","));
131+ return lines.join("\n");
132+}
133+
134+export function downloadText(filename: string, text: string, mime = "text/csv;charset=utf-8") {
135+ const blob = new Blob([text], { type: mime });
136+ const url = URL.createObjectURL(blob);
137+ const a = document.createElement("a");
138+ a.href = url;
139+ a.download = filename;
140+ document.body.appendChild(a);
141+ a.click();
142+ a.remove();
143+ setTimeout(() => URL.revokeObjectURL(url), 1000);
144+}
145+
146+export function splitList(text: string): string[] {
147+ return Array.from(new Set(text.split(/[\s,;]+/).map((s) => s.trim().toUpperCase()).filter(Boolean)));
148+}
added apps/web/src/lib/hooks.ts +112 −0
@@ -0,0 +1,112 @@
1+"use client";
2+import { useCallback, useEffect, useRef, useState } from "react";
3+
4+/** Generic async loader with abort + refetch. `deps` re-run the fetch. */
5+export function useAsync<T>(fn: (signal: AbortSignal) => Promise<T>, deps: unknown[], opts: { enabled?: boolean } = {}) {
6+ const enabled = opts.enabled ?? true;
7+ const [data, setData] = useState<T | null>(null);
8+ const [error, setError] = useState<unknown>(null);
9+ const [loading, setLoading] = useState<boolean>(enabled);
10+ const [tick, setTick] = useState(0);
11+ const fnRef = useRef(fn);
12+ fnRef.current = fn;
13+
14+ useEffect(() => {
15+ if (!enabled) {
16+ setLoading(false);
17+ return;
18+ }
19+ const ctl = new AbortController();
20+ let alive = true;
21+ setLoading(true);
22+ fnRef
23+ .current(ctl.signal)
24+ .then((d) => {
25+ if (!alive) return;
26+ setData(d);
27+ setError(null);
28+ })
29+ .catch((e) => {
30+ if (!alive || ctl.signal.aborted) return;
31+ setError(e);
32+ })
33+ .finally(() => {
34+ if (alive) setLoading(false);
35+ });
36+ return () => {
37+ alive = false;
38+ ctl.abort();
39+ };
40+ // eslint-disable-next-line react-hooks/exhaustive-deps
41+ }, [...deps, tick, enabled]);
42+
43+ const reload = useCallback(() => setTick((t) => t + 1), []);
44+ return { data, error, loading, reload, setData };
45+}
46+
47+export function useDebounced<T>(value: T, ms = 300): T {
48+ const [v, setV] = useState(value);
49+ useEffect(() => {
50+ const t = window.setTimeout(() => setV(value), ms);
51+ return () => window.clearTimeout(t);
52+ }, [value, ms]);
53+ return v;
54+}
55+
56+/** Read a query-string parameter client-side (static export cannot use dynamic routes). */
57+export function useQueryParam(name: string): string | null {
58+ const [v, setV] = useState<string | null>(null);
59+ useEffect(() => {
60+ const read = () => setV(new URLSearchParams(window.location.search).get(name));
61+ read();
62+ window.addEventListener("popstate", read);
63+ return () => window.removeEventListener("popstate", read);
64+ }, [name]);
65+ return v;
66+}
67+
68+export function useInterval(cb: () => void, ms: number | null) {
69+ const ref = useRef(cb);
70+ ref.current = cb;
71+ useEffect(() => {
72+ if (ms === null) return;
73+ const id = window.setInterval(() => ref.current(), ms);
74+ return () => window.clearInterval(id);
75+ }, [ms]);
76+}
77+
78+export function useMediaQuery(q: string): boolean {
79+ const [m, setM] = useState(false);
80+ useEffect(() => {
81+ const mq = window.matchMedia(q);
82+ const on = () => setM(mq.matches);
83+ on();
84+ mq.addEventListener("change", on);
85+ return () => mq.removeEventListener("change", on);
86+ }, [q]);
87+ return m;
88+}
89+
90+export function useLocalStorage<T>(key: string, initial: T): [T, (v: T) => void] {
91+ const [v, setV] = useState<T>(initial);
92+ useEffect(() => {
93+ try {
94+ const raw = window.localStorage.getItem(key);
95+ if (raw !== null) setV(JSON.parse(raw) as T);
96+ } catch {
97+ /* ignore */
98+ }
99+ }, [key]);
100+ const set = useCallback(
101+ (nv: T) => {
102+ setV(nv);
103+ try {
104+ window.localStorage.setItem(key, JSON.stringify(nv));
105+ } catch {
106+ /* ignore */
107+ }
108+ },
109+ [key],
110+ );
111+ return [v, set];
112+}
added apps/web/src/lib/toast.tsx +62 −0
@@ -0,0 +1,62 @@
1+"use client";
2+import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from "react";
3+import { isApiError } from "./api";
4+
5+export type ToastKind = "info" | "success" | "warning" | "error";
6+export interface ToastItem {
7+ id: number;
8+ kind: ToastKind;
9+ title: string;
10+ detail?: string;
11+ requestId?: string | null;
12+}
13+
14+interface ToastCtx {
15+ toasts: ToastItem[];
16+ push: (t: Omit<ToastItem, "id">) => void;
17+ dismiss: (id: number) => void;
18+ error: (e: unknown, fallback?: string) => void;
19+ success: (title: string, detail?: string) => void;
20+ info: (title: string, detail?: string) => void;
21+}
22+
23+const Ctx = createContext<ToastCtx | null>(null);
24+
25+export function ToastProvider({ children }: { children: ReactNode }) {
26+ const [toasts, setToasts] = useState<ToastItem[]>([]);
27+ const seq = useRef(0);
28+
29+ const dismiss = useCallback((id: number) => setToasts((t) => t.filter((x) => x.id !== id)), []);
30+ const push = useCallback(
31+ (t: Omit<ToastItem, "id">) => {
32+ const id = ++seq.current;
33+ setToasts((prev) => [...prev.slice(-4), { ...t, id }]);
34+ const ttl = t.kind === "error" ? 9000 : 4500;
35+ window.setTimeout(() => dismiss(id), ttl);
36+ },
37+ [dismiss],
38+ );
39+ const error = useCallback(
40+ (e: unknown, fallback = "Request failed") => {
41+ if (isApiError(e)) {
42+ push({ kind: "error", title: `${e.message}`, detail: `${e.code} · HTTP ${e.status}`, requestId: e.request_id });
43+ } else if (e instanceof Error) {
44+ push({ kind: "error", title: fallback, detail: e.message });
45+ } else {
46+ push({ kind: "error", title: fallback });
47+ }
48+ },
49+ [push],
50+ );
51+ const success = useCallback((title: string, detail?: string) => push({ kind: "success", title, detail }), [push]);
52+ const info = useCallback((title: string, detail?: string) => push({ kind: "info", title, detail }), [push]);
53+
54+ const value = useMemo(() => ({ toasts, push, dismiss, error, success, info }), [toasts, push, dismiss, error, success, info]);
55+ return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
56+}
57+
58+export function useToast(): ToastCtx {
59+ const c = useContext(Ctx);
60+ if (!c) throw new Error("useToast outside ToastProvider");
61+ return c;
62+}
added apps/web/tsconfig.json +21 −0
@@ -0,0 +1,21 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "lib": ["dom", "dom.iterable", "esnext"],
5+ "allowJs": false,
6+ "skipLibCheck": true,
7+ "strict": true,
8+ "noEmit": true,
9+ "esModuleInterop": true,
10+ "module": "esnext",
11+ "moduleResolution": "bundler",
12+ "resolveJsonModule": true,
13+ "isolatedModules": true,
14+ "jsx": "preserve",
15+ "incremental": true,
16+ "plugins": [{ "name": "next" }],
17+ "paths": { "@/*": ["./src/*"] }
18+ },
19+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
20+ "exclude": ["node_modules", "out"]
21+}
added connectors/fundamentals/pyproject.toml +14 −0
@@ -0,0 +1,14 @@
1+[project]
2+name = "findata-fundamentals"
3+version = "0.1.0"
4+requires-python = ">=3.12"
5+dependencies = ["findata-core", "findata-schema", "findata-market-firstrate", "httpx>=0.28"]
6+[tool.uv.sources]
7+findata-core = { workspace = true }
8+findata-schema = { workspace = true }
9+findata-market-firstrate = { workspace = true }
10+[build-system]
11+requires = ["hatchling"]
12+build-backend = "hatchling.build"
13+[tool.hatch.build.targets.wheel]
14+packages = ["src/findata_fundamentals"]
added connectors/fundamentals/src/findata_fundamentals/__init__.py +2 −0
@@ -0,0 +1,2 @@
1+from findata_fundamentals.firstrate import FirstRateFundamentalsConnector, facts_from_financials, available_at_policy # noqa: F401
2+from findata_fundamentals.sec_edgar import SecEdgarConnector, facts_from_companyfacts # noqa: F401
added connectors/fundamentals/src/findata_fundamentals/firstrate.py +138 −0
@@ -0,0 +1,138 @@
1+"""FirstRate Data US Fundamentals (`/api-f/v1`). Verified 2026-11-01: auth via `userid` query parameter
2+(the page advertises `api_key` but that returns 403). JSON envelope `{status, data:{ticker, count, financial_data:[...]}}`.
3+
4+Point-in-time policy
5+--------------------
6+The vendor supplies `filing_date` (SEC filing date) and one value per (ticker, period): no revision history.
7+FinData sets `available_at = filing_date 00:00 America/New_York + 1 day` (the filing is certainly public on the
8+next trading morning; EDGAR acceptance after 17:30 ET is disseminated the next business day). `published_at` is
9+unknown → NULL. Each ingestion run compares the vendor value with the latest stored revision; a change creates a
10+new revision with `restatement_flag = true` and supersedes the old one. Pre-ingestion restatements cannot be
11+reconstructed from this vendor (documented limitation).
12+"""
13+from __future__ import annotations
14+
15+import json
16+from datetime import UTC, date, datetime, time, timedelta
17+from typing import Any, Iterator
18+from zoneinfo import ZoneInfo
19+
20+import httpx
21+
22+from findata_core.config import get_settings
23+from findata_core.redact import redact
24+
25+BASE = "https://firstratedata.com/api-f"
26+SOURCE_ID = "firstrate_fundamentals"
27+ET = ZoneInfo("America/New_York")
28+DEFAULT_TRANSPORT: httpx.BaseTransport | None = None
29+
30+# metric -> (unit, kind) kind: flow|stock|per_share|ratio|count
31+METRICS: dict[str, tuple[str, str]] = {
32+ "rev": ("USD", "flow"), "cogs": ("USD", "flow"), "gp": ("USD", "flow"), "sgna": ("USD", "flow"),
33+ "research_dev": ("USD", "flow"), "op_ex": ("USD", "flow"), "op_inc": ("USD", "flow"), "interest_exp": ("USD", "flow"),
34+ "ebt": ("USD", "flow"), "tax_expense": ("USD", "flow"), "net_inc": ("USD", "flow"), "net_inc_com": ("USD", "flow"),
35+ "net_inc_non_control": ("USD", "flow"), "net_loss_discontinued": ("USD", "flow"), "cos_inc": ("USD", "flow"),
36+ "ebit": ("USD", "flow"), "ebitda": ("USD", "flow"), "depr": ("USD", "flow"), "share_comp": ("USD", "flow"),
37+ "pref_divs": ("USD", "flow"), "eps": ("USD/share", "per_share"), "eps_d": ("USD/share", "per_share"),
38+ "dps": ("USD/share", "per_share"), "shares": ("shares", "count"), "shares_av": ("shares", "count"),
39+ "shares_av_diluted": ("shares", "count"),
40+ "assets_tot": ("USD", "stock"), "assets_c": ("USD", "stock"), "assets_nc": ("USD", "stock"), "cash": ("USD", "stock"),
41+ "receivables": ("USD", "stock"), "inventory": ("USD", "stock"), "inv_c": ("USD", "stock"), "inv_nc": ("USD", "stock"),
42+ "investments": ("USD", "stock"), "ppe": ("USD", "stock"), "intangibles": ("USD", "stock"), "tax_asset": ("USD", "stock"),
43+ "liab_tot": ("USD", "stock"), "liab_c": ("USD", "stock"), "liab_nc": ("USD", "stock"), "payables": ("USD", "stock"),
44+ "def_rev": ("USD", "stock"), "debt": ("USD", "stock"), "debt_c": ("USD", "stock"), "debt_nc": ("USD", "stock"),
45+ "tax_liability": ("USD", "stock"), "equity": ("USD", "stock"), "ret_earnings": ("USD", "stock"), "acc_oci": ("USD", "stock"),
46+ "cash_op": ("USD", "flow"), "cash_inv": ("USD", "flow"), "cash_fin": ("USD", "flow"), "capex": ("USD", "flow"),
47+ "cash_div": ("USD", "flow"), "cash_change": ("USD", "flow"), "cash_business_acq": ("USD", "flow"),
48+ "cash_inv_acq": ("USD", "flow"), "cash_debt_sec": ("USD", "flow"), "cash_equity": ("USD", "flow"),
49+ "fx_impact_cash": ("USD", "flow"),
50+ "roa": ("ratio", "ratio"), "roe": ("ratio", "ratio"), "roic": ("ratio", "ratio"), "gm": ("ratio", "ratio"),
51+ "om": ("ratio", "ratio"), "intr_cover": ("ratio", "ratio"),
52+}
53+NON_METRIC_FIELDS = {"period_end", "period_type", "filing_date", "fiscal_year", "fiscal_period", "actual_year", "actual_period"}
54+
55+
56+def available_at_policy(filing_date: date | None, period_end: date) -> datetime:
57+ """PIT availability. Falls back to period_end + 90 days when the filing date is missing (flagged upstream)."""
58+ base = filing_date if filing_date else period_end + timedelta(days=90)
59+ return datetime.combine(base + timedelta(days=1), time(0, 0), tzinfo=ET).astimezone(UTC)
60+
61+
62+def facts_from_financials(payload: dict[str, Any], observed_at: datetime, ticker: str | None = None) -> Iterator[dict[str, Any]]:
63+ """Explode one `/v1/financials/all` or `/single` JSON payload into long-format facts (dicts)."""
64+ data = payload.get("data") or {}
65+ tkr = (data.get("ticker") or ticker or "").upper()
66+ records = data.get("financial_data") or ([data] if "period_end" in data else [])
67+ for rec in records:
68+ pe = date.fromisoformat(rec["period_end"][:10])
69+ fd = date.fromisoformat(rec["filing_date"][:10]) if rec.get("filing_date") else None
70+ ptype = "a" if str(rec.get("period_type", "")).lower().startswith("a") or str(rec.get("fiscal_period", "")).lower() == "fy" else "q"
71+ avail = available_at_policy(fd, pe)
72+ start = None
73+ if ptype == "q":
74+ start = (pe.replace(day=1) - timedelta(days=60)).replace(day=1) # approx. quarter start (vendor gives no start)
75+ else:
76+ start = (pe - timedelta(days=364)).replace(day=1)
77+ for k, v in rec.items():
78+ if k in NON_METRIC_FIELDS or k not in METRICS:
79+ continue
80+ unit, _kind = METRICS[k]
81+ yield {
82+ "entity_id": f"frd:{tkr}", "security_id": f"firstrate:stock:{tkr}", "metric": k,
83+ "value": None if v is None else float(v), "unit": unit, "currency": "USD" if unit.startswith("USD") else None,
84+ "fiscal_period_start": start, "fiscal_period_end": pe, "fiscal_period_type": ptype,
85+ "fiscal_year": rec.get("fiscal_year"), "fiscal_period": str(rec.get("fiscal_period") or "").lower() or None,
86+ "filing_date": fd, "published_at": None, "available_at": avail, "observed_at": observed_at,
87+ "source_document": f"SEC {'10-K' if ptype == 'a' else '10-Q'} via FirstRate (filing_date={fd})",
88+ "source_id": SOURCE_ID, "pit_fallback": fd is None,
89+ }
90+
91+
92+class FirstRateFundamentalsConnector:
93+ source_id = SOURCE_ID
94+
95+ def __init__(self, userid: str | None = None, transport: httpx.BaseTransport | None = None):
96+ self._userid = userid if userid is not None else get_settings().frd_userid.get_secret_value()
97+ self._client = httpx.Client(timeout=60.0, transport=transport or DEFAULT_TRANSPORT,
98+ headers={"User-Agent": "FinDataOS/0.1 (+private research lakehouse)"})
99+
100+ def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
101+ if not self._userid:
102+ raise RuntimeError("FRD_USERID not configured")
103+ q = dict(params or {})
104+ q["type"] = "json"
105+ q["userid"] = self._userid
106+ r = self._client.get(f"{BASE}{path}", params=q)
107+ if r.status_code != 200:
108+ raise RuntimeError(redact(f"fundamentals HTTP {r.status_code} on {path}: {r.text[:200]}", [self._userid]))
109+ try:
110+ payload = r.json()
111+ except json.JSONDecodeError as e:
112+ raise RuntimeError(redact(f"fundamentals: non-JSON response on {path}", [self._userid])) from e
113+ if payload.get("status") not in (None, "success"):
114+ raise RuntimeError(redact(f"fundamentals: status={payload.get('status')} on {path}", [self._userid]))
115+ return payload
116+
117+ def discover(self) -> dict[str, Any]:
118+ p = self._get("/v1/financials/all/AAPL/annual")
119+ return {"source_id": SOURCE_ID, "probe": "financials/all/AAPL/annual", "count": p.get("data", {}).get("count"),
120+ "fields": sorted((p.get("data", {}).get("financial_data") or [{}])[0].keys())}
121+
122+ def financials_all(self, ticker: str, period: str = "quarterly") -> dict[str, Any]:
123+ assert period in ("annual", "quarterly")
124+ return self._get(f"/v1/financials/all/{ticker.upper()}/{period}")
125+
126+ def financials_single(self, ticker: str, period: str, year: int) -> dict[str, Any]:
127+ return self._get(f"/v1/financials/single/{ticker.upper()}", {"period": period, "year": year})
128+
129+ def earnings(self, ticker: str) -> list[dict[str, Any]]:
130+ p = self._get(f"/v1/earnings_release/t/{ticker.upper()}")
131+ return p.get("data", {}).get("earnings_release_data") or []
132+
133+ def metrics(self, ticker: str, start: date, end: date) -> list[dict[str, Any]]:
134+ p = self._get(f"/v1/metrics/t/{ticker.upper()}/{start.isoformat()}/{end.isoformat()}")
135+ return p.get("data", {}).get("metrics_data") or []
136+
137+ def close(self) -> None:
138+ self._client.close()
added connectors/fundamentals/src/findata_fundamentals/sec_edgar.py +109 −0
@@ -0,0 +1,109 @@
1+"""SEC EDGAR XBRL `companyfacts` connector — independent public-domain source.
2+
3+Status: **pending configuration** until `SEC_EDGAR_USER_AGENT` (name + contact e-mail, per SEC fair-access policy)
4+is set. Parsing is implemented and fixture-tested; live fetches refuse to run without the User-Agent and respect
5+the 10 requests/second limit.
6+
7+PIT: `companyfacts` provides `filed` (filing date), `accn` (accession), `form`, `fy`, `fp`, `start`, `end`, `frame`.
8+`available_at = filed + 1 day 00:00 ET`. Facts re-reported in later filings appear as distinct (accn, filed) rows
9+→ FinData keeps each as a revision ordered by filing date, so as-of queries naturally pick the latest filing
10+*available at* the query time, never a later restatement.
11+"""
12+from __future__ import annotations
13+
14+import time
15+from datetime import UTC, date, datetime, timedelta
16+from typing import Any, Iterator
17+from zoneinfo import ZoneInfo
18+
19+import httpx
20+
21+from findata_core.config import get_settings
22+
23+SOURCE_ID = "sec_edgar"
24+ET = ZoneInfo("America/New_York")
25+BASE = "https://data.sec.gov"
26+
27+# us-gaap concept -> FinData metric name (subset; others keep the concept name prefixed `xbrl:`)
28+CONCEPT_MAP = {
29+ "Revenues": "rev", "RevenueFromContractWithCustomerExcludingAssessedTax": "rev", "CostOfRevenue": "cogs",
30+ "GrossProfit": "gp", "OperatingIncomeLoss": "op_inc", "NetIncomeLoss": "net_inc",
31+ "EarningsPerShareBasic": "eps", "EarningsPerShareDiluted": "eps_d", "Assets": "assets_tot",
32+ "AssetsCurrent": "assets_c", "Liabilities": "liab_tot", "LiabilitiesCurrent": "liab_c",
33+ "StockholdersEquity": "equity", "CashAndCashEquivalentsAtCarryingValue": "cash",
34+ "NetCashProvidedByUsedInOperatingActivities": "cash_op", "NetCashProvidedByUsedInInvestingActivities": "cash_inv",
35+ "NetCashProvidedByUsedInFinancingActivities": "cash_fin", "PaymentsToAcquirePropertyPlantAndEquipment": "capex",
36+ "CommonStockSharesOutstanding": "shares", "WeightedAverageNumberOfDilutedSharesOutstanding": "shares_av_diluted",
37+ "WeightedAverageNumberOfSharesOutstandingBasic": "shares_av", "ResearchAndDevelopmentExpense": "research_dev",
38+ "LongTermDebt": "debt", "RetainedEarningsAccumulatedDeficit": "ret_earnings",
39+}
40+
41+
42+def facts_from_companyfacts(payload: dict[str, Any], observed_at: datetime, only_mapped: bool = True) -> Iterator[dict[str, Any]]:
43+ cik = int(payload.get("cik", 0))
44+ entity_id = f"sec:CIK{cik:010d}"
45+ name = payload.get("entityName")
46+ for taxonomy, concepts in (payload.get("facts") or {}).items():
47+ if taxonomy not in ("us-gaap", "dei"):
48+ continue
49+ for concept, body in concepts.items():
50+ metric = CONCEPT_MAP.get(concept)
51+ if metric is None:
52+ if only_mapped:
53+ continue
54+ metric = f"xbrl:{concept}"
55+ for unit, vals in (body.get("units") or {}).items():
56+ for v in vals:
57+ end = date.fromisoformat(v["end"])
58+ start = date.fromisoformat(v["start"]) if v.get("start") else None
59+ filed = date.fromisoformat(v["filed"])
60+ fp = (v.get("fp") or "").upper()
61+ ptype = "a" if fp == "FY" or (start and (end - start).days > 300) else "q"
62+ if start and (end - start).days > 120 and ptype == "q":
63+ continue # year-to-date cumulative values are not quarterly facts
64+ yield {
65+ "entity_id": entity_id, "security_id": None, "metric": metric, "value": float(v["val"]),
66+ "unit": unit, "currency": "USD" if unit == "USD" else None,
67+ "fiscal_period_start": start, "fiscal_period_end": end, "fiscal_period_type": ptype,
68+ "fiscal_year": v.get("fy"), "fiscal_period": fp.lower() or None, "filing_date": filed,
69+ "published_at": None,
70+ "available_at": datetime.combine(filed + timedelta(days=1), datetime.min.time(), tzinfo=ET).astimezone(UTC),
71+ "observed_at": observed_at,
72+ "source_document": f"{v.get('form')} {v.get('accn')} ({name})", "source_id": SOURCE_ID,
73+ "accession": v.get("accn"), "form": v.get("form"), "frame": v.get("frame"),
74+ }
75+
76+
77+class SecEdgarConnector:
78+ source_id = SOURCE_ID
79+
80+ def __init__(self, user_agent: str | None = None, transport: httpx.BaseTransport | None = None):
81+ self.user_agent = user_agent if user_agent is not None else get_settings().sec_edgar_user_agent
82+ self._client = httpx.Client(timeout=60.0, transport=transport, headers={"User-Agent": self.user_agent or "unset",
83+ "Accept-Encoding": "gzip"})
84+ self._last = 0.0
85+
86+ @property
87+ def configured(self) -> bool:
88+ return bool(self.user_agent and "@" in self.user_agent)
89+
90+ def status(self) -> dict[str, Any]:
91+ return {"source_id": SOURCE_ID, "status": "active" if self.configured else "pending_configuration",
92+ "reason": None if self.configured else "SEC_EDGAR_USER_AGENT (name + e-mail) not set"}
93+
94+ def _get(self, url: str) -> dict[str, Any]:
95+ if not self.configured:
96+ raise RuntimeError("SEC EDGAR connector pending configuration: set SEC_EDGAR_USER_AGENT")
97+ wait = 0.11 - (time.monotonic() - self._last)
98+ if wait > 0:
99+ time.sleep(wait)
100+ self._last = time.monotonic()
101+ r = self._client.get(url)
102+ r.raise_for_status()
103+ return r.json()
104+
105+ def company_tickers(self) -> dict[str, Any]:
106+ return self._get("https://www.sec.gov/files/company_tickers.json")
107+
108+ def companyfacts(self, cik: int) -> dict[str, Any]:
109+ return self._get(f"{BASE}/api/xbrl/companyfacts/CIK{cik:010d}.json")
added connectors/market/pyproject.toml +14 −0
@@ -0,0 +1,14 @@
1+[project]
2+name = "findata-market-firstrate"
3+version = "0.1.0"
4+requires-python = ">=3.12"
5+dependencies = ["findata-core", "findata-schema", "findata-quality", "httpx>=0.28", "pyarrow>=18.0", "polars>=1.12"]
6+[tool.uv.sources]
7+findata-core = { workspace = true }
8+findata-schema = { workspace = true }
9+findata-quality = { workspace = true }
10+[build-system]
11+requires = ["hatchling"]
12+build-backend = "hatchling.build"
13+[tool.hatch.build.targets.wheel]
14+packages = ["src/findata_market_firstrate"]
added connectors/market/src/findata_market_firstrate/__init__.py +3 −0
@@ -0,0 +1,3 @@
1+from findata_market_firstrate.client import FRDClient, FRDError # noqa: F401
2+from findata_market_firstrate.connector import FirstRateMarketConnector # noqa: F401
3+from findata_market_firstrate.archive import iter_archive_files, parse_bar_file, ticker_from_filename # noqa: F401
added connectors/market/src/findata_market_firstrate/archive.py +131 −0
@@ -0,0 +1,131 @@
1+"""Zip archive handling and CSV → canonical Arrow conversion for FirstRate bar files.
2+
3+Vendor file facts: `{TICKER}_{period}_{timeframe}_{adjustment}.txt` (update archives) or `{TICKER}_{timeframe}.txt`
4+(full archives), no header, `yyyy-MM-dd HH:mm:ss,O,H,L,C,V` (daily: `yyyy-MM-dd,O,H,L,C,V[,OI]`), FX intraday splits
5+date and time into two fields, line endings may mix LF/CRLF, nested zips occur in bundle archives.
6+"""
7+from __future__ import annotations
8+
9+import io
10+import re
11+import zipfile
12+from collections.abc import Iterator
13+from datetime import datetime
14+from pathlib import Path
15+
16+import polars as pl
17+import pyarrow as pa
18+
19+from findata_schema import BARS_SCHEMA, Adjustment, AssetClass, Interval
20+
21+VENDOR_TF = {"1min": Interval.m1, "5min": Interval.m5, "30min": Interval.m30, "1hour": Interval.h1, "1day": Interval.d1}
22+VENDOR_ADJ = {"UNADJUSTED": Adjustment.raw, "adj_split": Adjustment.split, "adj_splitdiv": Adjustment.split_div}
23+_TICKER_RE = re.compile(r"^([A-Za-z0-9.\-]+?)(?:[_-](?:full|day|week|month|1min|5min|30min|1hour|1day|UNADJUSTED|adj_split|adj_splitdiv|contin.*))*\.(txt|csv)$", re.I)
24+
25+
26+def ticker_from_filename(name: str) -> str:
27+ base = Path(name).name
28+ m = _TICKER_RE.match(base)
29+ if m:
30+ return m.group(1).upper()
31+ return base.split("_")[0].upper()
32+
33+
34+def iter_archive_files(zip_path: Path) -> Iterator[tuple[str, bytes]]:
35+ """Yield (member_name, bytes) for every data file, descending into nested zips."""
36+ with zipfile.ZipFile(zip_path) as zf:
37+ for info in zf.infolist():
38+ if info.is_dir():
39+ continue
40+ name = info.filename
41+ if name.lower().endswith(".zip"):
42+ data = zf.read(info)
43+ with zipfile.ZipFile(io.BytesIO(data)) as inner:
44+ for inner_info in inner.infolist():
45+ if not inner_info.is_dir() and inner_info.filename.lower().endswith((".txt", ".csv")):
46+ yield f"{name}!/{inner_info.filename}", inner.read(inner_info)
47+ elif name.lower().endswith((".txt", ".csv")):
48+ yield name, zf.read(info)
49+
50+
51+def parse_bar_file(data: bytes, symbol: str, asset_class: AssetClass, interval: Interval, adjustment: Adjustment,
52+ source_id: str, ingested_at: datetime, source_tz: str = "America/New_York",
53+ currency: str = "USD", venue: str | None = None, revision_id: int = 1) -> tuple[pa.Table, int]:
54+ """Parse one vendor CSV into the canonical bars table. Returns (table, raw_row_count).
55+
56+ Duplicate (symbol, timestamp) rows are *kept* here and flagged downstream; we never drop source records silently.
57+ Unparseable timestamps (e.g. non-existent local time at DST spring-forward) are dropped from the typed table and
58+ counted in `raw_row_count - table.num_rows`.
59+ """
60+ text = data.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n")
61+ lines = [ln for ln in text.split("\n") if ln.strip()]
62+ if lines and lines[0].lower().startswith(("datetime", "date", "timestamp")):
63+ lines = lines[1:]
64+ raw_rows = len(lines)
65+ if raw_rows == 0:
66+ return pa.table({f.name: pa.array([], type=f.type) for f in BARS_SCHEMA}, schema=BARS_SCHEMA), 0
67+ ncols = len(lines[0].split(","))
68+ buf = "\n".join(lines).encode()
69+ if ncols == 7 and re.match(r"^\d{8},\d{2}:\d{2}", lines[0]):
70+ names = ["d", "t", "open", "high", "low", "close", "volume"]
71+ df = pl.read_csv(buf, has_header=False, new_columns=names, infer_schema_length=0)
72+ df = df.with_columns((pl.col("d") + " " + pl.col("t")).alias("dt_raw")).drop("d", "t")
73+ fmt = "%Y%m%d %H:%M:%S"
74+ else:
75+ names = ["dt_raw", "open", "high", "low", "close", "volume"] + (["open_interest"] if ncols == 7 else [])
76+ if ncols not in (6, 7):
77+ raise ValueError(f"unexpected column count {ncols} in {symbol}")
78+ df = pl.read_csv(buf, has_header=False, new_columns=names, infer_schema_length=0)
79+ fmt = "%Y-%m-%d %H:%M:%S" if interval != Interval.d1 else None
80+ for c in ("open", "high", "low", "close", "volume"):
81+ df = df.with_columns(pl.col(c).str.strip_chars().cast(pl.Float64, strict=False))
82+ if interval == Interval.d1:
83+ local = pl.col("dt_raw").str.strip_chars().str.slice(0, 10).str.to_date("%Y-%m-%d", strict=False).cast(pl.Datetime("us"))
84+ else:
85+ local = pl.col("dt_raw").str.strip_chars().str.to_datetime(fmt, strict=False, time_unit="us")
86+ df = df.with_columns(local.alias("local_naive"))
87+ df = df.filter(pl.col("local_naive").is_not_null() & pl.col("open").is_not_null() & pl.col("close").is_not_null())
88+ df = df.with_columns(
89+ pl.col("local_naive").dt.replace_time_zone(source_tz, ambiguous="earliest", non_existent="null").alias("ts_local")
90+ ).filter(pl.col("ts_local").is_not_null())
91+ df = df.with_columns(
92+ pl.col("ts_local").dt.convert_time_zone("UTC").alias("timestamp_utc"),
93+ pl.col("ts_local").dt.date().alias("session_date"),
94+ )
95+ n = df.height
96+ out = pl.DataFrame({
97+ "instrument_id": pl.repeat(f"{source_id}:{asset_class.value}:{symbol}", n, eager=True),
98+ "symbol_as_reported": pl.repeat(symbol, n, eager=True),
99+ "venue": pl.repeat(venue, n, eager=True, dtype=pl.Utf8),
100+ "asset_class": pl.repeat(asset_class.value, n, eager=True),
101+ "interval": pl.repeat(interval.value, n, eager=True),
102+ "timestamp_utc": df["timestamp_utc"],
103+ "session_date": df["session_date"],
104+ "open": df["open"], "high": df["high"], "low": df["low"], "close": df["close"],
105+ "volume": df["volume"].fill_null(0.0),
106+ "currency": pl.repeat(currency, n, eager=True),
107+ "trade_count": pl.repeat(None, n, eager=True, dtype=pl.Int32),
108+ "vwap": pl.repeat(None, n, eager=True, dtype=pl.Float64),
109+ "adjustment_type": pl.repeat(adjustment.value, n, eager=True),
110+ "source_id": pl.repeat(source_id, n, eager=True),
111+ "ingested_at": pl.repeat(ingested_at, n, eager=True, dtype=pl.Datetime("us", "UTC")),
112+ "quality_flags": pl.repeat([], n, eager=True, dtype=pl.List(pl.Utf8)),
113+ "revision_id": pl.repeat(revision_id, n, eager=True, dtype=pl.Int32),
114+ })
115+ return out.to_arrow().cast(BARS_SCHEMA), raw_rows
116+
117+
118+def parse_meta_pairs(data: bytes) -> list[tuple[str, float]]:
119+ """Splits/dividends per-ticker files: `yyyy-MM-dd,value` lines."""
120+ out = []
121+ for ln in data.decode("utf-8", errors="replace").splitlines():
122+ ln = ln.strip()
123+ if not ln or ln.lower().startswith("date"):
124+ continue
125+ parts = ln.split(",")
126+ if len(parts) >= 2:
127+ try:
128+ out.append((parts[0].strip(), float(parts[1])))
129+ except ValueError:
130+ continue
131+ return out
added connectors/market/src/findata_market_firstrate/client.py +144 −0
@@ -0,0 +1,144 @@
1+"""HTTP client for the FirstRate Data API. The user id is appended at request time and never logged.
2+
3+Vendor facts (verified 2026-11-01): auth = `userid` query parameter; archive endpoints answer HTTP 302 to a CDN
4+(x01.frd-data.com / backblazeb2.com) serving a zip; `last_update` returns a bare date (fx uses yyyyMMdd);
5+no documented rate limit — we still cap concurrency and sleep between metadata calls.
6+"""
7+from __future__ import annotations
8+
9+import hashlib
10+import random
11+import time
12+from dataclasses import dataclass
13+from datetime import date, datetime
14+from pathlib import Path
15+from typing import Any
16+
17+import httpx
18+
19+from findata_core.redact import redact
20+
21+BASE_URL = "https://firstratedata.com/api"
22+FUNDAMENTALS_BASE_URL = "https://firstratedata.com/api-f"
23+CHUNK = 4 * 1024 * 1024
24+MAX_RETRIES = 5
25+TIMEOUT_META = 60.0
26+TIMEOUT_DOWNLOAD = 7200.0
27+DEFAULT_TRANSPORT: httpx.BaseTransport | None = None # tests inject a MockTransport here
28+
29+
30+class FRDError(RuntimeError):
31+ pass
32+
33+
34+@dataclass
35+class Downloaded:
36+ path: Path
37+ sha256: str
38+ bytes: int
39+ final_url_host: str
40+ fetched_at: datetime
41+
42+
43+class FRDClient:
44+ def __init__(self, userid: str, base_url: str = BASE_URL, min_interval_s: float = 1.0,
45+ transport: httpx.BaseTransport | None = None):
46+ if not userid:
47+ raise FRDError("FRD_USERID is not configured")
48+ self._userid = userid
49+ self.base_url = base_url.rstrip("/")
50+ self.min_interval_s = min_interval_s
51+ self._last_call = 0.0
52+ self._client = httpx.Client(follow_redirects=True, timeout=TIMEOUT_META, transport=transport or DEFAULT_TRANSPORT,
53+ headers={"User-Agent": "FinDataOS/0.1 (+private research lakehouse)"})
54+
55+ # ----------------------------------------------------------------- helpers
56+ def _url(self, endpoint: str, params: dict[str, Any]) -> str:
57+ q = {k: str(v) for k, v in params.items() if v is not None}
58+ q["userid"] = self._userid
59+ return str(httpx.URL(f"{self.base_url}/{endpoint}", params=q))
60+
61+ def sanitized_url(self, endpoint: str, params: dict[str, Any]) -> str:
62+ q = {k: str(v) for k, v in params.items() if v is not None}
63+ return str(httpx.URL(f"{self.base_url}/{endpoint}", params=q)) + "&userid=<REDACTED>"
64+
65+ def _throttle(self) -> None:
66+ wait = self.min_interval_s - (time.monotonic() - self._last_call)
67+ if wait > 0:
68+ time.sleep(wait)
69+ self._last_call = time.monotonic()
70+
71+ def _redact(self, text: Any) -> str:
72+ return redact(text, [self._userid])
73+
74+ def _get_text(self, endpoint: str, params: dict[str, Any]) -> str:
75+ last: Exception | None = None
76+ for attempt in range(1, MAX_RETRIES + 1):
77+ self._throttle()
78+ try:
79+ r = self._client.get(self._url(endpoint, params))
80+ if r.status_code in (429, 500, 502, 503, 504):
81+ raise FRDError(f"HTTP {r.status_code} from {endpoint}")
82+ if r.status_code != 200:
83+ raise FRDError(f"HTTP {r.status_code} from {endpoint}: {self._redact(r.text[:200])}")
84+ return r.text
85+ except (httpx.TransportError, FRDError) as e: # retry
86+ last = e
87+ if isinstance(e, FRDError) and not any(c in str(e) for c in ("429", "500", "502", "503", "504")):
88+ raise FRDError(self._redact(e)) from None
89+ time.sleep(min(60, 2 ** attempt) + random.uniform(0, 1))
90+ raise FRDError(self._redact(f"{endpoint} failed after {MAX_RETRIES} attempts: {last}"))
91+
92+ # ----------------------------------------------------------------- metadata
93+ def last_update(self, type_: str, full: bool = False) -> date:
94+ txt = self._get_text("last_update", {"type": type_, "is_full_update": "true" if full else None}).strip()
95+ for fmt in ("%Y-%m-%d", "%Y%m%d"):
96+ try:
97+ return datetime.strptime(txt[:10] if fmt == "%Y-%m-%d" else txt[:8], fmt).date()
98+ except ValueError:
99+ continue
100+ raise FRDError(f"unparseable last_update for {type_}: {self._redact(txt[:40])!r}")
101+
102+ def ticker_listing(self, type_: str) -> str:
103+ return self._get_text("ticker_listing", {"type": type_})
104+
105+ def update_log(self, type_: str, log_type: str) -> str:
106+ return self._get_text("update_log", {"type": type_, "log_type": log_type})
107+
108+ # ----------------------------------------------------------------- archives
109+ def download(self, endpoint: str, params: dict[str, Any], dest: Path) -> Downloaded:
110+ """Stream an archive to `dest` (atomic: `.part` then rename), computing SHA-256 on the fly."""
111+ dest.parent.mkdir(parents=True, exist_ok=True)
112+ part = dest.with_suffix(dest.suffix + ".part")
113+ last: Exception | None = None
114+ for attempt in range(1, MAX_RETRIES + 1):
115+ self._throttle()
116+ h = hashlib.sha256()
117+ size = 0
118+ try:
119+ with self._client.stream("GET", self._url(endpoint, params), timeout=TIMEOUT_DOWNLOAD) as r:
120+ if r.status_code != 200:
121+ body = r.read()[:300]
122+ raise FRDError(f"HTTP {r.status_code} downloading {endpoint}: {self._redact(body)}")
123+ ctype = r.headers.get("content-type", "")
124+ if "text/html" in ctype:
125+ body = r.read()[:300]
126+ raise FRDError(f"HTML instead of archive from {endpoint}: {self._redact(body)}")
127+ with open(part, "wb") as f:
128+ for chunk in r.iter_bytes(CHUNK):
129+ f.write(chunk)
130+ h.update(chunk)
131+ size += len(chunk)
132+ host = r.url.host
133+ part.replace(dest)
134+ return Downloaded(dest, h.hexdigest(), size, host, datetime.now().astimezone())
135+ except (httpx.TransportError, FRDError, OSError) as e:
136+ last = e
137+ part.unlink(missing_ok=True)
138+ if isinstance(e, FRDError) and "HTTP 4" in str(e):
139+ raise FRDError(self._redact(e)) from None
140+ time.sleep(min(120, 5 * 2 ** attempt) + random.uniform(0, 2))
141+ raise FRDError(self._redact(f"download {endpoint} failed after {MAX_RETRIES} attempts: {last}"))
142+
143+ def close(self) -> None:
144+ self._client.close()
added connectors/market/src/findata_market_firstrate/connector.py +173 −0
@@ -0,0 +1,173 @@
1+from __future__ import annotations
2+
3+import csv
4+import hashlib
5+import io
6+import json
7+from collections.abc import Iterator
8+from datetime import UTC, date, datetime
9+from pathlib import Path
10+from typing import Any
11+
12+from findata_core.config import get_settings
13+from findata_market_firstrate.archive import (VENDOR_ADJ, VENDOR_TF, iter_archive_files, parse_bar_file,
14+ parse_meta_pairs, ticker_from_filename)
15+from findata_market_firstrate.client import FRDClient
16+from findata_quality import validate_bars
17+from findata_schema import AssetClass, Interval
18+from findata_schema.connector import (Capabilities, Checkpoint, Connector, FetchRequest, FetchResult,
19+ NormalizedBatch, ValidationReport)
20+
21+SOURCE_ID = "firstrate"
22+SOURCE_TZ = {"crypto": "UTC"} # everything else is US Eastern
23+
24+
25+class FirstRateMarketConnector(Connector):
26+ source_id = SOURCE_ID
27+
28+ def __init__(self, client: FRDClient | None = None):
29+ self._client = client
30+
31+ @property
32+ def client(self) -> FRDClient:
33+ if self._client is None:
34+ self._client = FRDClient(get_settings().frd_userid.get_secret_value())
35+ return self._client
36+
37+ # ------------------------------------------------------------------ discover / capabilities
38+ def discover(self) -> dict[str, Any]:
39+ out: dict[str, Any] = {"source_id": SOURCE_ID, "probed_at": datetime.now(UTC).isoformat(), "last_update": {}}
40+ for t in ("stock", "etf", "futures", "index", "fx", "crypto", "options"):
41+ try:
42+ out["last_update"][t] = self.client.last_update(t).isoformat()
43+ except Exception as e: # noqa: BLE001
44+ out["last_update"][t] = f"error: {e}"
45+ return out
46+
47+ def capabilities(self) -> Capabilities:
48+ return Capabilities(
49+ source_id=SOURCE_ID,
50+ asset_classes=tuple(a.value for a in AssetClass),
51+ intervals=("1m", "5m", "30m", "1h", "1d"),
52+ adjustments=("raw", "split", "split_div", "continuous"),
53+ supports_symbol_filter=False,
54+ supports_date_range=False,
55+ incremental_windows=("day", "week", "month"),
56+ license_class="frd-private",
57+ notes={"raw_only_for": ["1m", "1d"], "zero_volume_bars": "absent at source",
58+ "timestamp_convention": "bar_open", "timezone": "America/New_York (crypto: UTC)"},
59+ )
60+
61+ # ------------------------------------------------------------------ fetch
62+ def fetch_range(self, request: FetchRequest, dest_dir: Path) -> FetchResult:
63+ p = dict(request.params)
64+ endpoint = p.pop("endpoint", "data_file")
65+ asset = p.get("type", "stock")
66+ vendor_last = None
67+ try:
68+ vendor_last = self.client.last_update(asset, full=(p.get("period") == "full"))
69+ except Exception: # noqa: BLE001
70+ vendor_last = None
71+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
72+ label = "_".join(f"{k}-{v}" for k, v in sorted(p.items()))
73+ dest = dest_dir / f"{endpoint}__{label}__{stamp}.zip"
74+ got = self.client.download(endpoint, p, dest)
75+ file_count = None
76+ try:
77+ import zipfile
78+ with zipfile.ZipFile(got.path) as zf:
79+ file_count = sum(1 for i in zf.infolist() if not i.is_dir())
80+ except Exception: # noqa: BLE001
81+ pass
82+ return FetchResult(request=request, path=got.path, sha256=got.sha256, bytes=got.bytes, fetched_at=got.fetched_at,
83+ vendor_last_update=vendor_last, file_count=file_count,
84+ source_url_sanitized=self.client.sanitized_url(endpoint, p))
85+
86+ # ------------------------------------------------------------------ normalize
87+ def normalize(self, result: FetchResult, **kwargs: Any) -> Iterator[NormalizedBatch]:
88+ p = result.request.params
89+ asset = AssetClass(p.get("type", "stock"))
90+ interval = VENDOR_TF[p.get("timeframe", "1min")]
91+ adjustment = VENDOR_ADJ.get(p.get("adjustment", "UNADJUSTED"))
92+ if adjustment is None:
93+ from findata_schema import Adjustment
94+ adjustment = Adjustment.continuous
95+ ingested_at = datetime.now(UTC)
96+ only: set[str] | None = set(kwargs.get("symbols") or []) or None
97+ tz = SOURCE_TZ.get(asset.value, "America/New_York")
98+ for name, data in iter_archive_files(result.path):
99+ symbol = ticker_from_filename(name)
100+ if only and symbol not in only:
101+ continue
102+ table, raw_rows = parse_bar_file(data, symbol, asset, interval, adjustment, SOURCE_ID, ingested_at, source_tz=tz)
103+ if table.num_rows == 0:
104+ continue
105+ sd = table.column("session_date").to_pylist()
106+ yield NormalizedBatch(table=table, symbol=symbol, session_dates=(min(sd), max(sd)),
107+ row_count=table.num_rows, raw_rows=raw_rows)
108+
109+ # ------------------------------------------------------------------ validate
110+ def validate(self, batch: NormalizedBatch) -> ValidationReport:
111+ interval = Interval(batch.table.column("interval")[0].as_py())
112+ table, anomalies = validate_bars(batch.table, interval)
113+ batch.table = table
114+ flagged = sum(1 for f in table.column("quality_flags").to_pylist() if f)
115+ errors = [a for a in anomalies if a["severity"] == "error"]
116+ return ValidationReport(rows=table.num_rows, flagged_rows=flagged, anomalies=anomalies, ok=not errors)
117+
118+ # ------------------------------------------------------------------ checkpoint
119+ def checkpoint(self, result: FetchResult, batches_published: int) -> Checkpoint:
120+ p = result.request.params
121+ return Checkpoint(scope=f"dataset:{result.request.dataset_id}", key="last_archive",
122+ value={"sha256": result.sha256, "vendor_last_update": result.vendor_last_update.isoformat()
123+ if result.vendor_last_update else None, "params": p,
124+ "fetched_at": result.fetched_at.isoformat(), "batches": batches_published})
125+
126+ # ------------------------------------------------------------------ reference data
127+ def ticker_listing(self, type_: str) -> list[dict[str, Any]]:
128+ txt = self.client.ticker_listing(type_)
129+ rows = list(csv.DictReader(io.StringIO(txt)))
130+ out = []
131+ for r in rows:
132+ sym = (r.get("Ticker") or "").strip()
133+ if not sym:
134+ continue
135+ out.append({"symbol": sym, "name": (r.get("Name") or "").strip() or None,
136+ "first_date": _d(r.get("First Date")), "last_date": _d(r.get("Last Date")),
137+ "status": "delisted" if sym.endswith("-DELISTED") else "active", "asset_class": type_})
138+ return out
139+
140+ def delisted_log(self) -> list[dict[str, Any]]:
141+ txt = self.client.update_log("stock", "delisted")
142+ return [{"symbol": r["Ticker"].strip(), "last_date": _d(r.get("Last Date"))}
143+ for r in csv.DictReader(io.StringIO(txt)) if r.get("Ticker")]
144+
145+ def meta_actions(self, type_: str, kind: str, dest_dir: Path) -> tuple[FetchResult, list[dict[str, Any]]]:
146+ """kind = splits | dividends → list of {symbol, ex_date, value}."""
147+ req = FetchRequest(dataset_id=f"corporate_actions.{type_}", params={"endpoint": "meta_file", "type": type_, "metafile_type": kind})
148+ res = self.fetch_range(req, dest_dir)
149+ rows = []
150+ for name, data in iter_archive_files(res.path):
151+ if name.startswith("_") or "readme" in name.lower():
152+ continue
153+ sym = Path(name).stem.upper()
154+ for d, v in parse_meta_pairs(data):
155+ rows.append({"symbol": sym, "ex_date": d, "value": v, "action_type": "split" if kind == "splits" else "dividend"})
156+ return res, rows
157+
158+
159+def _d(s: str | None) -> date | None:
160+ if not s:
161+ return None
162+ try:
163+ return date.fromisoformat(s.strip()[:10])
164+ except ValueError:
165+ return None
166+
167+
168+def sanitized_request(params: dict[str, Any]) -> str:
169+ return json.dumps({k: v for k, v in params.items() if k != "userid"}, sort_keys=True)
170+
171+
172+def digest_params(params: dict[str, Any]) -> str:
173+ return hashlib.sha256(sanitized_request(params).encode()).hexdigest()[:16]
added docs/ARCHITECTURE.md +63 −0
@@ -0,0 +1,63 @@
1+# FinData OS — Architecture
2+
3+```
4+ FirstRate Data (vendor) SEC EDGAR (pending configuration)
5+ market zip archives │ /api-f JSON fundamentals │
6+ ▼ ▼
7+ connectors/market/firstrate connectors/fundamentals/{firstrate,sec_edgar}
8+ discover · capabilities · fetch_range · normalize · validate · checkpoint
9+ │
10+ ▼
11+ services/ingest ── single worker process (lake writer lock) ──────────────────────────────────────
12+ queue (Postgres, SKIP LOCKED, retries/backoff/jitter, dead-letter, checkpoints, resume)
13+ tasks: metadata · incremental · backfill · derive(5m) · compaction · fundamentals · quality · backup · gc
14+ lake: raw/ (immutable zips + sha256) → curated/<dataset>/{delta|base}/… Parquet ZSTD → manifests/vN.json
15+ │
16+ ┌────────────────────────┴───────────────────────┐
17+ ▼ ▼
18+ PostgreSQL 17 (metadata) DuckDB (read side, in-process, read-only)
19+ users/sessions/api_keys/audit · datasets/versions/ lake_files planner → read_parquet(file list)
20+ lake_files · instruments/aliases · corporate_actions sandboxed SQL (allowed_directories = lake only)
21+ coverage · quality · jobs/runs/checkpoints · exports
22+ fundamentals_facts (PIT, revisions) · earnings
23+ │
24+ ▼
25+ services/api FastAPI :8600 (127.0.0.1) — /api/v1 (OpenAPI), /metrics, static console (apps/web/out)
26+ │ session cookie + CSRF (owner, TOTP MFA) · scoped API keys · rate limit · audit · request ids
27+ ▼
28+ MacLustr Tunnel: node wg1 10.67.0.10 ⇄ BHS64 (Caddy TLS) ⇄ https://findata.spboucher.ai
29+```
30+
31+## Storage layout (M3U96a, `~/findata-lake`)
32+| Path | Content | Mutability |
33+|---|---|---|
34+| `raw/<source>/<dataset>/<year>/*.zip` | vendor archives as received; sha256 + sanitized request in `raw_archives` | immutable (full archives deleted after publication, hash kept) |
35+| `curated/<dataset>/delta/session_date=D/part-*.parquet` | one file per session, all symbols, sorted (symbol, ts) | replaced as a whole when the session is re-published |
36+| `curated/<dataset>/base/symbol=S/year=Y/part-*.parquet` | compacted / backfilled history | replaced per (symbol, year) |
37+| `manifests/<dataset>/vN.json` | file list + sha256 per version (snapshot id = sha256 of the list) | append-only |
38+| `_retired/` | files removed from the live set, kept 7 days so older snapshots remain reproducible | GC |
39+| `exports/` | export results, expire after 72 h | GC |
40+
41+**Invariant**: for every dataset version, each `(instrument_id, timestamp_utc)` is in exactly one live file. Enforced by: session-level supersede for deltas, delta-date exclusion in backfills, duplicate assertion in compaction, and a `duplicate` quality flag on rows inside a file.
42+
43+**Publication** is one Postgres transaction (`lake_files` add/retire, `dataset_versions` insert, `datasets.current_version`), after the Parquet files were written to `_tmp/` and atomically renamed. Readers choose files through `lake_files` (by version), so a half-written state is never visible.
44+
45+## Why these choices
46+- **Parquet + DuckDB + Postgres**: analytical reads over columnar files with predicate pushdown; metadata, auth and job state need transactions. DuckDB is never a multi-writer service: the API opens read-only in-memory connections, the worker writes Parquet files only.
47+- **Session-date delta files**: a daily update touches ~6 500 symbols; one file per session (≈12 MB) keeps the file count linear in days, not symbols × days. Monthly compaction turns deltas into per-symbol yearly files for fast single-symbol multi-year scans.
48+- **Adjustments at query time** from `corporate_actions` (vendor splits/dividends files): the lake stores one canonical raw series; `adjustment=split|split_div` is an explicit, reproducible transformation. Vendor-adjusted archives are therefore not duplicated on disk (they could be ingested as separate datasets if a reconciliation study needs them).
49+- **Postgres-backed queue** (no Redis): one writer, a few jobs per day, durability across reboots and `SKIP LOCKED` are all that is needed.
50+- **Fundamentals in Postgres** (long format): PIT queries are `DISTINCT ON`-style SQL with indexes on (entity, metric, period_end, available_at); volumes are modest (≈ 76 metrics × ~100 periods per entity).
51+
52+## Capacity model
53+See `docs/DISCOVERY.md` §5. Budget: curated ≤ 250 GB soft limit, 60 GB free hard stop (ingestion refuses to write below it). Measured: 1 session of 6 515 stocks = 66 MB CSV → ≈ 12–14 MB Parquet.
54+
55+## Data flows
56+1. **Incremental (daily)**: scheduler → `last_update(stock)` → skip if unchanged → download `period=day` zip → raw → normalize per file → group by session → validate → one delta file per session → publish (supersede) → coverage/quality → checkpoint → enqueue `derive 5m`.
57+2. **Backfill (operator)**: `period=full&ticker_range=L` → stream per ticker file → exclude sessions already in deltas → base files per (symbol, year) → publish per symbol (resumable via `done_symbols`).
58+3. **Compaction (weekly)**: deltas older than 35 days → merged into base files → duplicate assertion → publish.
59+4. **Fundamentals (daily)**: tracked tickers → `/v1/financials/all/{t}/{quarterly|annual}` + earnings → compare with latest revision → insert new revision when changed (`restatement_flag`).
60+5. **Backup (daily)**: `pg_dump | zstd` + `rsync -a --delete` of the lake to M4M64a; remote file count verified; `last_backup` recorded. Restore drill: `findata restore-drill`.
61+
62+## Security model
63+Owner-only by default. Browser: HttpOnly/Secure/SameSite=Strict session cookie, CSRF double-submit header, TOTP MFA (scopes empty until the second factor passes). Programmatic: `Authorization: Bearer fd_<prefix>.<secret>` keys, HMAC-hashed at rest, scoped, expirable, revocable, per-key rate limit and daily usage accounting. Vendor credentials live only in `.env` (mode 600) on the node; every log line and job error passes through a redaction filter; the HTTP client never logs URLs. The API binds to 127.0.0.1 (reached through the node's wg1 proxy by Caddy on BHS64). CSP `default-src 'self'`, HSTS, nosniff, frame-ancestors none. SQL playground: single read-only statement, keyword deny-list, DuckDB `allowed_directories` restricted to the lake, external access disabled, timeout and row caps. Exports are scoped to the requesting principal and expire.
added docs/DATA_DICTIONARY.md +60 −0
@@ -0,0 +1,60 @@
1+# FinData OS — Data dictionary and conventions
2+
3+## Bars (`market_bars.*`) — Arrow schema v1 (`GET /api/v1/catalog/schema/bars`)
4+| Column | Type | Meaning |
5+|---|---|---|
6+| instrument_id | string | `firstrate:<asset_class>:<SYMBOL>` (vendor-keyed). Delisted: `…:<SYMBOL>-DELISTED` |
7+| symbol_as_reported | string | vendor file ticker (uppercase) |
8+| venue | string? | unknown from vendor (US consolidated); NULL |
9+| asset_class | string | stock, etf, futures, index, fx, crypto |
10+| interval | string | 1m, 5m, 30m, 1h, 1d |
11+| **timestamp_utc** | timestamp[us, UTC] | **bar OPEN** instant. The 09:30 bar covers 09:30:00–09:30:59 ET |
12+| session_date | date | trading session date in the exchange timezone (America/New_York; crypto: UTC date) |
13+| open/high/low/close | float64 | prices in `currency` |
14+| volume | float64 | shares; integral for raw data (fractional only in vendor-adjusted series) |
15+| currency | string | USD |
16+| trade_count | int32? | number of 1-minute bars aggregated (derived datasets only) |
17+| vwap | float64? | volume-weighted close of the aggregated bars (derived datasets only; NULL when volume = 0) |
18+| adjustment_type | string | raw (stored) · split · split_div (query-time) · continuous (futures) |
19+| source_id | string | firstrate |
20+| ingested_at | timestamp[us, UTC] | when FinData normalized the row |
21+| quality_flags | list<string> | see below; never alter prices |
22+| revision_id | int32 | increments when a session is re-published by the vendor |
23+
24+### Sessions, DST, calendars
25+- Vendor timestamps are US Eastern wall-clock (EST/EDT); conversion to UTC uses `zoneinfo America/New_York`. Non-existent local times (spring-forward hour) are dropped and counted (`rows_dropped_unparseable` in the job result); extended hours never overlap the ambiguous fall-back hour.
26+- Extended session 04:00–20:00 ET (last 1-minute bar opens 19:59). Regular session 09:30–16:00; the 16:00 bar is the closing print. Expected regular bars per session = minutes(open→close) + 1 (391 on a normal day, 211 on a 13:00 early close), from `exchange_calendars` XNYS.
27+- **Zero-volume minutes are absent at the source** — FinData never forward-fills or manufactures bars. Coverage = bars present / expected regular bars.
28+- Overnight futures sessions: daily futures bars follow the contract trading day (≈18:00 ET previous day → 17:00); `session_date` is the vendor's date (dataset planned).
29+- Crypto: UTC timestamps, 7 days a week (dataset planned).
30+
31+### Quality flags (row level) and anomaly codes
32+`ohlc_inconsistent`, `nonpositive_price`, `negative_volume`, `zero_volume`, `fractional_volume`, `off_interval`, `duplicate`, `outside_session`, `non_trading_day`, `price_jump` (> 25 % between consecutive bars within a session), `gap` (coverage level). Severity: error (ohlc/nonpositive/negative/duplicate/off_interval/non_trading_day) · warning (price_jump, outside_session) · info (zero_volume, fractional_volume, gap).
33+
34+### Adjustments
35+- Stored data is **raw** (vendor `UNADJUSTED`). `adjustment=split`: prices × Π(1/ratio) and volume × Π(ratio) for splits with ex_date after the bar's session. `adjustment=split_div`: additionally × Π(1 − dividend / last close before ex_date) using the closes present in the requested range (if no prior close is in range the dividend factor is skipped and reported in `meta.adjustment_notes`). Splits and cash dividends only (vendor meta files); no mergers/spin-offs.
36+- Vendor-adjusted history is rewritten after each corporate action; FinData avoids that drift by storing raw bars and applying actions explicitly.
37+
38+### Symbols, delistings, survivorship
39+- Vendor merges ticker changes under the latest symbol (fully since 2022); earlier changes mapped by the vendor. Delisted instruments keep a `-DELISTED` suffix; `instrument_aliases` records the base ticker. The instrument master includes 8 069 delisted tickers (listing of 2026-11-01), so universes can be built without survivorship bias when delisted archives are ingested (planned).
40+- Futures roll policy is the vendor's (fixed dates ≈ 7–10 days before expiry for ES/NQ); three continuous series (unadjusted, ratio, absolute); absolute-adjusted series may be negative (dataset planned).
41+
42+## Fundamentals (`fundamentals_facts`)
43+| Column | Meaning |
44+|---|---|
45+| entity_id | `frd:<TICKER>` (vendor) or `sec:CIK##########` (EDGAR) |
46+| security_id | `firstrate:stock:<TICKER>` when known |
47+| metric | vendor field name (rev, net_inc, eps_d, assets_tot, cash_op, shares, roe, …) — 76 fields, units in `unit` |
48+| value / unit / currency | numeric; USD, USD/share, shares, ratio |
49+| fiscal_period_start/end, fiscal_period_type (q/a), fiscal_year, fiscal_period | vendor period; start is approximated (vendor gives end only) |
50+| filing_date | SEC filing date (vendor) |
51+| published_at | unknown from vendor → NULL |
52+| **available_at** | PIT policy: `filing_date + 1 day 00:00 America/New_York` (fallback period_end + 91 d when filing_date missing, flagged `pit_fallback`) |
53+| observed_at | when FinData ingested this value |
54+| revision_id / restatement_flag / superseded_at | a later ingestion seeing a different value creates revision n+1 with `restatement_flag=true` and supersedes revision n |
55+| source_document / source_id | `SEC 10-Q via FirstRate (filing_date=…)` / `firstrate_fundamentals` |
56+
57+**As-of semantics** (`GET /api/v1/fundamentals/{entity}?as_of=`): visible iff `available_at <= as_of` and (`revision_id = 1` or `observed_at <= as_of`). Revision 1 is the proxy for "as originally filed"; restatements observed after `as_of` are never shown. Limitation: restatements that happened before FinData's first ingestion are not reconstructible from this vendor (SEC EDGAR connector, once configured, provides filing-level history).
58+
59+## Provenance recorded per row / file
60+vendor + product (`sources`), endpoint + sanitized request (`raw_archives.request`), retrieval timestamp (`raw_archives.fetched_at`, `ingested_at`), vendor update date (`raw_archives.vendor_last_update`), archive sha256, Parquet sha256 (`lake_files`), dataset version + snapshot id (`dataset_versions`, `X-Dataset-Version` header), transformation code version (`ingest_runs.code_version` = git short SHA), completeness (`coverage`), license class (`datasets.license_class`).
added docs/DISCOVERY.md +124 −0
@@ -0,0 +1,124 @@
1+# FinData OS — Discovery report (Phase 0)
2+
3+Date: 2026-11-01 (America/Toronto). Author: Claude Code session for the owner (spbou4@icloud.com).
4+All vendor probes below were run **from the storage node M3U96a**, with the vendor user id read from
5+`~/findata-secrets/frd.env` (mode 600) and redacted as `<KEY>` in every log and document.
6+
7+## 1. Cluster inventory and storage node selection
8+
9+Measured on 2026-11-01 (`df -H /`, `diskutil info disk0`, `uptime`, `pm2 jlist`). Three nodes were unreachable at
10+probe time (M4M36, M2M32, M2M32b — SSH timeout) and were excluded.
11+
12+| Node | SSD | Free | RAM | Cores | SMART | Load (1m) | Already hosts |
13+|---|---|---:|---:|---:|---|---:|---|
14+| **M3U96a** | APPLE SSD AP1024Z (1 TB) | **508 GB** | 96 GB | 32 | Verified | 1.8 | admin-ka, vrai-prix, job-ka, sorti-ka, qc26 (≈2 GB RSS total) |
15+| M3U96b | APPLE SSD AP1024Z (1 TB) | 424 GB | 96 GB | 32 | Verified | 1.8 | toveli, autoscholar, UQO_RESEARCH_DB 232 GB, ovh-retired backups 26 GB |
16+| M4M64a | APPLE SSD AP1024Z (1 TB) | 778 GB | 64 GB | 16 | Verified | 2.2 | lou-ka, fabri-ka, ka-stats, uqo-chat, pdb-api, maclustr-www, agentd, maclustr-mobile |
17+| M2U64 | APPLE SSD AP1024Z (1 TB) | 339 GB | 64 GB | 24 | Verified | 1.6 | spb-cloud, vquant, uqo-eval, console, appmanager, nodepages, spboucher-ai, food-ka |
18+| M4M64c | APPLE SSD AP0512Z (512 GB) | 342 GB | 64 GB | 16 | Verified | 2.2 | akos, chat, note |
19+| others | 512 GB class | 216–448 GB | 16–48 GB | 10–18 | Verified | — | — |
20+
21+**Decision: M3U96a.** Rationale:
22+- Largest compute (32 cores, 96 GB RAM) for DuckDB/Polars analytical reads, with 1 TB internal NVMe (SMART verified).
23+- Measured sequential write 5.1 GB/s (`dd` 2 GiB), read 21.8 GB/s (page-cache assisted) — more than enough for Parquet compaction.
24+- 508 GB free fits the capacity model below with ≥2× headroom; M4M64a has more free space but is the agentd/mobile
25+ coordinator node, carries the heaviest Ka apps (lou-ka 1.3 GB RSS) and has half the cores.
26+- PostgreSQL 17.10, Redis 8.8, uv, pm2 6 and Node 25 are already installed; wg1 tunnel peer 10.67.0.10 is up (handshake < 2 min).
27+- Backup target: **M4M64a** (778 GB free, same LAN, 1 TB SSD) via rsync; restore drill documented in `docs/OPERATIONS.md`.
28+- Nothing is wiped or repartitioned: the lake lives in `~/findata-lake/` on the existing APFS volume.
29+
30+Network: LAN 192.168.2.x (1 Gbit+), public egress via Bell Giga Hub (TCP 80/443 + UDP 53 only; WireGuard to BHS64 uses UDP 443).
31+Reverse proxy / TLS: Caddy on BHS64 (OVH Beauharnois), WireGuard hub 10.67.0.1. DNS `findata.spboucher.ai → 51.161.112.61` **verified
32+present** (`dig +short`) before any change; the route is added with `mld deploy` (`tunnel.domain` in the manifest), no other route touched.
33+Existing auth/monitoring: none shared across apps; agentd (M4M64a:9210) polls PM2 + health paths; `mld heal` every 5 min.
34+
35+## 2. Vendor identification
36+
37+"First Trade Data" = **FirstRate Data** (firstratedata.com). Evidence: the doc URL given by the owner
38+(`https://firstratedata.com/c/market-data-api/<KEY>`), the prior platform `hfmarketdata` (spbgit `hfmarketdata.git`, retired 2026-10-02)
39+whose `frd_downloader.py` used `FRD_USERID` against `https://firstratedata.com/api`, and the official docs at
40+`https://firstratedata.com/about/api-docs`, `/about/license`, `/about/FAQ`, `/_readme/stock.txt`, `/fundamental-data/*`.
41+
42+Auth: the user id is a **query parameter** (`userid=<KEY>`) on both the market-data API and the fundamentals API (`/api-f`, where the
43+page says `api_key=` but only `userid=` is accepted — `api_key=` returned HTTP 403 on 2026-11-01). The key therefore appears in URLs:
44+the connector never logs full URLs, redacts the parameter in every exception message, and the HTTP client disables request logging.
45+
46+## 3. Capability matrix (verified 2026-11-01)
47+
48+Legend: **confirmed** = probed with the owner's key and a real response; **doc** = documented, not yet exercised; **unsupported** = vendor
49+says no; **unknown** = not determinable without a paid/bulk call.
50+
51+| Capability | Status | Evidence |
52+|---|---|---|
53+| `last_update?type=stock|etf|futures|index|options` | confirmed | all return `2026-10-30` (crypto `2026-10-31`, fx returns `20261030` — different format!) |
54+| `ticker_listing?type=stock` | confirmed | 16 313 rows (`Ticker,Name,First Date,Last Date`), 8 069 `-DELISTED` suffixes |
55+| `ticker_listing?type=etf` | confirmed | 5 100 rows |
56+| `update_log?type=stock&log_type=delisted` | confirmed | 1 355 rows (`Ticker,Last Date`) |
57+| `meta_file splits` / `dividends` | confirmed | 302 → zip (Backblaze B2) of per-ticker `{date,ratio}` / `{exdate,amount}` files: 2 963 / 3 900 files |
58+| `data_file type=stock period=day timeframe=1min adjustment=UNADJUSTED` | confirmed | 302 → `https://x01.frd-data.com/…zip`, 15.76 MB zip, 6 515 files, 66.4 MB raw, no header, `yyyy-MM-dd HH:mm:ss,O,H,L,C,V`, 04:00–19:59 ET (extended hours), bar **open** timestamps |
59+| `data_file period=full&ticker_range=A-Z` (stock/etf, 1min/5min/30min/1hour/1day, adj_split/adj_splitdiv/UNADJUSTED*) | doc | *UNADJUSTED only 1min + 1day. Bundle size ≈ 34 GB zip / 380 GB raw for stocks+ETFs. **Not downloaded yet** (bounded backfill in Phase 5 only) |
60+| `full_update period=day|week` (re-adjusted histories) | doc | needed to repair adjusted series after splits/dividends |
61+| `delisted_data_file archive_number=1..5 / update=week|year` | doc | archives count inconsistent in docs (4 vs 5) |
62+| futures (continuous ×3 adjustments + individual contracts), index, fx, crypto | confirmed license (last_update) ; doc endpoints | FX intraday = `yyyyMMdd,HH:mm:ss,…` two-field datetime; crypto timestamps UTC; index has no volume; futures daily bars follow the contract trading day |
63+| options (EOD only, CBOE feed, Greeks) | confirmed license ; doc | no intraday options; bundle only; 70 GB zip incl. options |
64+| quotes / trades / ticks | **unsupported** | vendor provides bars only |
65+| Fundamentals `/api-f/v1/financials/all/{ticker}/{annual|quarterly}` | confirmed | AAPL: 14 annual / 59 quarterly records, 76 fields incl. `period_end`, `filing_date`, `fiscal_year`, `fiscal_period`, `actual_year`, `actual_period` |
66+| `/api-f/v1/financials/single/{ticker}?period=FY|Q1..Q4&year=` | doc | |
67+| `/api-f/v1/earnings_release/t/{ticker}`, `/calendar/{start}/{end}` | confirmed | AAPL 115 records (`date, eps_estimate, eps_reported, release_time`) |
68+| `/api-f/v1/metrics/t/{ticker}/{start}/{end}`, `/metrics/last` | confirmed | daily valuation metrics (pe_ttm, market_cap, ev, …) |
69+| `/api-f/v1/short_interest`, `/short_volume`, `/social_sentiment` | doc | parameter tables only |
70+| Fundamentals revisions / restatement history | **unknown** | vendor exposes one value per (ticker, period) with `filing_date`; no "as originally reported vs restated" flag → we snapshot each ingestion run and version rows ourselves (see ARCHITECTURE §PIT) |
71+| Shares outstanding | confirmed (field `shares`, `shares_av`, `shares_av_diluted`) | |
72+| Corporate actions | confirmed splits + cash dividends only | no mergers/spin-offs |
73+| Identifier history | partial | ticker changes merged under latest ticker (since 2022), delisted suffixed `-DELISTED`; no CUSIP/ISIN/CIK from vendor → CIK mapping via SEC `company_tickers.json` (connector `sec_edgar`, pending configuration) |
74+| Rate limits / quotas | none documented ("There are no rate limits") | we still budget 2 concurrent downloads, 1 req/s metadata |
75+| Pagination | none | whole zip archives |
76+| Historical depth | stocks from 2000 (ticker listing first dates), options 2010, short data 2021 | |
77+| Update schedule | docs conflict (23:30 ET / 01:00 ET / 02:00–03:00 ET for full) | policy: poll `last_update` before every incremental job |
78+
79+Sanitized probe transcripts: `docs/evidence/phase0-probes.md`.
80+
81+## 4. License (FirstRate Data, `/about/license`, `/about/FAQ`)
82+
83+- Perpetual private use; data may be stored on own servers, loaded into databases, used for research, backtests, model training, derived
84+ statistics and charts. Attribution to FirstRate Data required when publishing research.
85+- **Prohibited**: redistributing raw data to third parties (CSV downloads, an API returning bars, tables of prices/volumes), reselling, sharing
86+ the user id. Multi-user only within a single institution with a multi-user license.
87+- Consequence for FinData OS: **owner-only by default**; every API token is scoped; the console shows a license banner; no public routes
88+ return bars; exports are owner-scoped and expire. The `license_class` column is `frd-private` on every vendor row.
89+
90+## 5. Capacity model (1-minute data)
91+
92+Measured: one full trading day of 1-min UNADJUSTED bars for 6 515 stocks = 66.4 MB CSV (15.8 MB zip). Parquet ZSTD(3) with typed
93+columns compresses FirstRate CSV ≈ 5–6× (prior hfmarketdata lake: 26.5 G rows in 350 GB incl. options → ≈ 13 bytes/row).
94+
95+| Scenario | Rows | Raw zip kept | Curated Parquet | Total incl. 15 % index/manifests + backup copy ×1 |
96+|---|---:|---:|---:|---:|
97+| Stocks, 1-min UNADJUSTED, 1 year incremental (252 d) | ≈ 7.5 × 10⁷ × … ≈ 1.6 × 10⁸ (avg 100 bars/sym/day × 6 500 × 252) | 4 GB | 2.5 GB | ≈ 15 GB |
98+| Stocks + ETFs, 1-min, full history 2000→2026, one adjustment | ≈ 1.5 × 10¹⁰ | 34 GB (bundle) | ≈ 190 GB | ≈ 500 GB (with backup on M4M64a) |
99+| + 5-min derived | +20 % | — | +40 GB | |
100+| Full vendor bundle all timeframes/adjustments (not planned) | ≈ 2.6 × 10¹⁰ | 70 GB | 350 GB | > 800 GB — exceeds single-node budget |
101+
102+Budget rule adopted: lake ≤ 300 GB on M3U96a (alert at 250 GB, hard stop at 60 GB free), raw zips retained for **incremental** archives
103+only (immutability proof) and deleted after checksum + Parquet publication for **full** archives (the zip is re-downloadable; its SHA-256 is
104+kept in the manifest). Full backfill is executed by letter range and by adjustment on explicit operator request (`findata ingest backfill`).
105+
106+## 6. Existing code and integration points
107+
108+- `hfmarketdata.git` (spbgit): `frd_downloader.py` (job plan, resume manifest, CSV→Parquet via DuckDB, zip quirks: nested zips, mixed
109+ LF/CRLF, FX two-field datetime, volume decimals) and `hfmarketdata/api/fundamentals/edgar_client.py` (SEC companyfacts) are reused as
110+ knowledge; code is rewritten under the FinData contracts (raw immutability, provenance, PIT).
111+- `mld` (M1M32): deployment, tunnel route, heal. Manifest `M1M32:~/dispatch/apps/findata.json`.
112+- agentd (M4M64a): auto-discovers PM2 processes and health paths from the registry.
113+- Postgres 17 local on M3U96a (`findata` role + database created 2026-11-01, localhost only). Redis 8 available (not used in v1: job queue
114+ is Postgres-backed with `SELECT … FOR UPDATE SKIP LOCKED`, durable across reboots).
115+
116+## 7. Blocked / deferred (honest list)
117+
118+- Fundamentals **revision history**: not provided by the vendor; FinData versions rows per ingestion run (`observed_at`) and flags value
119+ changes as `restatement_flag=true`. Historical restatements before the first ingestion are unknowable from this vendor.
120+- SEC EDGAR connector: implemented as a modular connector with fixture tests, **pending configuration** (requires a declared User-Agent
121+ contact e-mail per SEC fair-access policy; no secret).
122+- Full multi-year 1-min backfill: deferred to Phase 5 (representative letter range), on operator request because of disk budget.
123+- Options, futures contracts, FX, crypto, index: capability confirmed; connectors implement the file layouts; **ingestion not run** in
124+ Phase 2 (one licensed dataset end-to-end = US stocks 1-min UNADJUSTED).
added docs/OPERATIONS.md +52 −0
@@ -0,0 +1,52 @@
1+# FinData OS — Operations
2+
3+Node: **M3U96a** (`ssh M3U96a`). App: `~/apps/findata` (git clone, remote-first). Lake: `~/findata-lake`. Secrets: `~/apps/findata/.env` (600; laptop copy `~/Desktop/Cluster/secrets/findata/.env`). Processes (PM2): `findata-api` (:8600), `findata-worker`.
4+
5+## Daily operation
6+```bash
7+ssh M3U96a
8+cd ~/apps/findata && set -a && . ./.env && set +a
9+.venv/bin/findata status # lake usage, dataset versions, queue
10+.venv/bin/findata ingest jobs # last jobs (status, attempts, error)
11+pm2 logs findata-worker --lines 100 # structured JSON logs (secrets redacted)
12+curl -s 127.0.0.1:8600/api/v1/health | jq # alerts (anonymous → status only; use the console for details)
13+```
14+Scheduler (inside the worker, America/New_York): incremental `day` polling from 02:00 every 30 min (skips when the vendor's `last_update` is unchanged), Saturday 06:00 `week` repair, Sunday 07:00 metadata + compaction, 05:00 fundamentals (tracked tickers), 04:00 backup, 06:00 quality + gc.
15+
16+## Manual jobs
17+```bash
18+.venv/bin/findata ingest incremental --type stock --period day [--force]
19+.venv/bin/findata ingest backfill Z --type stock # one letter archive (bounded; check free space first)
20+.venv/bin/findata ingest fundamentals AAPL,MSFT,NVDA
21+.venv/bin/findata ingest derive --dates 2026-10-30
22+.venv/bin/findata ingest compaction --min-age-days 35
23+.venv/bin/findata backup && .venv/bin/findata restore-drill
24+```
25+Jobs can also be enqueued/retried/cancelled from the console (Ingestion page) or `POST /api/v1/admin/jobs`.
26+
27+## Deployment (mld, from the laptop)
28+```bash
29+~/Desktop/cluster-skill/mld deploy findata # pinned to M3U96a; runs post_sync hooks (uv sync, web build, migrate)
30+~/Desktop/cluster-skill/mld status --live
31+```
32+Remote-first: edit on the node, `pm2 restart findata-api findata-worker`, then `git commit && git push origin main` (origin = spbgit `findata.git`).
33+
34+## Backups, RPO / RTO
35+- Daily 04:00 ET: `pg_dump | zstd` (14 kept) + `rsync -a --delete` of `~/findata-lake` (minus `_tmp`, `_retired`, `exports`) → `M4M64a:~/backups/findata/{lake,pgdump}`; remote file count compared with local (`verified`).
36+- **RPO**: 24 h for metadata and curated data (raw archives are re-downloadable from the vendor while the subscription is active; sha256 kept). **RTO**: ≈ 30 min for metadata (restore dump, start API) + lake copy time over LAN (≈ 1 Gbit/s → 100 GB in 15–20 min).
37+- Restore drill: `findata restore-drill --dest /tmp/findata-restore-drill` restores the latest dump into a scratch database, copies one dataset back and verifies every Parquet sha256 against the manifest. Record the output in `docs/STATUS.md`.
38+- Full restore procedure: (1) `createdb findata && zstd -dc dump.sql.zst | psql findata`; (2) `rsync -a M4M64a:~/backups/findata/lake/ ~/findata-lake/`; (3) `findata migrate`; (4) start PM2 processes; (5) `findata status` must show the same versions as the dump.
39+
40+## Monitoring
41+- `GET /metrics` (Prometheus text; allowed from private addresses or admin): `findata_lake_free_gb`, `findata_lake_curated_bytes`, `findata_jobs{status}`, `findata_dataset_freshness_days`, `findata_vendor_lag_days`, `findata_backup_age_hours`, `findata_quality_anomalies_7d`, HTTP counters/latency. Alert rules: `infra/prometheus/findata-alerts.yml`.
42+- `GET /api/v1/health` (authenticated): SSD SMART status, lake budget, worker lock, dead jobs, staleness vs vendor, backup age.
43+- agentd (M4M64a) and `mld heal` keep PM2 processes up; the console Overview shows the same alerts.
44+
45+## Incident playbook
46+| Symptom | Action |
47+|---|---|
48+| `worker_down` alert | `pm2 restart findata-worker`; a second worker is refused by the lake lock (by design) |
49+| job `dead` | inspect `last_error` (console → Ingestion), fix, **Retry** |
50+| `disk_low/critical` | stop backfills, run `gc`, review `_retired/` and `raw/`; raise budget only after adding storage |
51+| vendor 403 / HTML instead of zip | subscription or key issue — check the customer page; never paste the key into chat or code |
52+| stale dataset | vendor publishes 23:30–03:00 ET; `findata ingest incremental --force` after confirming `last_update` |
added docs/SECURITY.md +10 −0
@@ -0,0 +1,10 @@
1+# Security notes
2+- Secrets: `.env` on the node only (mode 600), never in git, never sent to the browser; `Settings` are `SecretStr`; structlog redaction processor + regex on `userid|api_key|token|password|secret=`; httpx logging silenced. Tests assert the vendor id never appears in API responses, OpenAPI, audit, job results or raw-archive metadata.
3+- Auth: argon2id passwords, TOTP (pyotp) with obfuscated secret storage, 12 h sessions (hashed ids), CSRF double-submit on every non-GET browser request, SameSite=Strict + Secure + HttpOnly cookies, login rate limit (10/min/IP).
4+- API keys: `fd_<prefix>.<secret>`, HMAC-SHA256 hashed, scopes `read:market read:fundamentals query export admin`, optional expiry, revocation, per-key rate limit, daily usage rows.
5+- Transport: TLS at the MacLustr Tunnel (Caddy, BHS64); API bound to 127.0.0.1; HSTS; CSP `default-src 'self'`; `X-Frame-Options: DENY`; request ids on every response.
6+- Query sandbox: DuckDB in-memory, `allowed_directories` = lake curated/_retired, `enable_external_access=false`, single SELECT/WITH statement, keyword deny-list, timeout, row cap.
7+- Exports: owner/principal scoped, 72 h expiry, served from `lake/exports` only.
8+- Dependency scanning: `make audit` (pip-audit) in CI (`.github`-less: run on the node before deploy; results in STATUS.md).
9+- Encryption at rest: Apple internal SSD; FileVault is **disabled** on M3U96a by cluster policy (auto-login after power loss, decision 2026-09-10). Backups go over SSH to a LAN node with the same policy. If encryption at rest becomes a requirement, enable FileVault on M3U96a and M4M64a (trade-off: unattended reboot requires a manual unlock).
10+- License: FirstRate Data private use — the platform exposes no public data route; redistribution of bars/tables to third parties is prohibited and surfaced in the console and the API catalog.
added docs/evidence/phase0-probes.md +32 −0
@@ -0,0 +1,32 @@
1+# Phase 0 — sanitized vendor probes (2026-11-01, run on M3U96a)
2+
3+```
4+last_update stock : 2026-10-30
5+last_update etf : 2026-10-30
6+last_update futures : 2026-10-30
7+last_update crypto : 2026-10-31
8+last_update index : 2026-10-30
9+last_update fx : 20261030
10+last_update options : 2026-10-30
11+last_update stock (is_full_update=true): 2026-10-30
12+ticker_listing stock : 16314 lines (header + 16313), 8069 "-DELISTED"
13+ Ticker,Name,First Date,Last Date
14+ A,Agilent Technologies Inc,2000-01-04,2026-10-30
15+ticker_listing etf : 5101 lines
16+update_log delisted : 1356 lines (Ticker,Last Date)
17+meta_file splits : HTTP 302 → https://f004.backblazeb2.com/file/frd-apix03/stock_splits.zip ; 2963 files ; A.txt = "2014-11-03,1.398"
18+meta_file dividends : zip, 3900 files
19+data_file stock day 1min UNADJUSTED : HTTP 302 → https://x01.frd-data.com/stock_update_day_1min_UNADJUSTED_<token>.zip
20+ content-length 15759934 ; 6515 files ; 66413948 bytes raw ; e.g. AAPL_day_1min_UNADJUSTED.txt 900 lines
21+ 2026-10-30 04:00:00,350.76,351.6499,350.04,351.6499,4802
22+ 2026-10-30 09:30:00,346.63,348.51,346.62,348.26,1119437
23+ 2026-10-30 16:00:00,344.8,345.01,344.63,344.79,12902672
24+ 2026-10-30 19:59:00,345.0933,345.1,345.0933,345.1,852
25+api-f v1/financials/all/AAPL/annual?type=json&api_key=<KEY> → 403
26+api-f v1/financials/all/AAPL/annual?type=json&userid=<KEY> → 200 application/json, 22064 bytes, count=14
27+api-f v1/financials/all/AAPL/quarterly → 200, count=59, 76 fields
28+ latest: period_end 2026-06-27, period_type q, filing_date 2026-07-31, fiscal_year 2026, fiscal_period q3, actual_year 2026, actual_period q2
29+api-f v1/earnings_release/t/AAPL → 200, count=115
30+api-f v1/metrics/t/AAPL/2026-01-01/2026-01-10 → 200, count=6
31+SSD: dd 2 GiB write 5.15 GB/s ; read 21.8 GB/s (cached)
32+```
added infra/mld/findata.json +34 −0
@@ -0,0 +1,34 @@
1+{
2+ "app": "findata",
3+ "label": "FinData OS — private financial data lakehouse (findata.spboucher.ai)",
4+ "domain": "findata.spboucher.ai",
5+ "port": 8600,
6+ "health_path": "/api/v1/health",
7+ "dir": "~/apps/findata",
8+ "extra_paths": [],
9+ "sync_excludes": [".git/", ".DS_Store", ".venv/", "apps/web/node_modules/", "apps/web/.next/", "apps/web/out/", "data/", "logs/", "backups/", ".env", "__pycache__/", ".pytest_cache/", ".ruff_cache/", "tests/_tmp/"],
10+ "requires": { "runtimes": ["uv", "node", "pnpm", "pm2", "postgresql@17"], "ram_gb": 6, "disk_gb": 300, "ports": [8600] },
11+ "ram_mb_observed": 900,
12+ "size_mb": 20,
13+ "placement": { "pin": "M3U96a", "prefer": "M3U96a", "avoid": ["M1M32"], "reason": "Storage node chosen in docs/DISCOVERY.md: 1 TB Apple NVMe (508 GB free, SMART verified, 5 GB/s), 32 cores / 96 GB for DuckDB, Postgres 17 + Redis already local, wg1 10.67.0.10. Lake at ~/findata-lake (outside the app dir)." },
14+ "processes": [
15+ { "name": "findata-api", "manager": "pm2", "script": "{{HOME}}/apps/findata/.venv/bin/python", "args": ["-m", "uvicorn", "findata_api.main:app", "--host", "127.0.0.1", "--port", "8600", "--workers", "1", "--proxy-headers", "--forwarded-allow-ips", "10.67.0.1,127.0.0.1"], "interpreter": null, "cwd": "{{HOME}}/apps/findata", "env": {"FINDATA_ROOT": "{{HOME}}/apps/findata", "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"}, "cron_restart": null, "autorestart": true, "max_memory_restart": "6G" },
16+ { "name": "findata-worker", "manager": "pm2", "script": "{{HOME}}/apps/findata/.venv/bin/python", "args": ["-m", "findata_ingest.cli", "worker"], "interpreter": null, "cwd": "{{HOME}}/apps/findata", "env": {"FINDATA_ROOT": "{{HOME}}/apps/findata", "PATH": "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"}, "cron_restart": null, "autorestart": true, "max_memory_restart": "24G" }
17+ ],
18+ "ngrok": null,
19+ "launchd": [],
20+ "env_overrides": {},
21+ "hooks": {
22+ "post_sync": [
23+ "test -f .env || { echo 'ERREUR: .env absent (secrets) — copier ~/Desktop/Cluster/secrets/findata/.env'; exit 1; }; chmod 600 .env",
24+ "export PATH=\"/opt/homebrew/bin:$HOME/.local/bin:$PATH\"; uv sync --frozen 2>&1 | tail -1 && echo ' python deps ok'",
25+ "export PATH=\"/opt/homebrew/bin:$PATH\"; cd apps/web && pnpm install --frozen-lockfile --silent && pnpm build 2>&1 | tail -2 && echo ' web build ok'",
26+ "export PATH=\"/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$PATH\"; set -a; . ./.env; set +a; .venv/bin/python -m findata_ingest.cli migrate && echo ' migrations ok'",
27+ "mkdir -p ~/findata-lake logs && chmod 700 ~/findata-lake && echo ' lake dir ok'"
28+ ],
29+ "post_start": []
30+ },
31+ "ka_repo": false,
32+ "tunnel": { "domain": "findata.spboucher.ai", "gateway": "BHS64", "note": "DNS GoDaddy A findata.spboucher.ai → 51.161.112.61 vérifié le 2026-11-01 avant déploiement ; route Caddy posée par mld deploy ; findata-api écoute 127.0.0.1 seulement (Caddy → wg1 10.67.0.10:8600 via le proxy du nœud)" },
33+ "notes": "Monorepo uv (Python 3.13) : services/api (FastAPI :8600, sert aussi la console Next.js statique apps/web/out), services/ingest (worker unique = verrou d'écriture du lac + ordonnanceur), lac Parquet ~/findata-lake, Postgres 17 local (DB findata, rôle findata). Secrets uniquement dans ~/apps/findata/.env (600) ; copie ~/Desktop/Cluster/secrets/findata/.env. Sauvegarde quotidienne 04:00 ET → M4M64a:~/backups/findata (rsync + pg_dump zstd). CLI : .venv/bin/findata status|discover|ingest …"
34+}
added infra/prometheus/findata-alerts.yml +41 −0
@@ -0,0 +1,41 @@
1+# Prometheus alert rules for FinData OS (scrape http://<node-lan-ip>:8600/metrics from a private address; the
2+# endpoint also evaluates docs/OPERATIONS.md health checks on every scrape).
3+groups:
4+ - name: findata
5+ rules:
6+ - alert: FinDataLakeDiskLow
7+ expr: findata_lake_free_gb < 120
8+ for: 30m
9+ labels: {severity: warning}
10+ annotations: {summary: "Lake volume below 120 GB free"}
11+ - alert: FinDataLakeDiskCritical
12+ expr: findata_lake_free_gb < 60
13+ for: 5m
14+ labels: {severity: critical}
15+ annotations: {summary: "Lake volume below the 60 GB hard stop — ingestion refuses to write"}
16+ - alert: FinDataIngestDeadJobs
17+ expr: findata_jobs{status="dead"} > 0
18+ for: 10m
19+ labels: {severity: critical}
20+ annotations: {summary: "Jobs in the dead-letter queue"}
21+ - alert: FinDataStaleBars
22+ expr: findata_vendor_lag_days{dataset="market_bars.stock.1m.raw"} > 0
23+ for: 6h
24+ labels: {severity: warning}
25+ annotations: {summary: "1-minute bars are behind the vendor's last_update"}
26+ - alert: FinDataNoRecentSession
27+ expr: findata_dataset_freshness_days{dataset="market_bars.stock.1m.raw"} > 5
28+ for: 1h
29+ labels: {severity: warning}
30+ - alert: FinDataBackupStale
31+ expr: findata_backup_age_hours > 48
32+ for: 1h
33+ labels: {severity: warning}
34+ - alert: FinDataApiLatency
35+ expr: histogram_quantile(0.95, sum(rate(findata_http_request_seconds_bucket[5m])) by (le, route)) > 5
36+ for: 15m
37+ labels: {severity: warning}
38+ - alert: FinDataQualityErrors
39+ expr: findata_quality_anomalies_7d{severity="error"} > 100
40+ for: 1h
41+ labels: {severity: warning}
added infra/scripts/node-bootstrap.sh +10 −0
@@ -0,0 +1,10 @@
1+#!/bin/bash
2+# One-time preparation of the storage node (run ON the node). Idempotent.
3+set -euo pipefail
4+export PATH="/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:$HOME/.local/bin:$PATH"
5+psql -h localhost -d postgres -tAc "select 1 from pg_roles where rolname='findata'" | grep -q 1 || psql -h localhost -d postgres -c "create role findata login password '$(openssl rand -hex 16)'"
6+psql -h localhost -d postgres -tAc "select 1 from pg_database where datname='findata'" | grep -q 1 || createdb -h localhost -O findata findata
7+mkdir -p ~/findata-lake ~/apps/findata/logs && chmod 700 ~/findata-lake
8+command -v pnpm >/dev/null || npm install -g pnpm
9+command -v zstd >/dev/null || brew install zstd
10+echo "node ready: $(hostname) — lake $HOME/findata-lake ($(df -H ~ | tail -1 | awk '{print $4}') free)"
added packages/core/pyproject.toml +15 −0
@@ -0,0 +1,15 @@
1+[project]
2+name = "findata-core"
3+version = "0.1.0"
4+requires-python = ">=3.12"
5+dependencies = [
6+ "pydantic>=2.9",
7+ "pydantic-settings>=2.6",
8+ "psycopg[binary,pool]>=3.2",
9+ "structlog>=24.4",
10+]
11+[build-system]
12+requires = ["hatchling"]
13+build-backend = "hatchling.build"
14+[tool.hatch.build.targets.wheel]
15+packages = ["src/findata_core"]
added packages/core/src/findata_core/__init__.py +2 −0
@@ -0,0 +1,2 @@
1+"""findata_core — configuration, secrets redaction, database access, lake paths, clock."""
2+from findata_core.config import Settings, get_settings # noqa: F401
added packages/core/src/findata_core/clock.py +15 −0
@@ -0,0 +1,15 @@
1+from __future__ import annotations
2+
3+from datetime import UTC, datetime
4+
5+
6+def utcnow() -> datetime:
7+ return datetime.now(UTC)
8+
9+
10+def iso(dt: datetime | None) -> str | None:
11+ if dt is None:
12+ return None
13+ if dt.tzinfo is None:
14+ dt = dt.replace(tzinfo=UTC)
15+ return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
added packages/core/src/findata_core/config.py +72 −0
@@ -0,0 +1,72 @@
1+from __future__ import annotations
2+
3+from functools import lru_cache
4+from pathlib import Path
5+
6+from pydantic import Field, SecretStr
7+from pydantic_settings import BaseSettings, SettingsConfigDict
8+
9+
10+class Settings(BaseSettings):
11+ """Runtime settings. Secrets are `SecretStr` so that `repr()`/logging never prints them."""
12+
13+ model_config = SettingsConfigDict(env_prefix="FINDATA_", env_file=".env", env_file_encoding="utf-8", extra="ignore")
14+
15+ env: str = "development"
16+ root: Path = Field(default_factory=lambda: Path.cwd())
17+ lake: Path = Field(default_factory=lambda: Path.cwd() / "data" / "lake")
18+ database_url: SecretStr = SecretStr("postgresql://findata:findata-local@127.0.0.1:5432/findata")
19+ bind_host: str = "127.0.0.1"
20+ bind_port: int = 8600
21+ public_url: str = "http://127.0.0.1:8600"
22+ secret_key: SecretStr = SecretStr("dev-only-change-me-dev-only-change-me")
23+ lake_soft_limit_gb: float = 250.0
24+ lake_hard_min_free_gb: float = 60.0
25+ backup_target: str = ""
26+ # vendor + optional providers (no FINDATA_ prefix: read explicitly)
27+ frd_userid: SecretStr = Field(default=SecretStr(""), validation_alias="FRD_USERID")
28+ sec_edgar_user_agent: str = Field(default="", validation_alias="SEC_EDGAR_USER_AGENT")
29+ anthropic_api_key: SecretStr = Field(default=SecretStr(""), validation_alias="ANTHROPIC_API_KEY")
30+ openai_api_key: SecretStr = Field(default=SecretStr(""), validation_alias="OPENAI_API_KEY")
31+ # web static bundle
32+ web_dist: Path | None = None
33+ # rate limiting defaults (per API key / session)
34+ rate_limit_per_minute: int = 600
35+ query_timeout_seconds: int = 30
36+ query_max_rows: int = 100_000
37+ bars_max_rows_json: int = 50_000
38+ export_ttl_hours: int = 72
39+
40+ @property
41+ def is_production(self) -> bool:
42+ return self.env == "production"
43+
44+ @property
45+ def raw_dir(self) -> Path:
46+ return self.lake / "raw"
47+
48+ @property
49+ def curated_dir(self) -> Path:
50+ return self.lake / "curated"
51+
52+ @property
53+ def exports_dir(self) -> Path:
54+ return self.lake / "exports"
55+
56+ @property
57+ def tmp_dir(self) -> Path:
58+ return self.lake / "_tmp"
59+
60+ def secret_values(self) -> list[str]:
61+ """Every secret string that must never appear in logs or error messages."""
62+ vals = [self.frd_userid.get_secret_value(), self.anthropic_api_key.get_secret_value(),
63+ self.openai_api_key.get_secret_value()]
64+ pw = self.database_url.get_secret_value()
65+ if "@" in pw and ":" in pw.split("@")[0]:
66+ vals.append(pw.split("@")[0].rsplit(":", 1)[-1])
67+ return [v for v in vals if v and len(v) >= 6]
68+
69+
70+@lru_cache
71+def get_settings() -> Settings:
72+ return Settings()
added packages/core/src/findata_core/db.py +85 −0
@@ -0,0 +1,85 @@
1+"""PostgreSQL access (psycopg 3, connection pool) and SQL migrations."""
2+from __future__ import annotations
3+
4+import hashlib
5+from contextlib import contextmanager
6+from importlib import resources
7+from typing import Any, Iterator
8+
9+import psycopg
10+from psycopg.rows import dict_row
11+from psycopg_pool import ConnectionPool
12+
13+from findata_core.config import get_settings
14+
15+_pool: ConnectionPool | None = None
16+
17+
18+def get_pool() -> ConnectionPool:
19+ global _pool
20+ if _pool is None:
21+ s = get_settings()
22+ _pool = ConnectionPool(s.database_url.get_secret_value(), min_size=1, max_size=8, open=True,
23+ kwargs={"row_factory": dict_row, "autocommit": False})
24+ return _pool
25+
26+
27+def close_pool() -> None:
28+ global _pool
29+ if _pool is not None:
30+ _pool.close()
31+ _pool = None
32+
33+
34+@contextmanager
35+def connection() -> Iterator[psycopg.Connection[Any]]:
36+ pool = get_pool()
37+ with pool.connection() as conn:
38+ yield conn
39+
40+
41+@contextmanager
42+def transaction() -> Iterator[psycopg.Connection[Any]]:
43+ with connection() as conn:
44+ with conn.transaction():
45+ yield conn
46+
47+
48+def fetch_all(sql: str, params: Any = None) -> list[dict[str, Any]]:
49+ with connection() as conn:
50+ return conn.execute(sql, params).fetchall()
51+
52+
53+def fetch_one(sql: str, params: Any = None) -> dict[str, Any] | None:
54+ with connection() as conn:
55+ return conn.execute(sql, params).fetchone()
56+
57+
58+def execute(sql: str, params: Any = None) -> None:
59+ with transaction() as conn:
60+ conn.execute(sql, params)
61+
62+
63+def migrate(verbose: bool = True) -> list[str]:
64+ """Apply every `migrations/NNNN_*.sql` not yet recorded in `schema_migrations`. Idempotent."""
65+ applied: list[str] = []
66+ files = sorted(p for p in resources.files("findata_core.migrations").iterdir() if p.name.endswith(".sql"))
67+ with connection() as conn:
68+ conn.execute("CREATE TABLE IF NOT EXISTS schema_migrations (name text primary key, sha256 text not null, "
69+ "applied_at timestamptz not null default now())")
70+ conn.commit()
71+ done = {r["name"]: r["sha256"] for r in conn.execute("SELECT name, sha256 FROM schema_migrations").fetchall()}
72+ for f in files:
73+ sql = f.read_text(encoding="utf-8")
74+ digest = hashlib.sha256(sql.encode()).hexdigest()
75+ if f.name in done:
76+ if done[f.name] != digest:
77+ raise RuntimeError(f"migration {f.name} was modified after being applied")
78+ continue
79+ with conn.transaction():
80+ conn.execute(sql)
81+ conn.execute("INSERT INTO schema_migrations(name, sha256) VALUES (%s, %s)", (f.name, digest))
82+ applied.append(f.name)
83+ if verbose:
84+ print(f"applied {f.name}")
85+ return applied
added packages/core/src/findata_core/logging.py +33 −0
@@ -0,0 +1,33 @@
1+from __future__ import annotations
2+
3+import logging
4+import sys
5+
6+import structlog
7+
8+from findata_core.config import get_settings
9+from findata_core.redact import RedactingFilter
10+
11+
12+def configure_logging(level: str = "INFO") -> None:
13+ settings = get_settings()
14+ logging.basicConfig(level=level, stream=sys.stdout, format="%(message)s")
15+ # Never let httpx/httpcore print request URLs (they contain the vendor user id).
16+ for noisy in ("httpx", "httpcore", "urllib3"):
17+ logging.getLogger(noisy).setLevel(logging.WARNING)
18+ structlog.configure(
19+ processors=[
20+ structlog.contextvars.merge_contextvars,
21+ structlog.processors.add_log_level,
22+ structlog.processors.TimeStamper(fmt="iso", utc=True),
23+ RedactingFilter(settings.secret_values()),
24+ structlog.processors.JSONRenderer() if settings.is_production else structlog.dev.ConsoleRenderer(),
25+ ],
26+ wrapper_class=structlog.make_filtering_bound_logger(logging.getLevelName(level)),
27+ logger_factory=structlog.PrintLoggerFactory(),
28+ cache_logger_on_first_use=False,
29+ )
30+
31+
32+def get_logger(name: str): # type: ignore[no-untyped-def]
33+ return structlog.get_logger(name)
added packages/core/src/findata_core/migrations/0001_init.sql +359 −0
@@ -0,0 +1,359 @@
1+-- FinData OS metadata schema (PostgreSQL 17). Lake data itself lives in Parquet.
2+CREATE EXTENSION IF NOT EXISTS pgcrypto;
3+
4+CREATE TABLE users (
5+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6+ email text UNIQUE NOT NULL,
7+ password_hash text NOT NULL,
8+ role text NOT NULL CHECK (role IN ('owner','researcher','service')),
9+ totp_secret text,
10+ totp_enabled boolean NOT NULL DEFAULT false,
11+ disabled boolean NOT NULL DEFAULT false,
12+ created_at timestamptz NOT NULL DEFAULT now(),
13+ last_login_at timestamptz
14+);
15+
16+CREATE TABLE sessions (
17+ id text PRIMARY KEY,
18+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
19+ csrf_token text NOT NULL,
20+ mfa_passed boolean NOT NULL DEFAULT false,
21+ ip text, user_agent text,
22+ created_at timestamptz NOT NULL DEFAULT now(),
23+ expires_at timestamptz NOT NULL,
24+ revoked_at timestamptz
25+);
26+CREATE INDEX sessions_user_idx ON sessions(user_id);
27+
28+CREATE TABLE api_keys (
29+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
30+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
31+ name text NOT NULL,
32+ prefix text NOT NULL UNIQUE,
33+ key_hash text NOT NULL,
34+ scopes text[] NOT NULL,
35+ rate_limit_per_minute integer NOT NULL DEFAULT 600,
36+ daily_row_quota bigint NOT NULL DEFAULT 50000000,
37+ expires_at timestamptz,
38+ last_used_at timestamptz,
39+ revoked_at timestamptz,
40+ created_at timestamptz NOT NULL DEFAULT now()
41+);
42+
43+CREATE TABLE usage_daily (
44+ principal text NOT NULL, -- 'key:<uuid>' or 'user:<uuid>'
45+ day date NOT NULL,
46+ requests bigint NOT NULL DEFAULT 0,
47+ rows_returned bigint NOT NULL DEFAULT 0,
48+ bytes_exported bigint NOT NULL DEFAULT 0,
49+ PRIMARY KEY (principal, day)
50+);
51+
52+CREATE TABLE audit_log (
53+ id bigserial PRIMARY KEY,
54+ at timestamptz NOT NULL DEFAULT now(),
55+ actor_type text NOT NULL, -- user | api_key | system | anonymous
56+ actor_id text,
57+ action text NOT NULL,
58+ target text,
59+ request_id text,
60+ ip text,
61+ outcome text NOT NULL DEFAULT 'ok',
62+ details jsonb NOT NULL DEFAULT '{}'::jsonb
63+);
64+CREATE INDEX audit_log_at_idx ON audit_log(at DESC);
65+CREATE INDEX audit_log_actor_idx ON audit_log(actor_type, actor_id);
66+
67+CREATE TABLE sources (
68+ id text PRIMARY KEY, -- firstrate | firstrate_fundamentals | sec_edgar
69+ name text NOT NULL,
70+ vendor text NOT NULL,
71+ product text NOT NULL,
72+ license_class text NOT NULL, -- frd-private | public-domain | pending
73+ docs_url text,
74+ status text NOT NULL CHECK (status IN ('active','pending_configuration','disabled')),
75+ capabilities jsonb NOT NULL DEFAULT '{}'::jsonb,
76+ notes text,
77+ updated_at timestamptz NOT NULL DEFAULT now()
78+);
79+
80+CREATE TABLE datasets (
81+ id text PRIMARY KEY, -- e.g. market_bars.stock.1m.raw
82+ name text NOT NULL,
83+ description text NOT NULL,
84+ source_id text NOT NULL REFERENCES sources(id),
85+ kind text NOT NULL CHECK (kind IN ('market_bars','fundamentals','corporate_actions','reference','derived')),
86+ asset_class text,
87+ interval text,
88+ adjustment text,
89+ license_class text NOT NULL,
90+ status text NOT NULL CHECK (status IN ('active','planned','pending_configuration','unsupported','paused')),
91+ timestamp_convention text, -- bar_open
92+ timezone text, -- source timezone, canonical storage is UTC
93+ schema_version integer NOT NULL DEFAULT 1,
94+ current_version integer NOT NULL DEFAULT 0,
95+ notes text,
96+ created_at timestamptz NOT NULL DEFAULT now(),
97+ updated_at timestamptz NOT NULL DEFAULT now()
98+);
99+
100+CREATE TABLE dataset_versions (
101+ id bigserial PRIMARY KEY,
102+ dataset_id text NOT NULL REFERENCES datasets(id),
103+ version integer NOT NULL,
104+ snapshot_id text NOT NULL, -- sha256 over sorted (path, sha256) of live files
105+ file_count integer NOT NULL,
106+ row_count bigint NOT NULL,
107+ bytes bigint NOT NULL,
108+ created_at timestamptz NOT NULL DEFAULT now(),
109+ ingest_run_id uuid,
110+ note text,
111+ UNIQUE (dataset_id, version)
112+);
113+
114+CREATE TABLE lake_files (
115+ id bigserial PRIMARY KEY,
116+ dataset_id text NOT NULL REFERENCES datasets(id),
117+ path text NOT NULL UNIQUE, -- relative to lake root
118+ layout text NOT NULL CHECK (layout IN ('base','delta')),
119+ symbol_min text, symbol_max text,
120+ date_min date, date_max date,
121+ row_count bigint NOT NULL,
122+ bytes bigint NOT NULL,
123+ sha256 text NOT NULL,
124+ version_added integer NOT NULL,
125+ version_removed integer,
126+ ingest_run_id uuid,
127+ created_at timestamptz NOT NULL DEFAULT now()
128+);
129+CREATE INDEX lake_files_live_idx ON lake_files(dataset_id, date_min, date_max) WHERE version_removed IS NULL;
130+
131+CREATE TABLE instruments (
132+ id bigserial PRIMARY KEY,
133+ instrument_id text NOT NULL UNIQUE, -- frd:stock:AAPL
134+ symbol text NOT NULL,
135+ name text,
136+ asset_class text NOT NULL,
137+ venue text,
138+ currency text NOT NULL DEFAULT 'USD',
139+ first_date date, last_date date,
140+ status text NOT NULL CHECK (status IN ('active','delisted','unknown')),
141+ source_id text NOT NULL REFERENCES sources(id),
142+ external_ids jsonb NOT NULL DEFAULT '{}'::jsonb, -- cik, figi… when known
143+ updated_at timestamptz NOT NULL DEFAULT now()
144+);
145+CREATE INDEX instruments_symbol_idx ON instruments(symbol);
146+CREATE INDEX instruments_name_trgm_idx ON instruments(lower(name));
147+
148+CREATE TABLE instrument_aliases (
149+ id bigserial PRIMARY KEY,
150+ instrument_id text NOT NULL REFERENCES instruments(instrument_id) ON DELETE CASCADE,
151+ symbol text NOT NULL,
152+ valid_from date, valid_to date,
153+ source_id text NOT NULL,
154+ note text,
155+ UNIQUE (instrument_id, symbol, valid_from)
156+);
157+
158+CREATE TABLE corporate_actions (
159+ id bigserial PRIMARY KEY,
160+ instrument_id text NOT NULL,
161+ action_type text NOT NULL CHECK (action_type IN ('split','dividend')),
162+ ex_date date NOT NULL,
163+ value numeric NOT NULL, -- split ratio (new/old) or cash dividend per share
164+ source_id text NOT NULL REFERENCES sources(id),
165+ ingest_run_id uuid,
166+ observed_at timestamptz NOT NULL DEFAULT now(),
167+ UNIQUE (instrument_id, action_type, ex_date, value)
168+);
169+
170+CREATE TABLE ingest_jobs (
171+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
172+ kind text NOT NULL, -- metadata | incremental | backfill | compaction | derive | coverage | fundamentals | backup | quality
173+ dataset_id text REFERENCES datasets(id),
174+ params jsonb NOT NULL DEFAULT '{}'::jsonb,
175+ priority integer NOT NULL DEFAULT 50,
176+ status text NOT NULL CHECK (status IN ('queued','running','succeeded','failed','dead','cancelled')) DEFAULT 'queued',
177+ attempts integer NOT NULL DEFAULT 0,
178+ max_attempts integer NOT NULL DEFAULT 5,
179+ next_run_at timestamptz NOT NULL DEFAULT now(),
180+ locked_by text, locked_at timestamptz,
181+ created_at timestamptz NOT NULL DEFAULT now(),
182+ started_at timestamptz, finished_at timestamptz,
183+ last_error text,
184+ progress jsonb NOT NULL DEFAULT '{}'::jsonb,
185+ result jsonb NOT NULL DEFAULT '{}'::jsonb,
186+ dedupe_key text UNIQUE,
187+ created_by text
188+);
189+CREATE INDEX ingest_jobs_queue_idx ON ingest_jobs(status, next_run_at, priority);
190+
191+CREATE TABLE ingest_runs (
192+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
193+ job_id uuid REFERENCES ingest_jobs(id),
194+ worker text,
195+ started_at timestamptz NOT NULL DEFAULT now(),
196+ finished_at timestamptz,
197+ status text NOT NULL DEFAULT 'running',
198+ stats jsonb NOT NULL DEFAULT '{}'::jsonb,
199+ code_version text
200+);
201+
202+CREATE TABLE checkpoints (
203+ scope text NOT NULL, -- job:<uuid> | dataset:<id>
204+ key text NOT NULL,
205+ value jsonb NOT NULL,
206+ updated_at timestamptz NOT NULL DEFAULT now(),
207+ PRIMARY KEY (scope, key)
208+);
209+
210+CREATE TABLE raw_archives (
211+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
212+ source_id text NOT NULL REFERENCES sources(id),
213+ dataset_id text REFERENCES datasets(id),
214+ request jsonb NOT NULL, -- sanitized: endpoint + params without userid
215+ path text, -- relative to lake root; NULL once deleted
216+ sha256 text NOT NULL,
217+ bytes bigint NOT NULL,
218+ file_count integer,
219+ fetched_at timestamptz NOT NULL DEFAULT now(),
220+ vendor_last_update date,
221+ ingest_run_id uuid,
222+ status text NOT NULL CHECK (status IN ('stored','published','deleted_after_publish','failed')),
223+ UNIQUE (source_id, sha256)
224+);
225+
226+CREATE TABLE coverage (
227+ dataset_id text NOT NULL REFERENCES datasets(id),
228+ symbol text NOT NULL,
229+ session_date date NOT NULL,
230+ bar_count integer NOT NULL,
231+ first_ts timestamptz, last_ts timestamptz,
232+ expected_bars integer,
233+ flags text[] NOT NULL DEFAULT '{}',
234+ updated_at timestamptz NOT NULL DEFAULT now(),
235+ PRIMARY KEY (dataset_id, symbol, session_date)
236+);
237+CREATE INDEX coverage_date_idx ON coverage(dataset_id, session_date);
238+
239+CREATE TABLE quality_reports (
240+ id bigserial PRIMARY KEY,
241+ dataset_id text NOT NULL REFERENCES datasets(id),
242+ ingest_run_id uuid,
243+ created_at timestamptz NOT NULL DEFAULT now(),
244+ rows_checked bigint NOT NULL,
245+ anomalies integer NOT NULL,
246+ summary jsonb NOT NULL
247+);
248+
249+CREATE TABLE quality_anomalies (
250+ id bigserial PRIMARY KEY,
251+ dataset_id text NOT NULL,
252+ symbol text, session_date date,
253+ code text NOT NULL,
254+ severity text NOT NULL CHECK (severity IN ('info','warning','error')),
255+ detail jsonb NOT NULL DEFAULT '{}'::jsonb,
256+ ingest_run_id uuid,
257+ created_at timestamptz NOT NULL DEFAULT now()
258+);
259+CREATE INDEX quality_anomalies_ds_idx ON quality_anomalies(dataset_id, created_at DESC);
260+
261+CREATE TABLE exports (
262+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
263+ principal text NOT NULL,
264+ status text NOT NULL CHECK (status IN ('queued','running','ready','failed','expired')) DEFAULT 'queued',
265+ request jsonb NOT NULL,
266+ format text NOT NULL DEFAULT 'parquet',
267+ path text,
268+ bytes bigint, row_count bigint,
269+ snapshot jsonb NOT NULL DEFAULT '{}'::jsonb, -- dataset_id -> version/snapshot_id
270+ error text,
271+ created_at timestamptz NOT NULL DEFAULT now(),
272+ finished_at timestamptz,
273+ expires_at timestamptz
274+);
275+
276+CREATE TABLE entities (
277+ entity_id text PRIMARY KEY, -- frd:AAPL (vendor ticker-keyed) ; sec:CIK0000320193 when mapped
278+ name text,
279+ cik text,
280+ tickers text[] NOT NULL DEFAULT '{}',
281+ sector text, industry text,
282+ external_ids jsonb NOT NULL DEFAULT '{}'::jsonb,
283+ updated_at timestamptz NOT NULL DEFAULT now()
284+);
285+
286+CREATE TABLE fundamentals_facts (
287+ id bigserial PRIMARY KEY,
288+ entity_id text NOT NULL,
289+ security_id text,
290+ metric text NOT NULL,
291+ value numeric,
292+ unit text NOT NULL DEFAULT 'USD',
293+ currency text,
294+ fiscal_period_start date,
295+ fiscal_period_end date NOT NULL,
296+ fiscal_period_type text NOT NULL, -- q | a
297+ fiscal_year integer, fiscal_period text,
298+ filing_date date,
299+ published_at timestamptz,
300+ available_at timestamptz NOT NULL, -- PIT policy: filing_date 00:00 ET + 1 day unless vendor publishes a timestamp
301+ observed_at timestamptz NOT NULL, -- when FinData ingested this value
302+ source_document text,
303+ source_id text NOT NULL REFERENCES sources(id),
304+ revision_id integer NOT NULL DEFAULT 1,
305+ restatement_flag boolean NOT NULL DEFAULT false,
306+ superseded_at timestamptz, -- set when a later ingestion observed a different value
307+ ingest_run_id uuid
308+);
309+CREATE INDEX ff_lookup_idx ON fundamentals_facts(entity_id, metric, fiscal_period_end, available_at);
310+CREATE UNIQUE INDEX ff_rev_idx ON fundamentals_facts(entity_id, metric, fiscal_period_end, fiscal_period_type, source_id, revision_id);
311+
312+CREATE TABLE earnings_events (
313+ id bigserial PRIMARY KEY,
314+ entity_id text NOT NULL,
315+ event_date date NOT NULL,
316+ eps_estimate numeric, eps_reported numeric,
317+ release_time text,
318+ source_id text NOT NULL,
319+ observed_at timestamptz NOT NULL DEFAULT now(),
320+ UNIQUE (entity_id, event_date, source_id)
321+);
322+
323+CREATE TABLE settings_kv (
324+ key text PRIMARY KEY,
325+ value jsonb NOT NULL,
326+ updated_at timestamptz NOT NULL DEFAULT now()
327+);
328+
329+-- Seed: sources and datasets (status reflects verified capability on 2026-11-01; see docs/DISCOVERY.md)
330+INSERT INTO sources(id, name, vendor, product, license_class, docs_url, status, capabilities, notes) VALUES
331+('firstrate', 'FirstRate Data — Market Data API', 'FirstRate Data', 'Market data bundles (zip archives)', 'frd-private',
332+ 'https://firstratedata.com/about/api-docs', 'active',
333+ '{"asset_classes":["stock","etf","futures","index","fx","crypto","options"],"timeframes":["1min","5min","30min","1hour","1day"],"adjustments":["UNADJUSTED","adj_split","adj_splitdiv"],"ticks":false,"quotes":false,"pagination":false,"rate_limits":"none documented"}'::jsonb,
334+ 'Auth = userid query parameter. Redistribution of raw data prohibited.'),
335+('firstrate_fundamentals', 'FirstRate Data — US Fundamentals API', 'FirstRate Data', 'US Fundamentals (/api-f v1)', 'frd-private',
336+ 'https://firstratedata.com/fundamental-data/financials/', 'active',
337+ '{"financials":true,"earnings":true,"valuation_metrics":true,"short_interest":"doc","short_volume":"doc","social_sentiment":"doc","revision_history":false}'::jsonb,
338+ 'One value per (ticker, period) with filing_date; FinData versions values across ingestion runs.'),
339+('sec_edgar', 'SEC EDGAR — XBRL company facts', 'U.S. Securities and Exchange Commission', 'companyfacts / submissions JSON', 'public-domain',
340+ 'https://www.sec.gov/edgar/sec-api-documentation', 'pending_configuration',
341+ '{"companyfacts":true,"submissions":true,"rate_limit":"10 req/s, declared User-Agent required"}'::jsonb,
342+ 'Set SEC_EDGAR_USER_AGENT to enable.');
343+
344+INSERT INTO datasets(id, name, description, source_id, kind, asset_class, interval, adjustment, license_class, status, timestamp_convention, timezone, notes) VALUES
345+('market_bars.stock.1m.raw', 'US stocks — 1-minute bars (unadjusted)', 'Raw (unadjusted) 1-minute OHLCV bars for US-listed stocks, extended hours 04:00–20:00 ET, zero-volume bars absent at source.', 'firstrate', 'market_bars', 'stock', '1m', 'raw', 'frd-private', 'active', 'bar_open', 'America/New_York', 'Canonical dataset; adjustments are applied at query time from corporate_actions.'),
346+('market_bars.stock.5m.raw', 'US stocks — 5-minute bars (derived)', 'Derived from validated 1-minute bars: open=first, high=max, low=min, close=last, volume=sum, trade_count=count of 1m bars; buckets aligned to 5-minute boundaries in ET.', 'firstrate', 'derived', 'stock', '5m', 'raw', 'frd-private', 'active', 'bar_open', 'America/New_York', NULL),
347+('market_bars.stock.1d.raw', 'US stocks — daily bars (unadjusted)', 'Daily OHLCV as delivered by the vendor (not rebuilt from intraday).', 'firstrate', 'market_bars', 'stock', '1d', 'raw', 'frd-private', 'planned', 'session', 'America/New_York', NULL),
348+('market_bars.etf.1m.raw', 'US ETFs — 1-minute bars (unadjusted)', 'Same contract as stocks.', 'firstrate', 'market_bars', 'etf', '1m', 'raw', 'frd-private', 'planned', 'bar_open', 'America/New_York', NULL),
349+('market_bars.futures.1m.continuous', 'Futures — 1-minute continuous series', 'Three continuous series per root (unadjusted, ratio, absolute).', 'firstrate', 'market_bars', 'futures', '1m', 'continuous', 'frd-private', 'planned', 'bar_open', 'America/New_York', 'Roll policy fixed by vendor (≈7–10 days before expiry).'),
350+('market_bars.index.1m.raw', 'Indices — 1-minute bars', 'No volume.', 'firstrate', 'market_bars', 'index', '1m', 'raw', 'frd-private', 'planned', 'bar_open', 'America/New_York', NULL),
351+('market_bars.fx.1m.raw', 'FX majors — 1-minute bars', 'Aggregated from four money-center banks.', 'firstrate', 'market_bars', 'fx', '1m', 'raw', 'frd-private', 'planned', 'bar_open', 'America/New_York', 'Two-field datetime in source files.'),
352+('market_bars.crypto.1m.raw', 'Crypto — 1-minute bars', 'UTC timestamps, 7 days a week.', 'firstrate', 'market_bars', 'crypto', '1m', 'raw', 'frd-private', 'planned', 'bar_open', 'UTC', NULL),
353+('options.eod', 'US options — end-of-day chains', 'EOD only (CBOE), Greeks included, not split-adjusted.', 'firstrate', 'market_bars', 'options', '1d', 'raw', 'frd-private', 'planned', 'session', 'America/New_York', NULL),
354+('market_ticks', 'Quotes / trades / ticks', 'Not offered by the licensed vendor.', 'firstrate', 'market_bars', NULL, 'tick', NULL, 'frd-private', 'unsupported', NULL, NULL, 'Vendor provides bars only.'),
355+('corporate_actions.stock', 'Splits and cash dividends', 'From vendor meta files; mergers/spin-offs not covered.', 'firstrate', 'corporate_actions', 'stock', NULL, NULL, 'frd-private', 'active', NULL, NULL, NULL),
356+('reference.instruments', 'Instrument master', 'Ticker listing + delisted log; ticker changes merged by vendor under the latest symbol.', 'firstrate', 'reference', NULL, NULL, NULL, 'frd-private', 'active', NULL, NULL, NULL),
357+('fundamentals.financials', 'Financial statements (point-in-time)', 'Income statement, balance sheet, cash flow, shares, ratios — long format with available_at.', 'firstrate_fundamentals', 'fundamentals', 'stock', NULL, NULL, 'frd-private', 'active', NULL, NULL, 'PIT availability = filing_date + 1 day (ET) by policy; see docs/DATA_DICTIONARY.md.'),
358+('fundamentals.earnings', 'Earnings releases', 'Announcement dates with estimate and reported EPS.', 'firstrate_fundamentals', 'fundamentals', 'stock', NULL, NULL, 'frd-private', 'active', NULL, NULL, NULL),
359+('fundamentals.sec_companyfacts', 'SEC XBRL company facts', 'Independent public-domain source with filing accession and frame data.', 'sec_edgar', 'fundamentals', 'stock', NULL, NULL, 'public-domain', 'pending_configuration', NULL, NULL, 'Requires SEC_EDGAR_USER_AGENT.');
added packages/core/src/findata_core/redact.py +29 −0
@@ -0,0 +1,29 @@
1+"""Secret redaction for logs, exceptions and persisted job messages."""
2+from __future__ import annotations
3+
4+import re
5+from typing import Any
6+
7+_PARAM_RE = re.compile(r"(?i)(userid|api_key|apikey|token|password|secret)=([^&\s\"']+)")
8+
9+
10+def redact(text: Any, secrets: list[str] | None = None) -> str:
11+ s = str(text)
12+ for v in secrets or []:
13+ if v:
14+ s = s.replace(v, "<REDACTED>")
15+ s = _PARAM_RE.sub(lambda m: f"{m.group(1)}=<REDACTED>", s)
16+ return s
17+
18+
19+class RedactingFilter:
20+ """structlog processor: redact every string value in the event dict."""
21+
22+ def __init__(self, secrets: list[str]):
23+ self.secrets = secrets
24+
25+ def __call__(self, logger, method, event_dict): # type: ignore[no-untyped-def]
26+ for k, v in list(event_dict.items()):
27+ if isinstance(v, str):
28+ event_dict[k] = redact(v, self.secrets)
29+ return event_dict
added packages/python-sdk/pyproject.toml +14 −0
@@ -0,0 +1,14 @@
1+[project]
2+name = "findata-sdk"
3+version = "0.1.0"
4+description = "Typed Python client for FinData OS (pandas / polars / Arrow helpers)"
5+requires-python = ">=3.12"
6+dependencies = ["httpx>=0.28", "pyarrow>=18.0"]
7+[project.optional-dependencies]
8+pandas = ["pandas>=2.2"]
9+polars = ["polars>=1.12"]
10+[build-system]
11+requires = ["hatchling"]
12+build-backend = "hatchling.build"
13+[tool.hatch.build.targets.wheel]
14+packages = ["src/findata_sdk"]
added packages/python-sdk/src/findata_sdk/__init__.py +1 −0
@@ -0,0 +1 @@
1+from findata_sdk.client import FinData, FinDataError # noqa: F401
added packages/python-sdk/src/findata_sdk/client.py +129 −0
@@ -0,0 +1,129 @@
1+"""FinData OS Python SDK.
2+
3+ from findata_sdk import FinData
4+ fd = FinData("https://findata.spboucher.ai", api_key="fd_xxx.yyy")
5+ bars = fd.bars("AAPL", "1m", "2026-10-26", "2026-10-30") # pyarrow.Table (all pages, Arrow IPC)
6+ df = bars.to_pandas() # or fd.bars(...).to_polars()
7+ export = fd.export(["AAPL", "MSFT"], "2024-01-01", "2026-10-30") # async Parquet export → local file
8+ facts = fd.fundamentals("AAPL", as_of="2025-01-01T00:00:00Z", metrics=["rev", "net_inc"])
9+"""
10+from __future__ import annotations
11+
12+import io
13+import os
14+import time
15+from datetime import date, datetime
16+from pathlib import Path
17+from typing import Any
18+
19+import httpx
20+import pyarrow as pa
21+import pyarrow.ipc as ipc
22+
23+
24+class FinDataError(RuntimeError):
25+ def __init__(self, status: int, payload: Any):
26+ self.status = status
27+ self.payload = payload
28+ err = payload.get("error", {}) if isinstance(payload, dict) else {}
29+ super().__init__(f"HTTP {status} {err.get('code', '')}: {err.get('message', payload)} (request_id={err.get('request_id')})")
30+
31+
32+class FinData:
33+ def __init__(self, base_url: str | None = None, api_key: str | None = None, timeout: float = 120.0, transport: httpx.BaseTransport | None = None):
34+ self.base_url = (base_url or os.environ.get("FINDATA_URL", "https://findata.spboucher.ai")).rstrip("/")
35+ key = api_key or os.environ.get("FINDATA_API_KEY")
36+ if not key:
37+ raise ValueError("api_key required (or FINDATA_API_KEY env var)")
38+ self._c = httpx.Client(base_url=self.base_url, timeout=timeout, headers={"Authorization": f"Bearer {key}", "User-Agent": "findata-sdk/0.1"}, transport=transport)
39+
40+ # ------------------------------------------------------------------ low level
41+ def _get(self, path: str, **params: Any) -> Any:
42+ r = self._c.get(f"/api/v1{path}", params={k: v for k, v in params.items() if v is not None})
43+ if r.status_code >= 400:
44+ raise FinDataError(r.status_code, _json(r))
45+ return r.json()
46+
47+ def _post(self, path: str, body: dict[str, Any]) -> Any:
48+ r = self._c.post(f"/api/v1{path}", json=body)
49+ if r.status_code >= 400:
50+ raise FinDataError(r.status_code, _json(r))
51+ return r.json()
52+
53+ # ------------------------------------------------------------------ catalog
54+ def datasets(self) -> list[dict[str, Any]]:
55+ return self._get("/catalog/datasets")["data"]
56+
57+ def instruments(self, query: str = "", **kw: Any) -> list[dict[str, Any]]:
58+ return self._get("/instruments", query=query, **kw)["data"]
59+
60+ def health(self) -> dict[str, Any]:
61+ return self._get("/health")
62+
63+ # ------------------------------------------------------------------ bars
64+ def bars(self, symbol: str, interval: str, start: str | date, end: str | date, adjustment: str = "raw",
65+ max_pages: int = 200) -> pa.Table:
66+ """All pages of a bounded request as one Arrow table (Arrow IPC transport, cursor pagination)."""
67+ tables: list[pa.Table] = []
68+ cursor: str | None = None
69+ for _ in range(max_pages):
70+ params = {"symbol": symbol, "interval": interval, "start": str(start), "end": str(end), "adjustment": adjustment,
71+ "format": "arrow", "limit": 10000}
72+ if cursor:
73+ params["cursor"] = cursor
74+ r = self._c.get("/api/v1/market/bars", params=params)
75+ if r.status_code >= 400:
76+ raise FinDataError(r.status_code, _json(r))
77+ with ipc.open_stream(io.BytesIO(r.content)) as reader:
78+ tables.append(reader.read_all())
79+ cursor = r.headers.get("X-Next-Cursor") or None
80+ if not cursor:
81+ break
82+ return pa.concat_tables(tables) if tables else pa.table({})
83+
84+ def sessions(self, symbol: str, interval: str = "1m", **kw: Any) -> list[dict[str, Any]]:
85+ return self._get("/market/sessions", symbol=symbol, interval=interval, **kw)["data"]
86+
87+ # ------------------------------------------------------------------ fundamentals
88+ def fundamentals(self, entity: str, as_of: str | datetime | None = None, metrics: list[str] | None = None,
89+ period_type: str | None = None) -> dict[str, Any]:
90+ return self._get(f"/fundamentals/{entity}", as_of=str(as_of) if as_of else None, metrics=",".join(metrics) if metrics else None,
91+ period_type=period_type)
92+
93+ # ------------------------------------------------------------------ query
94+ def sql(self, sql: str, max_rows: int = 1000) -> pa.Table:
95+ res = self._post("/query", {"sql": sql, "max_rows": max_rows})
96+ if not res["rows"]:
97+ return pa.table({c["name"]: [] for c in res["columns"]})
98+ return pa.Table.from_pylist(res["rows"])
99+
100+ # ------------------------------------------------------------------ exports
101+ def export(self, symbols: list[str], start: str | date, end: str | date, dataset_id: str = "market_bars.stock.1m.raw",
102+ adjustment: str = "raw", fmt: str = "parquet", dest: str | Path | None = None, poll_s: float = 2.0, timeout_s: float = 3600) -> Path:
103+ job = self._post("/exports", {"dataset_id": dataset_id, "symbols": symbols, "start": str(start), "end": str(end), "adjustment": adjustment, "format": fmt})
104+ eid = job["id"]
105+ t0 = time.monotonic()
106+ while True:
107+ st = self._get(f"/exports/{eid}")
108+ if st["status"] == "ready":
109+ break
110+ if st["status"] in ("failed", "expired"):
111+ raise FinDataError(500, {"error": {"code": st["status"], "message": st.get("error")}})
112+ if time.monotonic() - t0 > timeout_s:
113+ raise TimeoutError(f"export {eid} not ready after {timeout_s}s")
114+ time.sleep(poll_s)
115+ out = Path(dest) if dest else Path(f"findata-{eid}.{fmt}")
116+ with self._c.stream("GET", f"/api/v1/exports/{eid}/download") as r:
117+ if r.status_code >= 400:
118+ raise FinDataError(r.status_code, {"error": {"code": "download_failed"}})
119+ with open(out, "wb") as f:
120+ for chunk in r.iter_bytes():
121+ f.write(chunk)
122+ return out
123+
124+
125+def _json(r: httpx.Response) -> Any:
126+ try:
127+ return r.json()
128+ except Exception: # noqa: BLE001
129+ return {"error": {"code": "http", "message": r.text[:300]}}
added packages/schema/pyproject.toml +10 −0
@@ -0,0 +1,10 @@
1+[project]
2+name = "findata-schema"
3+version = "0.1.0"
4+requires-python = ">=3.12"
5+dependencies = ["pyarrow>=18.0", "pydantic>=2.9"]
6+[build-system]
7+requires = ["hatchling"]
8+build-backend = "hatchling.build"
9+[tool.hatch.build.targets.wheel]
10+packages = ["src/findata_schema"]
added packages/schema/src/findata_schema/__init__.py +8 −0
@@ -0,0 +1,8 @@
1+"""findata_schema — Arrow schemas, data contracts and connector interfaces (single source of truth)."""
2+from findata_schema.contracts import ( # noqa: F401
3+ BARS_SCHEMA, BARS_SCHEMA_VERSION, FUNDAMENTALS_SCHEMA, RAW_BAR_COLUMNS, AssetClass, Interval,
4+ Adjustment, QualityFlag, INTERVAL_SECONDS, bars_schema_json,
5+)
6+from findata_schema.connector import ( # noqa: F401
7+ Capabilities, Checkpoint, Connector, FetchRequest, FetchResult, NormalizedBatch, ValidationReport,
8+)
added packages/schema/src/findata_schema/connector.py +95 −0
@@ -0,0 +1,95 @@
1+"""Connector interface: discover() → capabilities() → fetch_range() → normalize() → validate() → checkpoint().
2+
3+Connectors never log full URLs, never persist credentials, and return *raw bytes + provenance* separately from
4+normalized Arrow tables so that raw records stay immutable.
5+"""
6+from __future__ import annotations
7+
8+from abc import ABC, abstractmethod
9+from dataclasses import dataclass, field
10+from datetime import date, datetime
11+from pathlib import Path
12+from typing import Any
13+
14+import pyarrow as pa
15+
16+
17+@dataclass(frozen=True)
18+class Capabilities:
19+ source_id: str
20+ asset_classes: tuple[str, ...]
21+ intervals: tuple[str, ...]
22+ adjustments: tuple[str, ...]
23+ supports_symbol_filter: bool # can the vendor serve one symbol? (FirstRate: no)
24+ supports_date_range: bool # can the vendor serve an arbitrary range? (FirstRate: no)
25+ incremental_windows: tuple[str, ...] # e.g. day, week, month
26+ license_class: str
27+ notes: dict[str, Any] = field(default_factory=dict)
28+
29+
30+@dataclass(frozen=True)
31+class FetchRequest:
32+ dataset_id: str
33+ params: dict[str, Any] # vendor-specific, *without* credentials
34+ start: date | None = None
35+ end: date | None = None
36+
37+
38+@dataclass
39+class FetchResult:
40+ request: FetchRequest
41+ path: Path # raw archive stored immutably under lake/raw
42+ sha256: str
43+ bytes: int
44+ fetched_at: datetime
45+ vendor_last_update: date | None
46+ file_count: int | None = None
47+ source_url_sanitized: str = ""
48+
49+
50+@dataclass
51+class NormalizedBatch:
52+ table: pa.Table
53+ symbol: str
54+ session_dates: tuple[date, date]
55+ row_count: int
56+ raw_rows: int # rows read from the vendor file (before dedupe)
57+
58+
59+@dataclass
60+class ValidationReport:
61+ rows: int
62+ flagged_rows: int
63+ anomalies: list[dict[str, Any]]
64+ ok: bool
65+
66+
67+@dataclass
68+class Checkpoint:
69+ scope: str
70+ key: str
71+ value: dict[str, Any]
72+
73+
74+class Connector(ABC):
75+ source_id: str
76+
77+ @abstractmethod
78+ def discover(self) -> dict[str, Any]:
79+ """Light-weight live probe (e.g. last_update, listing counts). Never bulk-downloads."""
80+
81+ @abstractmethod
82+ def capabilities(self) -> Capabilities: ...
83+
84+ @abstractmethod
85+ def fetch_range(self, request: FetchRequest, dest_dir: Path) -> FetchResult: ...
86+
87+ @abstractmethod
88+ def normalize(self, result: FetchResult, **kwargs: Any): # -> Iterator[NormalizedBatch]
89+ ...
90+
91+ @abstractmethod
92+ def validate(self, batch: NormalizedBatch) -> ValidationReport: ...
93+
94+ @abstractmethod
95+ def checkpoint(self, result: FetchResult, batches_published: int) -> Checkpoint: ...
added packages/schema/src/findata_schema/contracts.py +116 −0
@@ -0,0 +1,116 @@
1+"""Canonical data contracts.
2+
3+Bars: `timestamp_utc` marks the **bar open** (vendor convention, enforced). `session_date` is the trading
4+session date in the exchange timezone (America/New_York for US listings). Volumes are float64 because vendor
5+adjusted volumes may be fractional; raw volumes are integral and validated as such.
6+"""
7+from __future__ import annotations
8+
9+import json
10+from enum import StrEnum
11+
12+import pyarrow as pa
13+
14+BARS_SCHEMA_VERSION = 1
15+
16+
17+class AssetClass(StrEnum):
18+ stock = "stock"
19+ etf = "etf"
20+ futures = "futures"
21+ index = "index"
22+ fx = "fx"
23+ crypto = "crypto"
24+ options = "options"
25+
26+
27+class Interval(StrEnum):
28+ m1 = "1m"
29+ m5 = "5m"
30+ m30 = "30m"
31+ h1 = "1h"
32+ d1 = "1d"
33+
34+
35+INTERVAL_SECONDS = {Interval.m1: 60, Interval.m5: 300, Interval.m30: 1800, Interval.h1: 3600, Interval.d1: 86400}
36+
37+
38+class Adjustment(StrEnum):
39+ raw = "raw" # vendor UNADJUSTED
40+ split = "split" # vendor adj_split
41+ split_div = "split_div" # vendor adj_splitdiv
42+ continuous = "continuous"
43+
44+
45+class QualityFlag(StrEnum):
46+ ohlc_inconsistent = "ohlc_inconsistent" # high < max(open, close) or low > min(open, close)
47+ nonpositive_price = "nonpositive_price"
48+ negative_volume = "negative_volume"
49+ zero_volume = "zero_volume"
50+ fractional_volume = "fractional_volume"
51+ off_interval = "off_interval" # timestamp not aligned to the interval grid
52+ duplicate = "duplicate"
53+ outside_session = "outside_session" # outside 04:00–20:00 ET extended hours
54+ non_trading_day = "non_trading_day"
55+ price_jump = "price_jump" # |close/prev_close - 1| > threshold within a session
56+ gap = "gap" # missing regular-hours minute(s) before this bar
57+ out_of_order = "out_of_order"
58+
59+
60+# The vendor CSV layout for intraday/daily bars (no header).
61+RAW_BAR_COLUMNS = ["datetime", "open", "high", "low", "close", "volume"]
62+
63+BARS_SCHEMA = pa.schema(
64+ [
65+ pa.field("instrument_id", pa.string(), nullable=False),
66+ pa.field("symbol_as_reported", pa.string(), nullable=False),
67+ pa.field("venue", pa.string(), nullable=True),
68+ pa.field("asset_class", pa.string(), nullable=False),
69+ pa.field("interval", pa.string(), nullable=False),
70+ pa.field("timestamp_utc", pa.timestamp("us", tz="UTC"), nullable=False),
71+ pa.field("session_date", pa.date32(), nullable=False),
72+ pa.field("open", pa.float64(), nullable=False),
73+ pa.field("high", pa.float64(), nullable=False),
74+ pa.field("low", pa.float64(), nullable=False),
75+ pa.field("close", pa.float64(), nullable=False),
76+ pa.field("volume", pa.float64(), nullable=False),
77+ pa.field("currency", pa.string(), nullable=False),
78+ pa.field("trade_count", pa.int32(), nullable=True),
79+ pa.field("vwap", pa.float64(), nullable=True),
80+ pa.field("adjustment_type", pa.string(), nullable=False),
81+ pa.field("source_id", pa.string(), nullable=False),
82+ pa.field("ingested_at", pa.timestamp("us", tz="UTC"), nullable=False),
83+ pa.field("quality_flags", pa.list_(pa.string()), nullable=False),
84+ pa.field("revision_id", pa.int32(), nullable=False),
85+ ],
86+ metadata={b"findata.schema": b"bars", b"findata.schema_version": str(BARS_SCHEMA_VERSION).encode(),
87+ b"findata.timestamp_convention": b"bar_open"},
88+)
89+
90+FUNDAMENTALS_SCHEMA = pa.schema(
91+ [
92+ pa.field("entity_id", pa.string(), nullable=False),
93+ pa.field("security_id", pa.string(), nullable=True),
94+ pa.field("metric", pa.string(), nullable=False),
95+ pa.field("value", pa.float64(), nullable=True),
96+ pa.field("unit", pa.string(), nullable=False),
97+ pa.field("currency", pa.string(), nullable=True),
98+ pa.field("fiscal_period_start", pa.date32(), nullable=True),
99+ pa.field("fiscal_period_end", pa.date32(), nullable=False),
100+ pa.field("fiscal_period_type", pa.string(), nullable=False),
101+ pa.field("fiscal_year", pa.int32(), nullable=True),
102+ pa.field("fiscal_period", pa.string(), nullable=True),
103+ pa.field("filing_date", pa.date32(), nullable=True),
104+ pa.field("published_at", pa.timestamp("us", tz="UTC"), nullable=True),
105+ pa.field("available_at", pa.timestamp("us", tz="UTC"), nullable=False),
106+ pa.field("observed_at", pa.timestamp("us", tz="UTC"), nullable=False),
107+ pa.field("source_document", pa.string(), nullable=True),
108+ pa.field("source_id", pa.string(), nullable=False),
109+ pa.field("revision_id", pa.int32(), nullable=False),
110+ pa.field("restatement_flag", pa.bool_(), nullable=False),
111+ ]
112+)
113+
114+
115+def bars_schema_json() -> str:
116+ return json.dumps([{"name": f.name, "type": str(f.type), "nullable": f.nullable} for f in BARS_SCHEMA], indent=2)
added pyproject.toml +57 −0
@@ -0,0 +1,57 @@
1+[project]
2+name = "findata-os"
3+version = "0.1.0"
4+description = "FinData OS — private research-grade financial data lakehouse (MacLustr)"
5+requires-python = ">=3.12"
6+dependencies = [
7+ "findata-core",
8+ "findata-schema",
9+ "findata-quality",
10+ "findata-market-firstrate",
11+ "findata-fundamentals",
12+ "findata-ingest",
13+ "findata-api",
14+ "findata-sdk",
15+]
16+
17+[dependency-groups]
18+dev = [
19+ "pytest>=8.3",
20+ "pytest-asyncio>=0.24",
21+ "httpx>=0.28",
22+ "ruff>=0.8",
23+ "pip-audit>=2.7",
24+ "freezegun>=1.5",
25+]
26+
27+[tool.uv.workspace]
28+members = [
29+ "packages/core",
30+ "packages/schema",
31+ "packages/python-sdk",
32+ "services/quality",
33+ "services/ingest",
34+ "services/api",
35+ "connectors/market",
36+ "connectors/fundamentals",
37+]
38+
39+[tool.uv.sources]
40+findata-core = { workspace = true }
41+findata-schema = { workspace = true }
42+findata-quality = { workspace = true }
43+findata-market-firstrate = { workspace = true }
44+findata-fundamentals = { workspace = true }
45+findata-ingest = { workspace = true }
46+findata-api = { workspace = true }
47+findata-sdk = { workspace = true }
48+
49+[tool.pytest.ini_options]
50+testpaths = ["tests"]
51+asyncio_mode = "auto"
52+markers = ["node: requires the storage node (Postgres, lake)", "vendor: performs a real vendor call (never in CI)"]
53+filterwarnings = ["ignore::DeprecationWarning"]
54+
55+[tool.ruff]
56+line-length = 110
57+target-version = "py312"
added services/api/pyproject.toml +18 −0
@@ -0,0 +1,18 @@
1+[project]
2+name = "findata-api"
3+version = "0.1.0"
4+requires-python = ">=3.12"
5+dependencies = ["findata-core", "findata-schema", "findata-ingest", "findata-fundamentals", "findata-market-firstrate",
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"]
8+[tool.uv.sources]
9+findata-core = { workspace = true }
10+findata-schema = { workspace = true }
11+findata-ingest = { workspace = true }
12+findata-fundamentals = { workspace = true }
13+findata-market-firstrate = { workspace = true }
14+[build-system]
15+requires = ["hatchling"]
16+build-backend = "hatchling.build"
17+[tool.hatch.build.targets.wheel]
18+packages = ["src/findata_api"]
added services/api/src/findata_api/__init__.py +0 −0
added services/api/src/findata_api/auth.py +244 −0
@@ -0,0 +1,244 @@
1+"""Authentication & authorization: owner password + TOTP MFA sessions (cookie, CSRF double-submit) and scoped API keys.
2+
3+Scopes: read:market, read:fundamentals, query, export, admin. Owner sessions implicitly have every scope once MFA
4+is passed (or if MFA is not enabled yet — the console nags to enable it)."""
5+from __future__ import annotations
6+
7+import base64
8+import hashlib
9+import hmac
10+import io
11+import json
12+import secrets
13+from dataclasses import dataclass, field
14+from datetime import UTC, datetime, timedelta
15+from typing import Any
16+
17+import pyotp
18+from argon2 import PasswordHasher
19+from argon2.exceptions import VerifyMismatchError
20+from fastapi import Depends, Request
21+
22+from findata_core.config import get_settings
23+from findata_core.db import connection, transaction
24+from findata_api.errors import ApiError
25+
26+SESSION_COOKIE = "fd_session"
27+SESSION_TTL = timedelta(hours=12)
28+ALL_SCOPES = ("read:market", "read:fundamentals", "query", "export", "admin")
29+_ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2)
30+
31+
32+@dataclass
33+class Principal:
34+ kind: str # user | api_key | anonymous
35+ id: str | None
36+ email: str | None
37+ role: str | None
38+ scopes: set[str] = field(default_factory=set)
39+ session_id: str | None = None
40+ csrf_token: str | None = None
41+ mfa_passed: bool = True
42+ rate_limit_per_minute: int = 600
43+
44+ @property
45+ def label(self) -> str:
46+ return f"{self.kind}:{self.id}"
47+
48+ def has(self, scope: str) -> bool:
49+ return scope in self.scopes or "admin" in self.scopes
50+
51+
52+# ----------------------------------------------------------------------------- password / totp
53+def hash_password(pw: str) -> str:
54+ return _ph.hash(pw)
55+
56+
57+def verify_password(h: str, pw: str) -> bool:
58+ try:
59+ return _ph.verify(h, pw)
60+ except VerifyMismatchError:
61+ return False
62+
63+
64+def _enc_secret(raw: str) -> str:
65+ """TOTP secrets are stored XOR-obfuscated with the app key (defense in depth vs. plain DB dumps)."""
66+ key = hashlib.sha256(get_settings().secret_key.get_secret_value().encode()).digest()
67+ data = raw.encode()
68+ return base64.b64encode(bytes(b ^ key[i % len(key)] for i, b in enumerate(data))).decode()
69+
70+
71+def _dec_secret(enc: str) -> str:
72+ key = hashlib.sha256(get_settings().secret_key.get_secret_value().encode()).digest()
73+ data = base64.b64decode(enc)
74+ return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)).decode()
75+
76+
77+# ----------------------------------------------------------------------------- sessions
78+def create_session(user: dict[str, Any], ip: str | None, ua: str | None) -> tuple[str, str, bool]:
79+ sid = secrets.token_urlsafe(32)
80+ csrf = secrets.token_urlsafe(24)
81+ mfa_passed = not user["totp_enabled"]
82+ with transaction() as conn:
83+ conn.execute("INSERT INTO sessions(id, user_id, csrf_token, mfa_passed, ip, user_agent, expires_at) VALUES (%s,%s,%s,%s,%s,%s,%s)",
84+ (_sid_hash(sid), user["id"], csrf, mfa_passed, ip, (ua or "")[:300], datetime.now(UTC) + SESSION_TTL))
85+ conn.execute("UPDATE users SET last_login_at=now() WHERE id=%s", (user["id"],))
86+ return sid, csrf, mfa_passed
87+
88+
89+def _sid_hash(sid: str) -> str:
90+ return hashlib.sha256(sid.encode()).hexdigest()
91+
92+
93+def load_session(sid: str) -> dict[str, Any] | None:
94+ with connection() as conn:
95+ return conn.execute("SELECT s.*, u.email, u.role, u.disabled, u.totp_enabled FROM sessions s JOIN users u ON u.id=s.user_id "
96+ "WHERE s.id=%s AND s.revoked_at IS NULL AND s.expires_at > now()", (_sid_hash(sid),)).fetchone()
97+
98+
99+def revoke_session(sid: str) -> None:
100+ with transaction() as conn:
101+ conn.execute("UPDATE sessions SET revoked_at=now() WHERE id=%s", (_sid_hash(sid),))
102+
103+
104+def mark_mfa_passed(sid: str) -> None:
105+ with transaction() as conn:
106+ conn.execute("UPDATE sessions SET mfa_passed=true WHERE id=%s", (_sid_hash(sid),))
107+
108+
109+# ----------------------------------------------------------------------------- api keys
110+def new_api_key(user_id: str, name: str, scopes: list[str], rate_limit: int = 600, expires_at: datetime | None = None) -> dict[str, Any]:
111+ bad = [s for s in scopes if s not in ALL_SCOPES]
112+ if bad:
113+ raise ValueError(f"unknown scopes: {bad}")
114+ prefix = "fd_" + secrets.token_hex(4)
115+ secret = secrets.token_urlsafe(32)
116+ token = f"{prefix}.{secret}"
117+ with transaction() as conn:
118+ r = conn.execute("INSERT INTO api_keys(user_id, name, prefix, key_hash, scopes, rate_limit_per_minute, expires_at) VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id",
119+ (user_id, name, prefix, _key_hash(secret), scopes, rate_limit, expires_at)).fetchone()
120+ return {"id": str(r["id"]), "name": name, "prefix": prefix, "token": token, "scopes": scopes,
121+ "note": "Store this token now; it is shown once and only its hash is kept."}
122+
123+
124+def _key_hash(secret: str) -> str:
125+ return hmac.new(get_settings().secret_key.get_secret_value().encode(), secret.encode(), hashlib.sha256).hexdigest()
126+
127+
128+def verify_api_key(token: str) -> dict[str, Any] | None:
129+ if "." not in token or not token.startswith("fd_"):
130+ return None
131+ prefix, secret = token.split(".", 1)
132+ with connection() as conn:
133+ row = conn.execute("SELECT k.*, u.email, u.role, u.disabled FROM api_keys k JOIN users u ON u.id=k.user_id WHERE k.prefix=%s", (prefix,)).fetchone()
134+ if not row or row["revoked_at"] or row["disabled"]:
135+ return None
136+ if row["expires_at"] and row["expires_at"] < datetime.now(UTC):
137+ return None
138+ if not hmac.compare_digest(row["key_hash"], _key_hash(secret)):
139+ return None
140+ with transaction() as conn:
141+ conn.execute("UPDATE api_keys SET last_used_at=now() WHERE id=%s", (row["id"],))
142+ return row
143+
144+
145+# ----------------------------------------------------------------------------- dependencies
146+def get_principal(request: Request) -> Principal:
147+ auth = request.headers.get("authorization", "")
148+ if auth.lower().startswith("bearer "):
149+ row = verify_api_key(auth[7:].strip())
150+ if not row:
151+ raise ApiError(401, "invalid_api_key", "API key is invalid, revoked or expired")
152+ return Principal("api_key", str(row["id"]), row["email"], row["role"], set(row["scopes"]),
153+ rate_limit_per_minute=row["rate_limit_per_minute"])
154+ sid = request.cookies.get(SESSION_COOKIE)
155+ if sid:
156+ s = load_session(sid)
157+ if s and not s["disabled"]:
158+ scopes = set(ALL_SCOPES) if s["role"] == "owner" else {"read:market", "read:fundamentals", "query", "export"}
159+ if not s["mfa_passed"]:
160+ scopes = set()
161+ return Principal("user", str(s["user_id"]), s["email"], s["role"], scopes, session_id=sid,
162+ csrf_token=s["csrf_token"], mfa_passed=s["mfa_passed"])
163+ return Principal("anonymous", None, None, None, set())
164+
165+
166+def require(scope: str): # noqa: ANN201
167+ def dep(request: Request, p: Principal = Depends(get_principal)) -> Principal:
168+ if p.kind == "anonymous":
169+ raise ApiError(401, "unauthenticated", "Sign in or provide an API key")
170+ if p.kind == "user" and not p.mfa_passed:
171+ raise ApiError(401, "mfa_required", "Complete the second factor to continue")
172+ if not p.has(scope):
173+ raise ApiError(403, "forbidden", f"Scope '{scope}' required")
174+ if p.kind == "user" and request.method not in ("GET", "HEAD", "OPTIONS"):
175+ if request.headers.get("x-csrf-token") != p.csrf_token:
176+ raise ApiError(403, "csrf", "Missing or invalid CSRF token")
177+ request.state.principal = p
178+ return p
179+ return dep
180+
181+
182+def require_user_session(request: Request, p: Principal = Depends(get_principal)) -> Principal:
183+ """Session present (MFA may still be pending) — used by the MFA step itself."""
184+ if p.kind != "user":
185+ raise ApiError(401, "unauthenticated", "Sign in first")
186+ request.state.principal = p
187+ return p
188+
189+
190+# ----------------------------------------------------------------------------- owner bootstrap (CLI)
191+def create_owner(email: str, password: str | None) -> dict[str, Any]:
192+ pw = password or secrets.token_urlsafe(16)
193+ with transaction() as conn:
194+ u = conn.execute("SELECT id FROM users WHERE email=%s", (email,)).fetchone()
195+ if u:
196+ conn.execute("UPDATE users SET password_hash=%s WHERE id=%s", (hash_password(pw), u["id"]))
197+ return {"email": email, "password": pw if not password else "(as provided)", "note": "password reset"}
198+ conn.execute("INSERT INTO users(email, password_hash, role) VALUES (%s,%s,'owner')", (email, hash_password(pw)))
199+ return {"email": email, "password": pw if not password else "(as provided)", "note": "owner created; enable TOTP in the console (Settings → Security)"}
200+
201+
202+def create_api_key_for_email(email: str, name: str, scopes: list[str]) -> dict[str, Any]:
203+ with connection() as conn:
204+ u = conn.execute("SELECT id FROM users WHERE email=%s", (email,)).fetchone()
205+ if not u:
206+ raise ValueError("no such user")
207+ return new_api_key(str(u["id"]), name, scopes)
208+
209+
210+def totp_setup(user_id: str, email: str) -> dict[str, Any]:
211+ secret = pyotp.random_base32()
212+ with transaction() as conn:
213+ conn.execute("UPDATE users SET totp_secret=%s, totp_enabled=false WHERE id=%s", (_enc_secret(secret), user_id))
214+ uri = pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name="FinData OS")
215+ try:
216+ import qrcode
217+ img = qrcode.make(uri)
218+ buf = io.BytesIO()
219+ img.save(buf, format="PNG")
220+ qr = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
221+ except Exception: # noqa: BLE001
222+ qr = None
223+ return {"otpauth_uri": uri, "qr_png_data_url": qr, "secret": secret}
224+
225+
226+def totp_verify(user_id: str, code: str, enable: bool = False) -> bool:
227+ with connection() as conn:
228+ u = conn.execute("SELECT totp_secret FROM users WHERE id=%s", (user_id,)).fetchone()
229+ if not u or not u["totp_secret"]:
230+ return False
231+ ok = pyotp.TOTP(_dec_secret(u["totp_secret"])).verify(code.replace(" ", ""), valid_window=1)
232+ if ok and enable:
233+ with transaction() as conn:
234+ conn.execute("UPDATE users SET totp_enabled=true WHERE id=%s", (user_id,))
235+ return ok
236+
237+
238+def audit(request: Request | None, action: str, target: str | None = None, outcome: str = "ok", **details: Any) -> None:
239+ p: Principal | None = getattr(request.state, "principal", None) if request else None
240+ with transaction() as conn:
241+ conn.execute("INSERT INTO audit_log(actor_type, actor_id, action, target, request_id, ip, outcome, details) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
242+ (p.kind if p else "system", p.id if p else None, action, target,
243+ getattr(request.state, "request_id", None) if request else None,
244+ request.client.host if request and request.client else None, outcome, json.dumps(details, default=str)))
added services/api/src/findata_api/errors.py +8 −0
@@ -0,0 +1,8 @@
1+from __future__ import annotations
2+
3+from fastapi import HTTPException
4+
5+
6+class ApiError(HTTPException):
7+ def __init__(self, status: int, code: str, message: str, **extra): # noqa: ANN003
8+ super().__init__(status_code=status, detail={"code": code, "message": message, **extra})
added services/api/src/findata_api/health.py +107 −0
@@ -0,0 +1,107 @@
1+"""Health/alert evaluation shared by /api/v1/health, /metrics and the console."""
2+from __future__ import annotations
3+
4+from datetime import UTC, datetime
5+from typing import Any
6+
7+from findata_core.config import get_settings
8+from findata_core.db import connection
9+from findata_ingest.lake import Lake
10+from findata_api import metrics as m
11+
12+
13+def evaluate() -> dict[str, Any]:
14+ s = get_settings()
15+ lake = Lake()
16+ out: dict[str, Any] = {"status": "ok", "checked_at": datetime.now(UTC).isoformat(), "alerts": [], "components": {}}
17+ try:
18+ with connection() as conn:
19+ conn.execute("SELECT 1")
20+ out["components"]["postgres"] = "ok"
21+ jobs = {r["status"]: r["n"] for r in conn.execute("SELECT status, count(*) n FROM ingest_jobs GROUP BY status").fetchall()}
22+ for st in ("queued", "running", "succeeded", "failed", "dead"):
23+ m.JOBS.labels(st).set(jobs.get(st, 0))
24+ ds = conn.execute("SELECT d.id, d.status, d.current_version, (SELECT max(session_date) FROM coverage c WHERE c.dataset_id=d.id) last_session, "
25+ "(SELECT value FROM checkpoints WHERE scope='dataset:'||d.id AND key='last_vendor_update:day') ck "
26+ "FROM datasets d WHERE d.status='active' AND d.kind IN ('market_bars','derived')").fetchall()
27+ out["datasets"] = []
28+ for d in ds:
29+ last = d["last_session"]
30+ fresh = (datetime.now(UTC).date() - last).days if last else None
31+ vendor = (d["ck"] or {}).get("vendor_last_update") if d["ck"] else None
32+ lag = None
33+ if vendor and last:
34+ lag = (datetime.fromisoformat(vendor).date() - last).days
35+ out["datasets"].append({"dataset_id": d["id"], "version": d["current_version"], "last_session": str(last) if last else None,
36+ "days_since_last_session": fresh, "vendor_last_update": vendor, "vendor_lag_days": lag})
37+ if fresh is not None:
38+ m.FRESHNESS.labels(d["id"]).set(fresh)
39+ if lag is not None:
40+ m.VENDOR_LAG.labels(d["id"]).set(lag)
41+ if lag > 0:
42+ out["alerts"].append({"severity": "warning", "code": "stale_dataset", "message": f"{d['id']} is {lag} day(s) behind the vendor"})
43+ if fresh is not None and fresh > 5:
44+ out["alerts"].append({"severity": "warning", "code": "no_recent_session", "message": f"{d['id']}: last session {last} ({fresh} days ago)"})
45+ dead = jobs.get("dead", 0)
46+ if dead:
47+ out["alerts"].append({"severity": "error", "code": "dead_jobs", "message": f"{dead} job(s) in the dead-letter queue"})
48+ lb = conn.execute("SELECT value, updated_at FROM settings_kv WHERE key='last_backup'").fetchone()
49+ if lb:
50+ age_h = (datetime.now(UTC) - lb["updated_at"]).total_seconds() / 3600
51+ m.BACKUP_AGE.set(age_h)
52+ out["components"]["backup"] = {"last": lb["updated_at"].isoformat(), "age_hours": round(age_h, 1), "verified": lb["value"].get("verified")}
53+ if age_h > 48:
54+ out["alerts"].append({"severity": "warning", "code": "backup_stale", "message": f"last backup {age_h:.0f} h ago"})
55+ else:
56+ out["components"]["backup"] = {"last": None}
57+ out["alerts"].append({"severity": "info", "code": "no_backup_yet", "message": "no backup recorded yet"})
58+ lock = lake.root / ".writer.lock"
59+ worker_alive = False
60+ if lock.exists():
61+ try:
62+ import fcntl
63+ fh = open(lock)
64+ try:
65+ fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
66+ fcntl.flock(fh, fcntl.LOCK_UN)
67+ except BlockingIOError:
68+ worker_alive = True
69+ finally:
70+ fh.close()
71+ except OSError:
72+ pass
73+ out["components"]["ingest_worker"] = "running" if worker_alive else "not running"
74+ if not worker_alive:
75+ out["alerts"].append({"severity": "error", "code": "worker_down", "message": "ingest worker is not holding the lake lock"})
76+ an = conn.execute("SELECT dataset_id, severity, count(*) n FROM quality_anomalies WHERE created_at > now() - interval '7 days' GROUP BY 1,2").fetchall()
77+ for a in an:
78+ m.ANOMALIES.labels(a["dataset_id"], a["severity"]).set(a["n"])
79+ except Exception as e: # noqa: BLE001
80+ out["components"]["postgres"] = f"error: {e}"
81+ out["alerts"].append({"severity": "error", "code": "postgres_down", "message": str(e)[:200]})
82+ free = lake.free_gb()
83+ m.LAKE_FREE.set(free)
84+ curated = lake.used_bytes("curated")
85+ m.LAKE_CURATED.set(curated)
86+ out["components"]["lake"] = {"root": str(lake.root), "free_gb": round(free, 1), "curated_gb": round(curated / 1e9, 2),
87+ "raw_gb": round(lake.used_bytes("raw") / 1e9, 2), "soft_limit_gb": s.lake_soft_limit_gb, "hard_min_free_gb": s.lake_hard_min_free_gb}
88+ if free < s.lake_hard_min_free_gb:
89+ out["alerts"].append({"severity": "error", "code": "disk_critical", "message": f"only {free:.0f} GB free"})
90+ elif free < s.lake_hard_min_free_gb * 2:
91+ out["alerts"].append({"severity": "warning", "code": "disk_low", "message": f"{free:.0f} GB free"})
92+ if curated / 1e9 > s.lake_soft_limit_gb:
93+ out["alerts"].append({"severity": "warning", "code": "lake_over_budget", "message": f"curated {curated/1e9:.0f} GB > {s.lake_soft_limit_gb} GB"})
94+ try:
95+ import subprocess
96+ r = subprocess.run(["diskutil", "info", "disk0"], capture_output=True, text=True, timeout=10)
97+ smart = next((ln.split(":", 1)[1].strip() for ln in r.stdout.splitlines() if "SMART Status" in ln), "unknown")
98+ out["components"]["ssd_smart"] = smart
99+ if smart not in ("Verified", "Not Supported", "unknown"):
100+ out["alerts"].append({"severity": "error", "code": "ssd_smart", "message": f"SMART status: {smart}"})
101+ except Exception: # noqa: BLE001
102+ out["components"]["ssd_smart"] = "unavailable"
103+ if any(a["severity"] == "error" for a in out["alerts"]):
104+ out["status"] = "degraded"
105+ elif out["alerts"]:
106+ out["status"] = "warning"
107+ return out
added services/api/src/findata_api/main.py +127 −0
@@ -0,0 +1,127 @@
1+"""FastAPI application: /api/v1 (OpenAPI at /api/v1/openapi.json, docs at /api/docs), /metrics, static console."""
2+from __future__ import annotations
3+
4+import time
5+import uuid
6+from pathlib import Path
7+
8+from fastapi import Depends, FastAPI, Request
9+from fastapi.exceptions import RequestValidationError
10+from fastapi.responses import JSONResponse, Response
11+from fastapi.staticfiles import StaticFiles
12+from starlette.exceptions import HTTPException as StarletteHTTPException
13+from starlette.middleware.base import BaseHTTPMiddleware
14+
15+from findata_api import auth as A
16+from findata_api import metrics as M
17+from findata_api.ratelimit import bucket
18+from findata_api.routers import admin, auth, catalog, coverage, exports, fundamentals, health, instruments, market, quality, query
19+from findata_core.config import get_settings
20+from findata_core.logging import configure_logging, get_logger
21+
22+API_PREFIX = "/api/v1"
23+
24+
25+class Observability(BaseHTTPMiddleware):
26+ async def dispatch(self, request: Request, call_next): # noqa: ANN001
27+ rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:16]
28+ request.state.request_id = rid
29+ t0 = time.monotonic()
30+ route = request.url.path
31+ try:
32+ response = await call_next(request)
33+ except Exception: # noqa: BLE001
34+ M.REQUESTS.labels(request.method, route, "500").inc()
35+ raise
36+ dt = time.monotonic() - t0
37+ if route.startswith(API_PREFIX):
38+ M.REQUESTS.labels(request.method, _route_label(route), str(response.status_code)).inc()
39+ M.LATENCY.labels(_route_label(route)).observe(dt)
40+ response.headers["X-Request-ID"] = rid
41+ response.headers["X-Content-Type-Options"] = "nosniff"
42+ response.headers["X-Frame-Options"] = "DENY"
43+ response.headers["Referrer-Policy"] = "no-referrer"
44+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
45+ if get_settings().is_production:
46+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
47+ if not route.startswith("/api/docs"):
48+ response.headers["Content-Security-Policy"] = ("default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
49+ "script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
50+ return response
51+
52+
53+def _route_label(path: str) -> str:
54+ parts = path.split("/")
55+ return "/".join(p if not _looks_id(p) else "{id}" for p in parts[:6])
56+
57+
58+def _looks_id(p: str) -> bool:
59+ return len(p) >= 16 and all(c in "0123456789abcdef-" for c in p)
60+
61+
62+class RateLimit(BaseHTTPMiddleware):
63+ async def dispatch(self, request: Request, call_next): # noqa: ANN001
64+ if request.url.path.startswith(API_PREFIX) and not request.url.path.endswith("/health"):
65+ auth_h = request.headers.get("authorization", "")
66+ key = auth_h[-24:] if auth_h else (request.cookies.get(A.SESSION_COOKIE, "")[-24:] or (request.client.host if request.client else "anon"))
67+ ok, remaining = bucket.allow(f"rl:{key}", get_settings().rate_limit_per_minute)
68+ if not ok:
69+ return JSONResponse({"error": {"code": "rate_limited", "message": "Too many requests", "request_id": getattr(request.state, "request_id", None)}},
70+ status_code=429, headers={"Retry-After": "5"})
71+ return await call_next(request)
72+
73+
74+def create_app() -> FastAPI:
75+ s = get_settings()
76+ configure_logging()
77+ log = get_logger("api")
78+ app = FastAPI(title="FinData OS API", version="1.0.0", openapi_url=f"{API_PREFIX}/openapi.json", docs_url="/api/docs", redoc_url=None,
79+ description="Private, research-grade financial data lakehouse. All market data © FirstRate Data — no redistribution. "
80+ "Timestamps are UTC ISO-8601; bars are stamped at bar **open**.")
81+ app.add_middleware(Observability)
82+ app.add_middleware(RateLimit)
83+
84+ @app.exception_handler(StarletteHTTPException)
85+ async def http_exc(request: Request, exc: StarletteHTTPException): # noqa: ANN001
86+ detail = exc.detail if isinstance(exc.detail, dict) else {"code": "http_error", "message": str(exc.detail)}
87+ detail["request_id"] = getattr(request.state, "request_id", None)
88+ return JSONResponse({"error": detail}, status_code=exc.status_code, headers=getattr(exc, "headers", None))
89+
90+ @app.exception_handler(RequestValidationError)
91+ async def val_exc(request: Request, exc: RequestValidationError): # noqa: ANN001
92+ return JSONResponse({"error": {"code": "validation_error", "message": "Invalid request", "details": exc.errors(),
93+ "request_id": getattr(request.state, "request_id", None)}}, status_code=422)
94+
95+ @app.exception_handler(Exception)
96+ async def any_exc(request: Request, exc: Exception): # noqa: ANN001
97+ from findata_core.redact import redact
98+ log.error("unhandled", error=redact(repr(exc), s.secret_values()), path=request.url.path)
99+ return JSONResponse({"error": {"code": "internal_error", "message": "Internal error", "request_id": getattr(request.state, "request_id", None)}}, status_code=500)
100+
101+ for r in (auth.router, catalog.router, instruments.router, market.router, fundamentals.router, query.router, exports.router,
102+ coverage.router, quality.router, health.router, admin.router):
103+ app.include_router(r, prefix=API_PREFIX)
104+
105+ @app.get("/metrics", include_in_schema=False)
106+ def metrics(request: Request, p: A.Principal = Depends(A.get_principal)):
107+ host = request.client.host if request.client else ""
108+ private = host in ("127.0.0.1", "::1") or host.startswith(("192.168.", "10.67.", "10.", "172.16."))
109+ if not private and not p.has("admin"):
110+ return JSONResponse({"error": {"code": "forbidden", "message": "metrics require admin or a private network"}}, status_code=403)
111+ from findata_api.health import evaluate
112+ evaluate()
113+ body, ctype = M.render()
114+ return Response(body, media_type=ctype)
115+
116+ web = s.web_dist or Path(s.root) / "apps" / "web" / "out"
117+ if web.exists():
118+ app.mount("/", StaticFiles(directory=str(web), html=True), name="web")
119+ log.info("web.mounted", path=str(web))
120+ else:
121+ @app.get("/", include_in_schema=False)
122+ def root():
123+ return {"service": "FinData OS", "docs": "/api/docs", "note": "web console not built"}
124+ return app
125+
126+
127+app = create_app()
added services/api/src/findata_api/metrics.py +18 −0
@@ -0,0 +1,18 @@
1+from __future__ import annotations
2+
3+from prometheus_client import CONTENT_TYPE_LATEST, Counter, Gauge, Histogram, generate_latest
4+
5+REQUESTS = Counter("findata_http_requests_total", "HTTP requests", ["method", "route", "status"])
6+LATENCY = Histogram("findata_http_request_seconds", "Request latency", ["route"], buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30))
7+ROWS = Counter("findata_rows_returned_total", "Rows returned by data endpoints", ["endpoint"])
8+LAKE_FREE = Gauge("findata_lake_free_gb", "Free space on the lake volume (GB)")
9+LAKE_CURATED = Gauge("findata_lake_curated_bytes", "Curated Parquet bytes")
10+JOBS = Gauge("findata_jobs", "Ingest jobs by status", ["status"])
11+FRESHNESS = Gauge("findata_dataset_freshness_days", "Days since last session in dataset", ["dataset"])
12+VENDOR_LAG = Gauge("findata_vendor_lag_days", "Days between vendor last_update and last ingested session", ["dataset"])
13+BACKUP_AGE = Gauge("findata_backup_age_hours", "Hours since last successful backup")
14+ANOMALIES = Gauge("findata_quality_anomalies_7d", "Quality anomalies in the last 7 days", ["dataset", "severity"])
15+
16+
17+def render() -> tuple[bytes, str]:
18+ return generate_latest(), CONTENT_TYPE_LATEST
added services/api/src/findata_api/ratelimit.py +34 −0
@@ -0,0 +1,34 @@
1+"""In-process token bucket per principal (single API process). Quotas (rows/day) live in usage_daily."""
2+from __future__ import annotations
3+
4+import threading
5+import time
6+
7+from findata_core.db import transaction
8+
9+
10+class TokenBucket:
11+ def __init__(self):
12+ self._b: dict[str, tuple[float, float]] = {}
13+ self._lock = threading.Lock()
14+
15+ def allow(self, key: str, per_minute: int) -> tuple[bool, int]:
16+ now = time.monotonic()
17+ with self._lock:
18+ tokens, last = self._b.get(key, (float(per_minute), now))
19+ tokens = min(per_minute, tokens + (now - last) * per_minute / 60.0)
20+ if tokens >= 1:
21+ self._b[key] = (tokens - 1, now)
22+ return True, int(tokens - 1)
23+ self._b[key] = (tokens, now)
24+ return False, 0
25+
26+
27+bucket = TokenBucket()
28+
29+
30+def record_usage(principal: str, rows: int = 0, bytes_exported: int = 0) -> None:
31+ with transaction() as conn:
32+ conn.execute("INSERT INTO usage_daily(principal, day, requests, rows_returned, bytes_exported) VALUES (%s, current_date, 1, %s, %s) "
33+ "ON CONFLICT (principal, day) DO UPDATE SET requests=usage_daily.requests+1, rows_returned=usage_daily.rows_returned+EXCLUDED.rows_returned, "
34+ "bytes_exported=usage_daily.bytes_exported+EXCLUDED.bytes_exported", (principal, rows, bytes_exported))
added services/api/src/findata_api/routers/__init__.py +0 −0
added services/api/src/findata_api/routers/admin.py +162 −0
@@ -0,0 +1,162 @@
1+from __future__ import annotations
2+
3+import json
4+from datetime import datetime
5+from typing import Any
6+
7+from fastapi import APIRouter, Depends, Query, Request
8+from pydantic import BaseModel, Field
9+
10+from findata_api import auth as A
11+from findata_api.errors import ApiError
12+from findata_core.config import get_settings
13+from findata_core.db import connection, transaction
14+from findata_ingest import queue
15+from findata_ingest.lake import Lake
16+
17+router = APIRouter(prefix="/admin", tags=["admin"])
18+
19+
20+class JobIn(BaseModel):
21+ kind: str
22+ dataset_id: str | None = None
23+ params: dict[str, Any] = Field(default_factory=dict)
24+ priority: int = 50
25+
26+
27+class KeyIn(BaseModel):
28+ name: str = Field(..., min_length=1, max_length=60)
29+ scopes: list[str]
30+ rate_limit_per_minute: int = Field(600, ge=1, le=10000)
31+ expires_at: datetime | None = None
32+
33+
34+class TrackedIn(BaseModel):
35+ tickers: list[str]
36+
37+
38+@router.get("/jobs")
39+def jobs(status: str | None = None, kind: str | None = None, limit: int = Query(100, le=1000), p: A.Principal = Depends(A.require("admin"))):
40+ with connection() as conn:
41+ rows = conn.execute("SELECT id, kind, dataset_id, params, priority, status, attempts, max_attempts, next_run_at, created_at, started_at, finished_at, last_error, progress, result, created_by "
42+ "FROM ingest_jobs WHERE (%s::text IS NULL OR status=%s) AND (%s::text IS NULL OR kind=%s) ORDER BY created_at DESC LIMIT %s",
43+ (status, status, kind, kind, limit)).fetchall()
44+ return {"data": rows}
45+
46+
47+@router.post("/jobs", status_code=201)
48+def enqueue(body: JobIn, request: Request, p: A.Principal = Depends(A.require("admin"))):
49+ from findata_ingest.tasks import REGISTRY
50+ if body.kind not in REGISTRY:
51+ raise ApiError(400, "bad_kind", f"kind must be one of {sorted(REGISTRY)}")
52+ if body.kind == "backfill":
53+ lake = Lake()
54+ if lake.free_gb() < get_settings().lake_hard_min_free_gb * 1.5:
55+ raise ApiError(409, "disk_budget", "not enough free space to start a backfill")
56+ jid = queue.enqueue(body.kind, body.dataset_id, body.params, body.priority, created_by=p.email or p.label)
57+ A.audit(request, "admin.enqueue", jid, kind=body.kind, dataset=body.dataset_id, params=body.params)
58+ return {"id": jid}
59+
60+
61+@router.post("/jobs/{job_id}/retry")
62+def retry(job_id: str, request: Request, p: A.Principal = Depends(A.require("admin"))):
63+ with transaction() as conn:
64+ r = conn.execute("UPDATE ingest_jobs SET status='queued', attempts=0, next_run_at=now(), last_error=NULL WHERE id=%s AND status IN ('failed','dead','cancelled') RETURNING id", (job_id,)).fetchone()
65+ if not r:
66+ raise ApiError(409, "not_retryable", "job is not in a retryable state")
67+ A.audit(request, "admin.retry", job_id)
68+ return {"ok": True}
69+
70+
71+@router.post("/jobs/{job_id}/cancel")
72+def cancel(job_id: str, request: Request, p: A.Principal = Depends(A.require("admin"))):
73+ with transaction() as conn:
74+ r = conn.execute("UPDATE ingest_jobs SET status='cancelled', finished_at=now() WHERE id=%s AND status='queued' RETURNING id", (job_id,)).fetchone()
75+ if not r:
76+ raise ApiError(409, "not_cancellable", "only queued jobs can be cancelled")
77+ A.audit(request, "admin.cancel", job_id)
78+ return {"ok": True}
79+
80+
81+@router.get("/keys")
82+def keys(p: A.Principal = Depends(A.require("admin"))):
83+ with connection() as conn:
84+ rows = conn.execute("SELECT k.id, k.name, k.prefix, k.scopes, k.rate_limit_per_minute, k.expires_at, k.last_used_at, k.revoked_at, k.created_at, u.email "
85+ "FROM api_keys k JOIN users u ON u.id=k.user_id ORDER BY k.created_at DESC").fetchall()
86+ usage = conn.execute("SELECT principal, day, requests, rows_returned, bytes_exported FROM usage_daily WHERE day > current_date - 14 ORDER BY day DESC").fetchall()
87+ return {"data": rows, "usage": usage}
88+
89+
90+@router.post("/keys", status_code=201)
91+def create_key(body: KeyIn, request: Request, p: A.Principal = Depends(A.require("admin"))):
92+ try:
93+ out = A.new_api_key(p.id, body.name, body.scopes, body.rate_limit_per_minute, body.expires_at)
94+ except ValueError as e:
95+ raise ApiError(400, "bad_scopes", str(e)) from None
96+ A.audit(request, "admin.key_create", out["id"], name=body.name, scopes=body.scopes)
97+ return out
98+
99+
100+@router.delete("/keys/{key_id}")
101+def revoke_key(key_id: str, request: Request, p: A.Principal = Depends(A.require("admin"))):
102+ with transaction() as conn:
103+ conn.execute("UPDATE api_keys SET revoked_at=now() WHERE id=%s", (key_id,))
104+ A.audit(request, "admin.key_revoke", key_id)
105+ return {"ok": True}
106+
107+
108+@router.get("/audit")
109+def audit_log(limit: int = Query(200, le=2000), action: str | None = None, p: A.Principal = Depends(A.require("admin"))):
110+ with connection() as conn:
111+ rows = conn.execute("SELECT * FROM audit_log WHERE (%s::text IS NULL OR action LIKE %s) ORDER BY at DESC LIMIT %s", (action, (action or "") + "%", limit)).fetchall()
112+ return {"data": rows}
113+
114+
115+@router.get("/storage")
116+def storage(p: A.Principal = Depends(A.require("admin"))):
117+ lake = Lake()
118+ s = get_settings()
119+ with connection() as conn:
120+ per_ds = conn.execute("SELECT dataset_id, layout, count(*) files, sum(bytes) bytes, sum(row_count) rows FROM lake_files WHERE version_removed IS NULL GROUP BY 1,2 ORDER BY 1,2").fetchall()
121+ raw = conn.execute("SELECT status, count(*) n, sum(bytes) bytes FROM raw_archives GROUP BY status").fetchall()
122+ exports = conn.execute("SELECT status, count(*) n, coalesce(sum(bytes),0) bytes FROM exports GROUP BY status").fetchall()
123+ retired = conn.execute("SELECT count(*) n, coalesce(sum(bytes),0) bytes FROM lake_files WHERE version_removed IS NOT NULL").fetchone()
124+ db = conn.execute("SELECT pg_database_size(current_database()) bytes").fetchone()
125+ backup = conn.execute("SELECT value, updated_at FROM settings_kv WHERE key='last_backup'").fetchone()
126+ import os
127+ st = os.statvfs(lake.root)
128+ return {"lake_root": str(lake.root), "volume": {"total_gb": round(st.f_blocks * st.f_frsize / 1e9, 1), "free_gb": round(st.f_bavail * st.f_frsize / 1e9, 1)},
129+ "budget": {"soft_limit_gb": s.lake_soft_limit_gb, "hard_min_free_gb": s.lake_hard_min_free_gb},
130+ "curated_bytes": lake.used_bytes("curated"), "raw_bytes": lake.used_bytes("raw"), "retired_bytes": lake.used_bytes("_retired"),
131+ "exports_bytes": lake.used_bytes("exports"), "per_dataset": per_ds, "raw_archives": raw, "exports": exports, "retired_files": retired,
132+ "postgres_bytes": db["bytes"], "last_backup": backup,
133+ "capacity_model": {"bytes_per_1m_bar_curated": 13, "note": "see docs/DISCOVERY.md §5 — measured on the prior lake; recomputed from per_dataset above"}}
134+
135+
136+@router.get("/settings")
137+def get_settings_(p: A.Principal = Depends(A.require("admin"))):
138+ with connection() as conn:
139+ rows = conn.execute("SELECT key, value, updated_at FROM settings_kv WHERE key NOT LIKE 'secret%'").fetchall()
140+ s = get_settings()
141+ return {"settings": rows, "env": {"env": s.env, "lake": str(s.lake), "public_url": s.public_url, "backup_target": s.backup_target,
142+ "vendor_configured": bool(s.frd_userid.get_secret_value()), "sec_edgar_configured": bool(s.sec_edgar_user_agent),
143+ "ai_providers": {"anthropic": bool(s.anthropic_api_key.get_secret_value()), "openai": bool(s.openai_api_key.get_secret_value())}}}
144+
145+
146+@router.put("/settings/tracked-tickers")
147+def tracked(body: TrackedIn, request: Request, p: A.Principal = Depends(A.require("admin"))):
148+ tk = sorted({t.upper().strip() for t in body.tickers if t.strip()})[:2000]
149+ with transaction() as conn:
150+ conn.execute("INSERT INTO settings_kv(key, value) VALUES ('fundamentals_tracked_tickers', %s) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()", (json.dumps(tk),))
151+ A.audit(request, "admin.tracked_tickers", None, count=len(tk))
152+ return {"tickers": tk}
153+
154+
155+@router.post("/discover")
156+def discover(request: Request, p: A.Principal = Depends(A.require("admin"))):
157+ """Sanitized live vendor probe (no bulk download)."""
158+ from findata_market_firstrate import FirstRateMarketConnector
159+ from findata_fundamentals import SecEdgarConnector
160+ out = {"firstrate": FirstRateMarketConnector().discover(), "sec_edgar": SecEdgarConnector().status()}
161+ A.audit(request, "admin.discover")
162+ return out
added services/api/src/findata_api/routers/auth.py +93 −0
@@ -0,0 +1,93 @@
1+from __future__ import annotations
2+
3+from fastapi import APIRouter, Depends, Request, Response
4+from pydantic import BaseModel, EmailStr
5+
6+from findata_api import auth as A
7+from findata_api.errors import ApiError
8+from findata_core.config import get_settings
9+from findata_core.db import connection
10+
11+router = APIRouter(prefix="/auth", tags=["auth"])
12+
13+
14+class LoginIn(BaseModel):
15+ email: EmailStr
16+ password: str
17+
18+
19+class CodeIn(BaseModel):
20+ code: str
21+
22+
23+def _set_cookie(resp: Response, sid: str) -> None:
24+ s = get_settings()
25+ resp.set_cookie(A.SESSION_COOKIE, sid, httponly=True, secure=s.is_production or s.public_url.startswith("https"),
26+ samesite="strict", max_age=int(A.SESSION_TTL.total_seconds()), path="/")
27+
28+
29+@router.post("/login")
30+def login(body: LoginIn, request: Request, response: Response):
31+ with connection() as conn:
32+ u = conn.execute("SELECT * FROM users WHERE email=%s", (body.email.lower(),)).fetchone()
33+ ok, _ = __import__("findata_api.ratelimit", fromlist=["bucket"]).bucket.allow(f"login:{request.client.host if request.client else '?'}", 10)
34+ if not ok:
35+ raise ApiError(429, "rate_limited", "Too many login attempts")
36+ if not u or u["disabled"] or not A.verify_password(u["password_hash"], body.password):
37+ A.audit(request, "auth.login", body.email, outcome="denied")
38+ raise ApiError(401, "invalid_credentials", "Invalid e-mail or password")
39+ sid, csrf, mfa_passed = A.create_session(u, request.client.host if request.client else None, request.headers.get("user-agent"))
40+ _set_cookie(response, sid)
41+ request.state.principal = A.Principal("user", str(u["id"]), u["email"], u["role"])
42+ A.audit(request, "auth.login", u["email"], mfa_required=not mfa_passed)
43+ return {"ok": True, "mfa_required": not mfa_passed, "csrf_token": csrf, "email": u["email"], "role": u["role"]}
44+
45+
46+@router.post("/mfa")
47+def mfa(body: CodeIn, request: Request, p: A.Principal = Depends(A.require_user_session)):
48+ if p.mfa_passed:
49+ return {"ok": True, "csrf_token": p.csrf_token}
50+ if request.headers.get("x-csrf-token") != p.csrf_token:
51+ raise ApiError(403, "csrf", "Missing or invalid CSRF token")
52+ if not A.totp_verify(p.id, body.code):
53+ A.audit(request, "auth.mfa", p.email, outcome="denied")
54+ raise ApiError(401, "invalid_code", "Invalid one-time code")
55+ A.mark_mfa_passed(p.session_id)
56+ A.audit(request, "auth.mfa", p.email)
57+ return {"ok": True, "csrf_token": p.csrf_token}
58+
59+
60+@router.post("/logout")
61+def logout(request: Request, response: Response, p: A.Principal = Depends(A.require_user_session)):
62+ A.revoke_session(p.session_id)
63+ response.delete_cookie(A.SESSION_COOKIE, path="/")
64+ A.audit(request, "auth.logout", p.email)
65+ return {"ok": True}
66+
67+
68+@router.get("/me")
69+def me(request: Request, p: A.Principal = Depends(A.get_principal)):
70+ if p.kind == "anonymous":
71+ raise ApiError(401, "unauthenticated", "Not signed in")
72+ totp_enabled = None
73+ if p.kind == "user":
74+ with connection() as conn:
75+ u = conn.execute("SELECT totp_enabled FROM users WHERE id=%s", (p.id,)).fetchone()
76+ totp_enabled = bool(u and u["totp_enabled"])
77+ return {"kind": p.kind, "email": p.email, "role": p.role, "scopes": sorted(p.scopes), "mfa_passed": p.mfa_passed,
78+ "totp_enabled": totp_enabled, "csrf_token": p.csrf_token}
79+
80+
81+@router.post("/totp/setup")
82+def totp_setup(request: Request, p: A.Principal = Depends(A.require("admin"))):
83+ out = A.totp_setup(p.id, p.email)
84+ A.audit(request, "auth.totp_setup", p.email)
85+ return {"otpauth_uri": out["otpauth_uri"], "qr_png_data_url": out["qr_png_data_url"], "secret": out["secret"]}
86+
87+
88+@router.post("/totp/verify")
89+def totp_verify(body: CodeIn, request: Request, p: A.Principal = Depends(A.require("admin"))):
90+ if not A.totp_verify(p.id, body.code, enable=True):
91+ raise ApiError(400, "invalid_code", "Code did not match; try again")
92+ A.audit(request, "auth.totp_enabled", p.email)
93+ return {"ok": True, "totp_enabled": True}
added services/api/src/findata_api/routers/catalog.py +60 −0
@@ -0,0 +1,60 @@
1+from __future__ import annotations
2+
3+from fastapi import APIRouter, Depends
4+
5+from findata_api import auth as A
6+from findata_core.db import connection
7+from findata_api.errors import ApiError
8+
9+router = APIRouter(prefix="/catalog", tags=["catalog"])
10+
11+LICENSE_TEXT = {
12+ "frd-private": "FirstRate Data license: private use by the subscriber only. Raw bars/tables may not be redistributed to third parties "
13+ "(no public API, CSV or tables). Derived results and charts may be published with attribution to FirstRate Data.",
14+ "public-domain": "U.S. government work (SEC EDGAR): public domain; SEC fair-access policy applies to retrieval.",
15+ "pending": "License not yet determined.",
16+}
17+
18+
19+@router.get("/datasets")
20+def datasets(p: A.Principal = Depends(A.require("read:market"))):
21+ with connection() as conn:
22+ rows = conn.execute(
23+ "SELECT d.*, s.name source_name, s.vendor, s.product, v.snapshot_id, v.file_count, v.row_count, v.bytes, v.created_at version_created_at, "
24+ "(SELECT min(session_date) FROM coverage c WHERE c.dataset_id=d.id) first_session, (SELECT max(session_date) FROM coverage c WHERE c.dataset_id=d.id) last_session, "
25+ "(SELECT count(DISTINCT symbol) FROM coverage c WHERE c.dataset_id=d.id) symbols "
26+ "FROM datasets d JOIN sources s ON s.id=d.source_id LEFT JOIN dataset_versions v ON v.dataset_id=d.id AND v.version=d.current_version ORDER BY d.kind, d.id").fetchall()
27+ out = []
28+ for r in rows:
29+ r = dict(r)
30+ r["license_text"] = LICENSE_TEXT.get(r["license_class"], "")
31+ out.append(r)
32+ return {"data": out, "meta": {"count": len(out)}}
33+
34+
35+@router.get("/datasets/{dataset_id}")
36+def dataset(dataset_id: str, p: A.Principal = Depends(A.require("read:market"))):
37+ with connection() as conn:
38+ d = conn.execute("SELECT d.*, s.name source_name, s.vendor, s.license_class src_license FROM datasets d JOIN sources s ON s.id=d.source_id WHERE d.id=%s", (dataset_id,)).fetchone()
39+ if not d:
40+ raise ApiError(404, "not_found", "unknown dataset")
41+ versions = conn.execute("SELECT version, snapshot_id, file_count, row_count, bytes, created_at, note FROM dataset_versions WHERE dataset_id=%s ORDER BY version DESC LIMIT 50", (dataset_id,)).fetchall()
42+ files = conn.execute("SELECT layout, count(*) n, sum(row_count) rows, sum(bytes) bytes, min(date_min) d0, max(date_max) d1 FROM lake_files WHERE dataset_id=%s AND version_removed IS NULL GROUP BY layout", (dataset_id,)).fetchall()
43+ ck = conn.execute("SELECT key, value, updated_at FROM checkpoints WHERE scope=%s", (f"dataset:{dataset_id}",)).fetchall()
44+ d = dict(d)
45+ d["license_text"] = LICENSE_TEXT.get(d["license_class"], "")
46+ return {"data": d, "versions": versions, "files": files, "checkpoints": ck}
47+
48+
49+@router.get("/sources")
50+def sources(p: A.Principal = Depends(A.require("read:market"))):
51+ with connection() as conn:
52+ rows = conn.execute("SELECT * FROM sources ORDER BY id").fetchall()
53+ return {"data": rows}
54+
55+
56+@router.get("/schema/bars")
57+def schema_bars(p: A.Principal = Depends(A.require("read:market"))):
58+ from findata_schema import BARS_SCHEMA, BARS_SCHEMA_VERSION
59+ return {"schema_version": BARS_SCHEMA_VERSION, "timestamp_convention": "bar_open", "timezone_storage": "UTC",
60+ "fields": [{"name": f.name, "type": str(f.type), "nullable": f.nullable} for f in BARS_SCHEMA]}
added services/api/src/findata_api/routers/coverage.py +48 −0
@@ -0,0 +1,48 @@
1+from __future__ import annotations
2+
3+from datetime import date, timedelta
4+
5+from fastapi import APIRouter, Depends, Query
6+
7+from findata_api import auth as A
8+from findata_core.db import connection
9+
10+router = APIRouter(prefix="/coverage", tags=["coverage"])
11+
12+
13+@router.get("")
14+def coverage(dataset_id: str = "market_bars.stock.1m.raw", symbol: str | None = None, start: date | None = None, end: date | None = None,
15+ limit: int = Query(500, le=5000), p: A.Principal = Depends(A.require("read:market"))):
16+ with connection() as conn:
17+ rows = conn.execute("SELECT symbol, session_date, bar_count, expected_bars, first_ts, last_ts, flags FROM coverage WHERE dataset_id=%s "
18+ "AND (%s::text IS NULL OR symbol=%s) AND (%s::date IS NULL OR session_date>=%s) AND (%s::date IS NULL OR session_date<=%s) "
19+ "ORDER BY session_date DESC, symbol LIMIT %s", (dataset_id, symbol, symbol, start, start, end, end, limit)).fetchall()
20+ return {"data": rows, "meta": {"dataset_id": dataset_id}}
21+
22+
23+@router.get("/heatmap")
24+def heatmap(dataset_id: str = "market_bars.stock.1m.raw", start: date | None = None, end: date | None = None,
25+ p: A.Principal = Depends(A.require("read:market"))):
26+ """Per session: symbols with data, total bars, mean regular-hours completeness (bars / expected, capped at 1)."""
27+ end = end or date.today()
28+ start = start or end - timedelta(days=120)
29+ with connection() as conn:
30+ rows = conn.execute(
31+ "SELECT session_date, count(*) symbols, sum(bar_count) bars, "
32+ "avg(LEAST(1.0, bar_count::float / NULLIF(expected_bars,0))) FILTER (WHERE expected_bars > 0) completeness, "
33+ "count(*) FILTER (WHERE 'non_trading_day' = ANY(flags)) non_trading_rows "
34+ "FROM coverage WHERE dataset_id=%s AND session_date BETWEEN %s AND %s GROUP BY 1 ORDER BY 1", (dataset_id, start, end)).fetchall()
35+ syms = conn.execute("SELECT symbol, count(*) sessions, sum(bar_count) bars, avg(LEAST(1.0, bar_count::float / NULLIF(expected_bars,0))) FILTER (WHERE expected_bars>0) completeness "
36+ "FROM coverage WHERE dataset_id=%s AND session_date BETWEEN %s AND %s GROUP BY 1 ORDER BY bars DESC LIMIT 50", (dataset_id, start, end)).fetchall()
37+ return {"sessions": rows, "top_symbols": syms, "meta": {"dataset_id": dataset_id, "start": str(start), "end": str(end)}}
38+
39+
40+@router.get("/symbols")
41+def symbols(dataset_id: str = "market_bars.stock.1m.raw", min_sessions: int = 1, limit: int = Query(200, le=5000), cursor: str | None = None,
42+ p: A.Principal = Depends(A.require("read:market"))):
43+ with connection() as conn:
44+ rows = conn.execute("SELECT symbol, count(*) sessions, min(session_date) d0, max(session_date) d1, sum(bar_count) bars FROM coverage WHERE dataset_id=%s "
45+ "AND (%s::text IS NULL OR symbol > %s) GROUP BY symbol HAVING count(*) >= %s ORDER BY symbol LIMIT %s",
46+ (dataset_id, cursor, cursor, min_sessions, limit + 1)).fetchall()
47+ nxt = rows[limit]["symbol"] if len(rows) > limit else None
48+ return {"data": rows[:limit], "next_cursor": nxt}
added services/api/src/findata_api/routers/exports.py +124 −0
@@ -0,0 +1,124 @@
1+"""Async exports: bounded request validated synchronously, executed in a background thread, Parquet/CSV file served
2+from lake/exports with expiration. Dataset snapshot (version) is pinned at submission time."""
3+from __future__ import annotations
4+
5+import json
6+import threading
7+import traceback
8+from concurrent.futures import ThreadPoolExecutor
9+from datetime import UTC, date, datetime, timedelta
10+from typing import Literal
11+
12+import pyarrow.parquet as pq
13+from fastapi import APIRouter, Depends, Request
14+from fastapi.responses import FileResponse
15+from pydantic import BaseModel, Field
16+
17+from findata_api import auth as A
18+from findata_api.errors import ApiError
19+from findata_api.ratelimit import record_usage
20+from findata_core.config import get_settings
21+from findata_core.db import connection, transaction
22+from findata_ingest.lake import Lake
23+from findata_ingest.reader import apply_adjustment, read_bars
24+
25+router = APIRouter(prefix="/exports", tags=["exports"])
26+_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="export")
27+
28+
29+class ExportIn(BaseModel):
30+ dataset_id: str = "market_bars.stock.1m.raw"
31+ symbols: list[str] = Field(..., min_length=1, max_length=500)
32+ start: date
33+ end: date
34+ adjustment: Literal["raw", "split", "split_div"] = "raw"
35+ format: Literal["parquet", "csv"] = "parquet"
36+
37+
38+def _run(export_id: str, req: dict, principal: str) -> None:
39+ lake = Lake()
40+ try:
41+ with transaction() as conn:
42+ conn.execute("UPDATE exports SET status='running' WHERE id=%s", (export_id,))
43+ syms = [s.upper() for s in req["symbols"]]
44+ table, meta = read_bars(req["dataset_id"], syms, date.fromisoformat(req["start"]), date.fromisoformat(req["end"]), version=req.get("version"))
45+ if req["adjustment"] != "raw":
46+ table, _ = apply_adjustment(table, req["adjustment"])
47+ ext = "parquet" if req["format"] == "parquet" else "csv"
48+ rel = f"exports/{export_id}.{ext}"
49+ out = lake.abs(rel)
50+ if ext == "parquet":
51+ pq.write_table(table, out, compression="zstd")
52+ else:
53+ import pyarrow.csv as pcsv
54+ pcsv.write_csv(table.drop_columns(["quality_flags"]), out)
55+ ttl = get_settings().export_ttl_hours
56+ with transaction() as conn:
57+ conn.execute("UPDATE exports SET status='ready', path=%s, bytes=%s, row_count=%s, finished_at=now(), expires_at=%s, snapshot=%s WHERE id=%s",
58+ (rel, out.stat().st_size, table.num_rows, datetime.now(UTC) + timedelta(hours=ttl),
59+ json.dumps({"dataset_id": req["dataset_id"], "version": meta.get("version"), "files_scanned": meta.get("files_scanned")}), export_id))
60+ record_usage(principal, rows=table.num_rows, bytes_exported=out.stat().st_size)
61+ except Exception as e: # noqa: BLE001
62+ with transaction() as conn:
63+ conn.execute("UPDATE exports SET status='failed', error=%s, finished_at=now() WHERE id=%s", (f"{e}\n{traceback.format_exc()[-800:]}"[:2000], export_id))
64+
65+
66+@router.post("", status_code=202)
67+def create(body: ExportIn, request: Request, p: A.Principal = Depends(A.require("export"))):
68+ if body.end < body.start:
69+ raise ApiError(400, "bad_range", "end must be >= start")
70+ span = (body.end - body.start).days
71+ if span > 366 * 12:
72+ raise ApiError(413, "range_too_large", "exports are limited to 12 years per request")
73+ with connection() as conn:
74+ d = conn.execute("SELECT id, status, current_version FROM datasets WHERE id=%s", (body.dataset_id,)).fetchone()
75+ if not d:
76+ raise ApiError(404, "not_found", "unknown dataset")
77+ if d["status"] != "active":
78+ raise ApiError(409, "dataset_not_active", f"dataset status is {d['status']}")
79+ n = conn.execute("SELECT count(*) FROM exports WHERE principal=%s AND status IN ('queued','running')", (p.label,)).fetchone()["count"]
80+ if n >= 3:
81+ raise ApiError(429, "too_many_exports", "at most 3 concurrent exports per principal")
82+ req = body.model_dump(mode="json")
83+ req["version"] = d["current_version"]
84+ with transaction() as conn:
85+ r = conn.execute("INSERT INTO exports(principal, request, format) VALUES (%s,%s,%s) RETURNING id", (p.label, json.dumps(req), body.format)).fetchone()
86+ eid = str(r["id"])
87+ A.audit(request, "export.create", eid, dataset=body.dataset_id, symbols=len(body.symbols), start=str(body.start), end=str(body.end))
88+ _pool.submit(_run, eid, req, p.label)
89+ return {"id": eid, "status": "queued", "poll": f"/api/v1/exports/{eid}"}
90+
91+
92+@router.get("")
93+def list_exports(p: A.Principal = Depends(A.require("export"))):
94+ with connection() as conn:
95+ rows = conn.execute("SELECT id, status, request, format, bytes, row_count, created_at, finished_at, expires_at, error, snapshot FROM exports WHERE principal=%s OR %s ORDER BY created_at DESC LIMIT 100",
96+ (p.label, p.has("admin"))).fetchall()
97+ return {"data": rows}
98+
99+
100+@router.get("/{export_id}")
101+def get(export_id: str, p: A.Principal = Depends(A.require("export"))):
102+ with connection() as conn:
103+ r = conn.execute("SELECT * FROM exports WHERE id=%s", (export_id,)).fetchone()
104+ if not r or (r["principal"] != p.label and not p.has("admin")):
105+ raise ApiError(404, "not_found", "unknown export")
106+ r = dict(r)
107+ if r["status"] == "ready":
108+ r["download"] = f"/api/v1/exports/{export_id}/download"
109+ return r
110+
111+
112+@router.get("/{export_id}/download")
113+def download(export_id: str, request: Request, p: A.Principal = Depends(A.require("export"))):
114+ with connection() as conn:
115+ r = conn.execute("SELECT * FROM exports WHERE id=%s", (export_id,)).fetchone()
116+ if not r or (r["principal"] != p.label and not p.has("admin")):
117+ raise ApiError(404, "not_found", "unknown export")
118+ if r["status"] != "ready":
119+ raise ApiError(409, "not_ready", f"export status is {r['status']}")
120+ if r["expires_at"] and r["expires_at"] < datetime.now(UTC):
121+ raise ApiError(410, "expired", "export expired")
122+ path = Lake().abs(r["path"])
123+ A.audit(request, "export.download", export_id, bytes=r["bytes"])
124+ return FileResponse(path, filename=f"findata-{export_id}.{r['format']}", media_type="application/octet-stream")
added services/api/src/findata_api/routers/fundamentals.py +68 −0
@@ -0,0 +1,68 @@
1+from __future__ import annotations
2+
3+from datetime import UTC, datetime
4+from typing import Any
5+
6+from fastapi import APIRouter, Depends, Query
7+
8+from findata_api import auth as A
9+from findata_api.errors import ApiError
10+from findata_core.db import connection
11+
12+router = APIRouter(prefix="/fundamentals", tags=["fundamentals"])
13+
14+
15+def _entity(e: str) -> str:
16+ e = e.strip()
17+ if ":" in e:
18+ return e
19+ return f"frd:{e.upper()}"
20+
21+
22+@router.get("/{entity_id}")
23+def facts(entity_id: str, as_of: datetime | None = Query(None, description="PIT instant (UTC). Default: now."),
24+ metrics: str | None = Query(None, description="comma-separated metric names"),
25+ period_type: str | None = Query(None, pattern="^(q|a)$"),
26+ include_superseded: bool = False, limit: int = Query(2000, le=20000),
27+ p: A.Principal = Depends(A.require("read:fundamentals"))):
28+ """Point-in-time facts: a fact is visible iff available_at <= as_of; among revisions, revision 1 (first observed
29+ value, proxy for 'as originally filed') is used unless a later revision was *observed* before as_of."""
30+ ent = _entity(entity_id)
31+ asof = as_of or datetime.now(UTC)
32+ if asof.tzinfo is None:
33+ asof = asof.replace(tzinfo=UTC)
34+ mlist = [m.strip() for m in metrics.split(",")] if metrics else None
35+ sql = """
36+ WITH visible AS (
37+ SELECT *, row_number() OVER (PARTITION BY metric, fiscal_period_end, fiscal_period_type, source_id
38+ ORDER BY revision_id DESC) rn
39+ FROM fundamentals_facts
40+ WHERE entity_id=%(ent)s AND available_at <= %(asof)s
41+ AND (revision_id = 1 OR observed_at <= %(asof)s)
42+ AND (%(pt)s::text IS NULL OR fiscal_period_type=%(pt)s)
43+ AND (%(ms)s::text[] IS NULL OR metric = ANY(%(ms)s))
44+ )
45+ SELECT entity_id, security_id, metric, value, unit, currency, fiscal_period_start, fiscal_period_end, fiscal_period_type,
46+ fiscal_year, fiscal_period, filing_date, published_at, available_at, observed_at, source_document, source_id,
47+ revision_id, restatement_flag, superseded_at
48+ FROM visible WHERE rn = 1 ORDER BY fiscal_period_end DESC, metric LIMIT %(lim)s"""
49+ with connection() as conn:
50+ rows = conn.execute(sql, {"ent": ent, "asof": asof, "pt": period_type, "ms": mlist, "lim": limit}).fetchall()
51+ e = conn.execute("SELECT * FROM entities WHERE entity_id=%s", (ent,)).fetchone()
52+ hidden = conn.execute("SELECT count(*) FROM fundamentals_facts WHERE entity_id=%s AND available_at > %s", (ent, asof)).fetchone()["count"]
53+ if not e and not rows:
54+ raise ApiError(404, "not_found", f"no fundamentals for {ent}; ingest with `findata ingest fundamentals {ent.split(':')[-1]}`")
55+ return {"data": rows, "entity": e, "meta": {"as_of": asof.isoformat(), "rows": len(rows), "facts_hidden_by_as_of": hidden,
56+ "pit_policy": "available_at = filing_date + 1 day 00:00 America/New_York; revisions observed after as_of are not shown",
57+ "license": "frd-private"}}
58+
59+
60+@router.get("/{entity_id}/periods")
61+def periods(entity_id: str, p: A.Principal = Depends(A.require("read:fundamentals"))):
62+ ent = _entity(entity_id)
63+ with connection() as conn:
64+ rows = conn.execute("SELECT fiscal_period_end, fiscal_period_type, fiscal_year, fiscal_period, filing_date, min(available_at) available_at, "
65+ "count(*) metrics, max(revision_id) max_revision, bool_or(restatement_flag) restated FROM fundamentals_facts WHERE entity_id=%s "
66+ "GROUP BY 1,2,3,4,5 ORDER BY 1 DESC", (ent,)).fetchall()
67+ earn = conn.execute("SELECT event_date, eps_estimate, eps_reported, release_time FROM earnings_events WHERE entity_id=%s ORDER BY event_date DESC LIMIT 60", (ent,)).fetchall()
68+ return {"periods": rows, "earnings": earn}
added services/api/src/findata_api/routers/health.py +35 −0
@@ -0,0 +1,35 @@
1+from __future__ import annotations
2+
3+from fastapi import APIRouter, Depends, Request, Response
4+
5+from findata_api import auth as A
6+from findata_api.errors import ApiError
7+from findata_api.health import evaluate
8+from findata_api.metrics import render
9+from findata_core.db import connection
10+
11+router = APIRouter(tags=["health"])
12+
13+
14+@router.get("/health")
15+def health(p: A.Principal = Depends(A.get_principal)):
16+ """Public liveness (no details) — authenticated callers get the full report."""
17+ h = evaluate()
18+ if p.kind == "anonymous":
19+ return {"status": h["status"], "service": "findata-os"}
20+ return h
21+
22+
23+@router.get("/health/summary")
24+def summary(p: A.Principal = Depends(A.require("read:market"))):
25+ with connection() as conn:
26+ ds = conn.execute("SELECT count(*) FILTER (WHERE status='active') active, count(*) FILTER (WHERE status='planned') planned, "
27+ "count(*) FILTER (WHERE status='unsupported') unsupported, count(*) FILTER (WHERE status='pending_configuration') pending FROM datasets").fetchone()
28+ inst = conn.execute("SELECT count(*) n, count(*) FILTER (WHERE status='active') active FROM instruments").fetchone()
29+ cov = conn.execute("SELECT count(DISTINCT symbol) symbols, count(DISTINCT session_date) sessions, sum(bar_count) bars, max(session_date) last FROM coverage WHERE dataset_id='market_bars.stock.1m.raw'").fetchone()
30+ jobs = conn.execute("SELECT status, count(*) n FROM ingest_jobs GROUP BY status").fetchall()
31+ recent = conn.execute("SELECT id, kind, dataset_id, status, started_at, finished_at, left(last_error,120) last_error, result FROM ingest_jobs ORDER BY created_at DESC LIMIT 8").fetchall()
32+ fund = conn.execute("SELECT count(DISTINCT entity_id) entities, count(*) facts FROM fundamentals_facts WHERE superseded_at IS NULL").fetchone()
33+ an = conn.execute("SELECT severity, count(*) n FROM quality_anomalies WHERE created_at > now() - interval '7 days' GROUP BY 1").fetchall()
34+ return {"datasets": ds, "instruments": inst, "bars_1m": cov, "jobs": {r["status"]: r["n"] for r in jobs}, "recent_jobs": recent,
35+ "fundamentals": fund, "anomalies_7d": {r["severity"]: r["n"] for r in an}, "health": evaluate()}
added services/api/src/findata_api/routers/instruments.py +47 −0
@@ -0,0 +1,47 @@
1+from __future__ import annotations
2+
3+from fastapi import APIRouter, Depends, Query
4+
5+from findata_api import auth as A
6+from findata_core.db import connection
7+
8+router = APIRouter(prefix="/instruments", tags=["instruments"])
9+
10+
11+@router.get("")
12+def search(query: str = Query("", max_length=64), asset_class: str | None = None, status: str | None = None,
13+ limit: int = Query(50, le=500), cursor: str | None = None, p: A.Principal = Depends(A.require("read:market"))):
14+ q = query.strip()
15+ sql = "SELECT instrument_id, symbol, name, asset_class, status, first_date, last_date, currency FROM instruments WHERE true"
16+ params: list = []
17+ if q:
18+ sql += " AND (upper(symbol) LIKE %s OR lower(name) LIKE %s)"
19+ params += [q.upper() + "%", "%" + q.lower() + "%"]
20+ if asset_class:
21+ sql += " AND asset_class=%s"
22+ params.append(asset_class)
23+ if status:
24+ sql += " AND status=%s"
25+ params.append(status)
26+ if cursor:
27+ sql += " AND symbol > %s"
28+ params.append(cursor)
29+ sql += " ORDER BY (upper(symbol) = %s) DESC, symbol LIMIT %s"
30+ params += [q.upper(), limit + 1]
31+ with connection() as conn:
32+ rows = conn.execute(sql, params).fetchall()
33+ total = conn.execute("SELECT count(*) n, count(*) FILTER (WHERE status='active') active FROM instruments").fetchone()
34+ nxt = rows[limit]["symbol"] if len(rows) > limit else None
35+ return {"data": rows[:limit], "meta": {"total_instruments": total["n"], "active": total["active"]}, "next_cursor": nxt if not q else None}
36+
37+
38+@router.get("/{symbol}")
39+def detail(symbol: str, p: A.Principal = Depends(A.require("read:market"))):
40+ sym = symbol.upper()
41+ with connection() as conn:
42+ inst = conn.execute("SELECT * FROM instruments WHERE symbol=%s ORDER BY asset_class LIMIT 1", (sym,)).fetchone()
43+ aliases = conn.execute("SELECT a.* FROM instrument_aliases a JOIN instruments i ON i.instrument_id=a.instrument_id WHERE a.symbol=%s OR i.symbol=%s", (sym, sym)).fetchall()
44+ actions = conn.execute("SELECT action_type, ex_date, value FROM corporate_actions WHERE instrument_id LIKE %s ORDER BY ex_date DESC LIMIT 200", (f"firstrate:%:{sym}",)).fetchall()
45+ cov = conn.execute("SELECT dataset_id, count(*) sessions, min(session_date) d0, max(session_date) d1, sum(bar_count) bars FROM coverage WHERE symbol=%s GROUP BY dataset_id", (sym,)).fetchall()
46+ fund = conn.execute("SELECT count(*) facts, max(fiscal_period_end) last_period, max(filing_date) last_filing FROM fundamentals_facts WHERE entity_id=%s AND superseded_at IS NULL", (f"frd:{sym}",)).fetchone()
47+ return {"instrument": inst, "aliases": aliases, "corporate_actions": actions, "coverage": cov, "fundamentals": fund}
added services/api/src/findata_api/routers/market.py +104 −0
@@ -0,0 +1,104 @@
1+from __future__ import annotations
2+
3+import base64
4+import io
5+from datetime import date, datetime
6+from typing import Literal
7+
8+import pyarrow as pa
9+import pyarrow.ipc as ipc
10+from fastapi import APIRouter, Depends, Query, Request, Response
11+from fastapi.responses import StreamingResponse
12+
13+from findata_api import auth as A
14+from findata_api import metrics as M
15+from findata_api.errors import ApiError
16+from findata_api.ratelimit import record_usage
17+from findata_core.config import get_settings
18+from findata_core.db import connection
19+from findata_ingest.reader import apply_adjustment, read_bars
20+
21+router = APIRouter(prefix="/market", tags=["market"])
22+INTERVAL_DS = {"1m": "market_bars.{a}.1m.raw", "5m": "market_bars.{a}.5m.raw", "1d": "market_bars.{a}.1d.raw"}
23+MAX_SPAN_DAYS = {"1m": 31, "5m": 186, "1d": 3660}
24+PAGE_MAX = 10_000
25+
26+
27+def _dataset_for(symbol: str, interval: str) -> tuple[str, str]:
28+ with connection() as conn:
29+ inst = conn.execute("SELECT asset_class FROM instruments WHERE symbol=%s ORDER BY (asset_class='stock') DESC LIMIT 1", (symbol,)).fetchone()
30+ asset = inst["asset_class"] if inst else "stock"
31+ if interval not in INTERVAL_DS:
32+ raise ApiError(400, "bad_interval", "interval must be one of 1m, 5m, 1d")
33+ return INTERVAL_DS[interval].format(a=asset), asset
34+
35+
36+@router.get("/bars")
37+def bars(request: Request, response: Response,
38+ symbol: str = Query(..., min_length=1, max_length=20), interval: Literal["1m", "5m", "1d"] = "1m",
39+ start: date = Query(...), end: date = Query(...), adjustment: Literal["raw", "split", "split_div"] = "raw",
40+ limit: int = Query(5000, ge=1, le=PAGE_MAX), cursor: str | None = None,
41+ format: Literal["json", "arrow", "csv"] = "json", version: int | None = None,
42+ p: A.Principal = Depends(A.require("read:market"))):
43+ sym = symbol.upper().strip()
44+ if end < start:
45+ raise ApiError(400, "bad_range", "end must be >= start")
46+ if (end - start).days > MAX_SPAN_DAYS[interval]:
47+ raise ApiError(413, "range_too_large", f"{interval} requests are limited to {MAX_SPAN_DAYS[interval]} days per call; "
48+ "use POST /api/v1/exports for larger ranges (async Parquet).", max_days=MAX_SPAN_DAYS[interval])
49+ dataset_id, asset = _dataset_for(sym, interval)
50+ table, meta = read_bars(dataset_id, [sym], start, end, version=version)
51+ notes: list[str] = []
52+ if adjustment != "raw":
53+ table, notes = apply_adjustment(table, adjustment)
54+ # cursor = base64(last timestamp_utc isoformat)
55+ if cursor:
56+ try:
57+ after = datetime.fromisoformat(base64.urlsafe_b64decode(cursor.encode()).decode())
58+ except Exception: # noqa: BLE001
59+ raise ApiError(400, "bad_cursor", "invalid cursor") from None
60+ import pyarrow.compute as pc
61+ table = table.filter(pc.greater(table.column("timestamp_utc"), pa.scalar(after, pa.timestamp("us", tz="UTC"))))
62+ total = table.num_rows
63+ page = table.slice(0, limit)
64+ next_cursor = None
65+ if total > limit:
66+ last = page.column("timestamp_utc")[-1].as_py()
67+ next_cursor = base64.urlsafe_b64encode(last.isoformat().encode()).decode()
68+ response.headers["X-Dataset-Id"] = dataset_id
69+ response.headers["X-Dataset-Version"] = str(meta.get("version"))
70+ M.ROWS.labels("bars").inc(page.num_rows)
71+ record_usage(p.label, rows=page.num_rows)
72+ if format == "arrow":
73+ sink = io.BytesIO()
74+ with ipc.new_stream(sink, page.schema) as w:
75+ w.write_table(page)
76+ headers = {"X-Dataset-Id": dataset_id, "X-Dataset-Version": str(meta.get("version")), "X-Next-Cursor": next_cursor or ""}
77+ return Response(sink.getvalue(), media_type="application/vnd.apache.arrow.stream", headers=headers)
78+ if format == "csv":
79+ import pyarrow.csv as pcsv
80+ sink = io.BytesIO()
81+ pcsv.write_csv(page.drop_columns(["quality_flags"]) if "quality_flags" in page.column_names else page, sink)
82+ return StreamingResponse(iter([sink.getvalue()]), media_type="text/csv",
83+ headers={"Content-Disposition": f'attachment; filename="{sym}_{interval}_{start}_{end}.csv"', "X-Next-Cursor": next_cursor or ""})
84+ rows = page.to_pylist()
85+ for r in rows:
86+ r["timestamp_utc"] = r["timestamp_utc"].isoformat().replace("+00:00", "Z")
87+ r["session_date"] = r["session_date"].isoformat()
88+ r["ingested_at"] = r["ingested_at"].isoformat().replace("+00:00", "Z")
89+ return {"data": rows, "meta": {**meta, "symbol": sym, "interval": interval, "adjustment": adjustment, "start": str(start), "end": str(end),
90+ "rows": len(rows), "rows_matching": total, "adjustment_notes": notes, "timestamp_convention": "bar_open",
91+ "license": "frd-private: no redistribution"}, "next_cursor": next_cursor}
92+
93+
94+@router.get("/sessions")
95+def sessions(symbol: str, interval: Literal["1m", "5m"] = "1m", start: date | None = None, end: date | None = None,
96+ p: A.Principal = Depends(A.require("read:market"))):
97+ """Per-session coverage for one symbol (bar counts vs expected regular-hours bars)."""
98+ sym = symbol.upper()
99+ dataset_id, _ = _dataset_for(sym, interval)
100+ with connection() as conn:
101+ rows = conn.execute("SELECT session_date, bar_count, expected_bars, first_ts, last_ts, flags FROM coverage WHERE dataset_id=%s AND symbol=%s "
102+ "AND (%s::date IS NULL OR session_date>=%s) AND (%s::date IS NULL OR session_date<=%s) ORDER BY session_date DESC LIMIT 2000",
103+ (dataset_id, sym, start, start, end, end)).fetchall()
104+ return {"data": rows, "meta": {"dataset_id": dataset_id, "symbol": sym}}
added services/api/src/findata_api/routers/quality.py +31 −0
@@ -0,0 +1,31 @@
1+from __future__ import annotations
2+
3+from fastapi import APIRouter, Depends, Query
4+
5+from findata_api import auth as A
6+from findata_core.db import connection
7+
8+router = APIRouter(prefix="/quality", tags=["quality"])
9+
10+
11+@router.get("")
12+def quality(dataset_id: str = "market_bars.stock.1m.raw", p: A.Principal = Depends(A.require("read:market"))):
13+ with connection() as conn:
14+ report = conn.execute("SELECT * FROM quality_reports WHERE dataset_id=%s ORDER BY created_at DESC LIMIT 1", (dataset_id,)).fetchone()
15+ by_code = conn.execute("SELECT code, severity, count(*) n, sum((detail->>'rows')::int) rows FROM quality_anomalies WHERE dataset_id=%s AND created_at > now() - interval '30 days' GROUP BY 1,2 ORDER BY n DESC", (dataset_id,)).fetchall()
16+ reports = conn.execute("SELECT id, created_at, rows_checked, anomalies, summary FROM quality_reports WHERE dataset_id=%s ORDER BY created_at DESC LIMIT 30", (dataset_id,)).fetchall()
17+ return {"latest": report, "anomalies_by_code_30d": by_code, "reports": reports,
18+ "codes": {"ohlc_inconsistent": "high < max(open,close) or low > min(open,close)", "nonpositive_price": "price <= 0",
19+ "negative_volume": "volume < 0", "zero_volume": "volume == 0 (vendor normally omits these)", "fractional_volume": "non-integral volume",
20+ "off_interval": "timestamp not on the interval grid", "duplicate": "duplicate (symbol, timestamp)",
21+ "outside_session": "outside 04:00–20:00 ET", "non_trading_day": "bars on a non-session date", "price_jump": ">25 % move between consecutive bars"}}
22+
23+
24+@router.get("/anomalies")
25+def anomalies(dataset_id: str = "market_bars.stock.1m.raw", code: str | None = None, severity: str | None = None, symbol: str | None = None,
26+ limit: int = Query(200, le=5000), p: A.Principal = Depends(A.require("read:market"))):
27+ with connection() as conn:
28+ rows = conn.execute("SELECT id, symbol, session_date, code, severity, detail, created_at FROM quality_anomalies WHERE dataset_id=%s "
29+ "AND (%s::text IS NULL OR code=%s) AND (%s::text IS NULL OR severity=%s) AND (%s::text IS NULL OR symbol=%s) ORDER BY created_at DESC, id DESC LIMIT %s",
30+ (dataset_id, code, code, severity, severity, symbol, symbol, limit)).fetchall()
31+ return {"data": rows}
added services/api/src/findata_api/routers/query.py +54 −0
@@ -0,0 +1,54 @@
1+from __future__ import annotations
2+
3+import time
4+
5+from fastapi import APIRouter, Depends, Request
6+from pydantic import BaseModel, Field
7+
8+from findata_api import auth as A
9+from findata_api import metrics as M
10+from findata_api.errors import ApiError
11+from findata_api.ratelimit import record_usage
12+from findata_core.config import get_settings
13+from findata_ingest.reader import dataset_views, run_sql
14+
15+router = APIRouter(prefix="/query", tags=["query"])
16+
17+
18+class QueryIn(BaseModel):
19+ sql: str = Field(..., max_length=20000)
20+ max_rows: int = Field(1000, ge=1, le=100_000)
21+
22+
23+@router.post("")
24+def query(body: QueryIn, request: Request, p: A.Principal = Depends(A.require("query"))):
25+ s = get_settings()
26+ views = dataset_views()
27+ t0 = time.monotonic()
28+ try:
29+ tbl, meta = run_sql(body.sql, min(body.max_rows, s.query_max_rows), s.query_timeout_seconds, views)
30+ except ValueError as e:
31+ raise ApiError(400, "query_rejected", str(e)) from None
32+ except TimeoutError as e:
33+ raise ApiError(408, "query_timeout", str(e)) from None
34+ except Exception as e: # noqa: BLE001
35+ raise ApiError(400, "query_error", str(e)[:500]) from None
36+ rows = tbl.to_pylist()
37+ for r in rows:
38+ for k, v in r.items():
39+ if hasattr(v, "isoformat"):
40+ r[k] = v.isoformat()
41+ M.ROWS.labels("query").inc(len(rows))
42+ record_usage(p.label, rows=len(rows))
43+ A.audit(request, "query.sql", None, rows=len(rows), ms=int((time.monotonic() - t0) * 1000), sql=body.sql[:500])
44+ return {"columns": [{"name": f.name, "type": str(f.type)} for f in tbl.schema], "rows": rows,
45+ "meta": {**meta, "rows": len(rows), "elapsed_ms": int((time.monotonic() - t0) * 1000),
46+ "views": sorted(views), "engine": "duckdb (read-only, sandboxed, 1 statement)"}}
47+
48+
49+@router.get("/views")
50+def views(p: A.Principal = Depends(A.require("query"))):
51+ v = dataset_views()
52+ return {"views": [{"name": k, "files": len(paths)} for k, paths in sorted(v.items())],
53+ "examples": ["SELECT symbol_as_reported, count(*) bars FROM market_bars_stock_1m_raw GROUP BY 1 ORDER BY 2 DESC LIMIT 20",
54+ "SELECT session_date, min(low) lo, max(high) hi, sum(volume) vol FROM market_bars_stock_1m_raw WHERE symbol_as_reported='AAPL' GROUP BY 1 ORDER BY 1"]}
added services/ingest/pyproject.toml +19 −0
@@ -0,0 +1,19 @@
1+[project]
2+name = "findata-ingest"
3+version = "0.1.0"
4+requires-python = ">=3.12"
5+dependencies = ["findata-core", "findata-schema", "findata-quality", "findata-market-firstrate", "findata-fundamentals",
6+ "pyarrow>=18.0", "polars>=1.12", "duckdb>=1.1", "typer>=0.12"]
7+[project.scripts]
8+findata = "findata_ingest.cli:app"
9+[tool.uv.sources]
10+findata-core = { workspace = true }
11+findata-schema = { workspace = true }
12+findata-quality = { workspace = true }
13+findata-market-firstrate = { workspace = true }
14+findata-fundamentals = { workspace = true }
15+[build-system]
16+requires = ["hatchling"]
17+build-backend = "hatchling.build"
18+[tool.hatch.build.targets.wheel]
19+packages = ["src/findata_ingest"]
added services/ingest/src/findata_ingest/__init__.py +1 −0
@@ -0,0 +1 @@
1+"""findata_ingest — lake writer (single-writer, atomic publication, versioned manifests), durable job queue, tasks."""
added services/ingest/src/findata_ingest/cli.py +138 −0
@@ -0,0 +1,138 @@
1+"""`findata` operator CLI."""
2+from __future__ import annotations
3+
4+import json
5+from pathlib import Path
6+from typing import Optional
7+
8+import typer
9+
10+app = typer.Typer(no_args_is_help=True, help="FinData OS operator CLI")
11+ingest_app = typer.Typer(no_args_is_help=True)
12+app.add_typer(ingest_app, name="ingest")
13+
14+
15+@app.command()
16+def migrate() -> None:
17+ """Apply database migrations."""
18+ from findata_core.db import migrate as _m
19+ applied = _m()
20+ typer.echo(f"applied: {applied or 'nothing'}")
21+
22+
23+@app.command()
24+def discover(source: str = "all") -> None:
25+ """Sanitized live probe of vendor capabilities (no bulk download)."""
26+ out = {}
27+ if source in ("all", "firstrate"):
28+ from findata_market_firstrate import FirstRateMarketConnector
29+ out["firstrate"] = FirstRateMarketConnector().discover()
30+ if source in ("all", "firstrate_fundamentals"):
31+ from findata_fundamentals import FirstRateFundamentalsConnector
32+ out["firstrate_fundamentals"] = FirstRateFundamentalsConnector().discover()
33+ if source in ("all", "sec_edgar"):
34+ from findata_fundamentals import SecEdgarConnector
35+ out["sec_edgar"] = SecEdgarConnector().status()
36+ typer.echo(json.dumps(out, indent=2, default=str))
37+
38+
39+@app.command()
40+def worker(once: bool = False, no_scheduler: bool = False, kinds: Optional[str] = None) -> None:
41+ """Run the single ingest worker (+ scheduler)."""
42+ from findata_ingest.worker import main
43+ main(once=once, kinds=kinds.split(",") if kinds else None, with_scheduler=not no_scheduler)
44+
45+
46+@ingest_app.command("metadata")
47+def ingest_metadata(types: str = "stock,etf") -> None:
48+ from findata_ingest import queue
49+ typer.echo(queue.enqueue("metadata", None, {"types": types.split(",")}, priority=10, created_by="cli"))
50+
51+
52+@ingest_app.command("incremental")
53+def ingest_incremental(type_: str = typer.Option("stock", "--type"), period: str = "day", timeframe: str = "1min",
54+ adjustment: str = "UNADJUSTED", force: bool = False) -> None:
55+ from findata_ingest import queue
56+ from findata_ingest.tasks.common import DATASET_ASSET
57+ typer.echo(queue.enqueue("incremental", DATASET_ASSET[type_], {"type": type_, "period": period, "timeframe": timeframe,
58+ "adjustment": adjustment, "force": force}, priority=20, created_by="cli"))
59+
60+
61+@ingest_app.command("backfill")
62+def ingest_backfill(ticker_range: str, type_: str = typer.Option("stock", "--type"), timeframe: str = "1min",
63+ adjustment: str = "UNADJUSTED", symbols: Optional[str] = None, max_symbols: int = 0) -> None:
64+ """Bounded backfill of one ticker_range letter (full archive). Disk budget enforced by the lake."""
65+ from findata_ingest import queue
66+ from findata_ingest.tasks.common import DATASET_ASSET
67+ for letter in ticker_range.upper():
68+ jid = queue.enqueue("backfill", DATASET_ASSET[type_], {"type": type_, "ticker_range": letter, "timeframe": timeframe, "adjustment": adjustment,
69+ "symbols": symbols.split(",") if symbols else None, "max_symbols": max_symbols}, priority=35, created_by="cli",
70+ dedupe_key=f"backfill:{type_}:{letter}:{timeframe}:{adjustment}")
71+ typer.echo(f"{letter}: {jid or 'already queued'}")
72+
73+
74+@ingest_app.command("fundamentals")
75+def ingest_fundamentals(tickers: str) -> None:
76+ from findata_ingest import queue
77+ typer.echo(queue.enqueue("fundamentals", "fundamentals.financials", {"tickers": tickers.split(",")}, priority=30, created_by="cli"))
78+
79+
80+@ingest_app.command("derive")
81+def ingest_derive(dates: Optional[str] = None, source: str = "market_bars.stock.1m.raw") -> None:
82+ from findata_ingest import queue
83+ typer.echo(queue.enqueue("derive", source.replace(".1m.", ".5m."), {"source_dataset": source, "target_interval": "5m", "dates": dates.split(",") if dates else []}, created_by="cli"))
84+
85+
86+@ingest_app.command("compaction")
87+def ingest_compaction(dataset: str = "market_bars.stock.1m.raw", min_age_days: int = 35) -> None:
88+ from findata_ingest import queue
89+ typer.echo(queue.enqueue("compaction", dataset, {"min_age_days": min_age_days}, priority=60, created_by="cli"))
90+
91+
92+@ingest_app.command("jobs")
93+def ingest_jobs(limit: int = 20) -> None:
94+ from findata_core.db import fetch_all
95+ for r in fetch_all("SELECT id, kind, dataset_id, status, attempts, created_at, finished_at, left(last_error, 80) err FROM ingest_jobs ORDER BY created_at DESC LIMIT %s", (limit,)):
96+ typer.echo(f"{str(r['id'])[:8]} {r['kind']:<12} {str(r['dataset_id']):<28} {r['status']:<9} a={r['attempts']} {r['created_at']:%m-%d %H:%M} {r['err'] or ''}")
97+
98+
99+@app.command()
100+def backup(now: bool = True) -> None:
101+ from findata_ingest import queue
102+ typer.echo(queue.enqueue("backup", None, {}, priority=5, created_by="cli"))
103+
104+
105+@app.command("restore-drill")
106+def restore_drill(dest: Path = Path("/tmp/findata-restore-drill"), dataset: str = "market_bars.stock.1m.raw") -> None:
107+ from findata_core.config import get_settings
108+ from findata_ingest.tasks.backup import restore_drill as rd
109+ typer.echo(json.dumps(rd(get_settings().backup_target, dest, dataset), indent=2, default=str))
110+
111+
112+@app.command("create-owner")
113+def create_owner(email: str, password: Optional[str] = None) -> None:
114+ """Create (or reset the password of) the owner account; prints the TOTP provisioning URI."""
115+ from findata_api.auth import create_owner as _co
116+ typer.echo(json.dumps(_co(email, password), indent=2))
117+
118+
119+@app.command("create-key")
120+def create_key(email: str, name: str, scopes: str = "read:market,read:fundamentals,query,export") -> None:
121+ from findata_api.auth import create_api_key_for_email
122+ typer.echo(json.dumps(create_api_key_for_email(email, name, scopes.split(",")), indent=2))
123+
124+
125+@app.command()
126+def status() -> None:
127+ from findata_core.db import fetch_all, fetch_one
128+ from findata_ingest.lake import Lake
129+ lake = Lake()
130+ typer.echo(f"lake {lake.root} free {lake.free_gb():.1f} GB curated {lake.used_bytes('curated')/1e9:.2f} GB raw {lake.used_bytes('raw')/1e9:.2f} GB")
131+ for r in fetch_all("SELECT d.id, d.status, d.current_version, v.file_count, v.row_count, v.bytes FROM datasets d LEFT JOIN dataset_versions v ON v.dataset_id=d.id AND v.version=d.current_version ORDER BY d.id"):
132+ typer.echo(f" {r['id']:<36} {r['status']:<22} v{r['current_version']:<4} files={r['file_count'] or 0:<6} rows={r['row_count'] or 0:<12} {(r['bytes'] or 0)/1e6:.1f} MB")
133+ q = fetch_one("SELECT count(*) FILTER (WHERE status='queued') q, count(*) FILTER (WHERE status='running') r, count(*) FILTER (WHERE status='dead') d FROM ingest_jobs")
134+ typer.echo(f"jobs queued={q['q']} running={q['r']} dead={q['d']}")
135+
136+
137+if __name__ == "__main__":
138+ app()
added services/ingest/src/findata_ingest/lake.py +227 −0
@@ -0,0 +1,227 @@
1+"""Parquet lake: layout, atomic publication, versions and snapshots.
2+
3+Layout (relative to FINDATA_LAKE):
4+ raw/<source>/<dataset>/<YYYY>/<archive>.zip immutable vendor archives (+ sha256 in raw_archives)
5+ curated/<dataset>/delta/session_date=YYYY-MM-DD/part-<id>.parquet one file per session (all symbols, sorted)
6+ curated/<dataset>/base/symbol=<SYM>/year=YYYY/part-<id>.parquet compacted / backfilled history
7+ manifests/<dataset>/v<version>.json reproducible snapshot manifests
8+ _tmp/ staging (same filesystem → atomic rename)
9+ _retired/<dataset>/... files removed from the live set (GC after 7 days)
10+ exports/<export_id>.<ext>
11+
12+Invariant: at any dataset version, each (instrument_id, timestamp_utc) is in exactly one live file.
13+Only the ingest worker writes under curated/ (file lock `.writer.lock`); the API only reads.
14+"""
15+from __future__ import annotations
16+
17+import fcntl
18+import hashlib
19+import json
20+import os
21+import shutil
22+import uuid
23+from dataclasses import dataclass
24+from datetime import UTC, date, datetime
25+from pathlib import Path
26+from typing import Any
27+
28+import pyarrow as pa
29+import pyarrow.parquet as pq
30+
31+from findata_core.config import get_settings
32+from findata_core.db import transaction
33+
34+ROW_GROUP_INTRADAY = 65_536
35+ROW_GROUP_DAILY = 1_000_000
36+
37+
38+@dataclass
39+class WrittenFile:
40+ rel_path: str
41+ layout: str
42+ symbol_min: str
43+ symbol_max: str
44+ date_min: date
45+ date_max: date
46+ row_count: int
47+ bytes: int
48+ sha256: str
49+
50+
51+class WriterLock:
52+ def __init__(self, lake: Path):
53+ self.path = lake / ".writer.lock"
54+ self._fh = None
55+
56+ def __enter__(self):
57+ self.path.parent.mkdir(parents=True, exist_ok=True)
58+ self._fh = open(self.path, "w")
59+ try:
60+ fcntl.flock(self._fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
61+ except BlockingIOError as e:
62+ raise RuntimeError("another lake writer holds the lock") from e
63+ self._fh.write(f"{os.getpid()} {datetime.now(UTC).isoformat()}\n")
64+ self._fh.flush()
65+ return self
66+
67+ def __exit__(self, *exc):
68+ if self._fh:
69+ fcntl.flock(self._fh, fcntl.LOCK_UN)
70+ self._fh.close()
71+
72+
73+class Lake:
74+ def __init__(self, root: Path | None = None):
75+ self.root = (root or get_settings().lake).expanduser()
76+ for sub in ("raw", "curated", "manifests", "_tmp", "_retired", "exports"):
77+ (self.root / sub).mkdir(parents=True, exist_ok=True)
78+
79+ # ------------------------------------------------------------------ paths
80+ def raw_dir(self, source_id: str, dataset_id: str) -> Path:
81+ p = self.root / "raw" / source_id / dataset_id / str(datetime.now(UTC).year)
82+ p.mkdir(parents=True, exist_ok=True)
83+ return p
84+
85+ def abs(self, rel: str) -> Path:
86+ return self.root / rel
87+
88+ def resolve(self, rel: str) -> Path:
89+ """Live path, or its retired location (older snapshots stay readable until GC after 7 days)."""
90+ p = self.root / rel
91+ if p.exists():
92+ return p
93+ if rel.startswith("curated/"):
94+ r = self.root / "_retired" / rel[len("curated/"):]
95+ if r.exists():
96+ return r
97+ return p
98+
99+ def free_gb(self) -> float:
100+ st = os.statvfs(self.root)
101+ return st.f_bavail * st.f_frsize / 1e9
102+
103+ def used_bytes(self, sub: str = "curated") -> int:
104+ total = 0
105+ for p in (self.root / sub).rglob("*"):
106+ if p.is_file():
107+ total += p.stat().st_size
108+ return total
109+
110+ def check_budget(self) -> None:
111+ s = get_settings()
112+ if self.free_gb() < s.lake_hard_min_free_gb:
113+ raise RuntimeError(f"lake hard stop: only {self.free_gb():.1f} GB free (< {s.lake_hard_min_free_gb} GB)")
114+
115+ # ------------------------------------------------------------------ writing
116+ def write_table(self, dataset_id: str, table: pa.Table, layout: str, partition: dict[str, str],
117+ sort: bool = True) -> WrittenFile:
118+ """Write a Parquet file into _tmp then move it atomically under curated/. Does NOT register it."""
119+ self.check_budget()
120+ if table.num_rows == 0:
121+ raise ValueError("refusing to write an empty file")
122+ if sort:
123+ table = table.sort_by([("symbol_as_reported", "ascending"), ("timestamp_utc", "ascending")])
124+ part_dir = "/".join(f"{k}={v}" for k, v in partition.items())
125+ rel = f"curated/{dataset_id}/{layout}/{part_dir}/part-{uuid.uuid4().hex[:12]}.parquet"
126+ final = self.root / rel
127+ tmp = self.root / "_tmp" / f"{uuid.uuid4().hex}.parquet"
128+ interval = table.column("interval")[0].as_py() if "interval" in table.column_names else "1d"
129+ rg = ROW_GROUP_DAILY if interval == "1d" else ROW_GROUP_INTRADAY
130+ pq.write_table(table, tmp, compression="zstd", compression_level=3, row_group_size=rg,
131+ write_statistics=True, use_dictionary=["symbol_as_reported", "instrument_id", "asset_class",
132+ "interval", "currency", "adjustment_type", "source_id"])
133+ with open(tmp, "rb") as f:
134+ os.fsync(f.fileno())
135+ digest = _sha256(tmp)
136+ final.parent.mkdir(parents=True, exist_ok=True)
137+ os.replace(tmp, final)
138+ syms = table.column("symbol_as_reported")
139+ dates = table.column("session_date")
140+ import pyarrow.compute as pc
141+ return WrittenFile(rel_path=rel, layout=layout, symbol_min=pc.min(syms).as_py(), symbol_max=pc.max(syms).as_py(),
142+ date_min=pc.min(dates).as_py(), date_max=pc.max(dates).as_py(), row_count=table.num_rows,
143+ bytes=final.stat().st_size, sha256=digest)
144+
145+ # ------------------------------------------------------------------ publication (atomic, versioned)
146+ def publish(self, dataset_id: str, added: list[WrittenFile], retire_paths: list[str], ingest_run_id: str | None,
147+ note: str = "") -> dict[str, Any]:
148+ """One transaction: register added files, retire replaced files, bump the dataset version, write manifest.
149+ Retired files are moved to _retired/ *after* commit (readers that already opened them keep working)."""
150+ with transaction() as conn:
151+ row = conn.execute("SELECT current_version FROM datasets WHERE id=%s FOR UPDATE", (dataset_id,)).fetchone()
152+ if row is None:
153+ raise RuntimeError(f"unknown dataset {dataset_id}")
154+ version = int(row["current_version"]) + 1
155+ for p in retire_paths:
156+ conn.execute("UPDATE lake_files SET version_removed=%s WHERE path=%s AND version_removed IS NULL", (version, p))
157+ for f in added:
158+ conn.execute(
159+ "INSERT INTO lake_files(dataset_id, path, layout, symbol_min, symbol_max, date_min, date_max, row_count, bytes, sha256, version_added, ingest_run_id) "
160+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
161+ (dataset_id, f.rel_path, f.layout, f.symbol_min, f.symbol_max, f.date_min, f.date_max, f.row_count, f.bytes, f.sha256, version, ingest_run_id))
162+ live = conn.execute("SELECT path, sha256, row_count, bytes FROM lake_files WHERE dataset_id=%s AND version_removed IS NULL ORDER BY path",
163+ (dataset_id,)).fetchall()
164+ snapshot_id = hashlib.sha256("\n".join(f"{r['path']} {r['sha256']}" for r in live).encode()).hexdigest()
165+ file_count = len(live)
166+ row_count = sum(int(r["row_count"]) for r in live)
167+ nbytes = sum(int(r["bytes"]) for r in live)
168+ conn.execute("INSERT INTO dataset_versions(dataset_id, version, snapshot_id, file_count, row_count, bytes, ingest_run_id, note) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
169+ (dataset_id, version, snapshot_id, file_count, row_count, nbytes, ingest_run_id, note))
170+ conn.execute("UPDATE datasets SET current_version=%s, updated_at=now() WHERE id=%s", (version, dataset_id))
171+ manifest = {"dataset_id": dataset_id, "version": version, "snapshot_id": snapshot_id,
172+ "created_at": datetime.now(UTC).isoformat(), "ingest_run_id": ingest_run_id, "note": note,
173+ "files": [{"path": r["path"], "sha256": r["sha256"], "rows": int(r["row_count"]), "bytes": int(r["bytes"])} for r in live]}
174+ mpath = self.root / "manifests" / dataset_id / f"v{version}.json"
175+ mpath.parent.mkdir(parents=True, exist_ok=True)
176+ tmp = mpath.with_suffix(".json.tmp")
177+ tmp.write_text(json.dumps(manifest, indent=1, default=str))
178+ os.replace(tmp, mpath)
179+ for p in retire_paths:
180+ src = self.root / p
181+ if src.exists():
182+ dst = self.root / "_retired" / p[len("curated/"):] if p.startswith("curated/") else self.root / "_retired" / p
183+ dst.parent.mkdir(parents=True, exist_ok=True)
184+ shutil.move(str(src), str(dst))
185+ return {"version": version, "snapshot_id": snapshot_id, "file_count": file_count, "row_count": row_count, "bytes": nbytes}
186+
187+ def gc_retired(self, older_than_days: int = 7) -> int:
188+ cutoff = datetime.now(UTC).timestamp() - older_than_days * 86400
189+ n = 0
190+ for p in (self.root / "_retired").rglob("*.parquet"):
191+ if p.stat().st_mtime < cutoff:
192+ p.unlink()
193+ n += 1
194+ for p in (self.root / "_tmp").glob("*"):
195+ if p.stat().st_mtime < datetime.now(UTC).timestamp() - 86400:
196+ p.unlink()
197+ return n
198+
199+
200+def _sha256(path: Path) -> str:
201+ h = hashlib.sha256()
202+ with open(path, "rb") as f:
203+ for chunk in iter(lambda: f.read(4 * 1024 * 1024), b""):
204+ h.update(chunk)
205+ return h.hexdigest()
206+
207+
208+def live_files(conn, dataset_id: str, symbols: list[str] | None = None, start: date | None = None,
209+ end: date | None = None, version: int | None = None) -> list[dict[str, Any]]:
210+ """Files to scan for a query (planner). `version=None` → current live set."""
211+ sql = "SELECT path, layout, symbol_min, symbol_max, date_min, date_max, row_count FROM lake_files WHERE dataset_id=%s"
212+ params: list[Any] = [dataset_id]
213+ if version is None:
214+ sql += " AND version_removed IS NULL"
215+ else:
216+ sql += " AND version_added <= %s AND (version_removed IS NULL OR version_removed > %s)"
217+ params += [version, version]
218+ if start is not None:
219+ sql += " AND date_max >= %s"
220+ params.append(start)
221+ if end is not None:
222+ sql += " AND date_min <= %s"
223+ params.append(end)
224+ rows = conn.execute(sql, params).fetchall()
225+ if symbols:
226+ rows = [r for r in rows if any(r["symbol_min"] <= s <= r["symbol_max"] for s in symbols)]
227+ return rows
added services/ingest/src/findata_ingest/queue.py +108 −0
@@ -0,0 +1,108 @@
1+"""Durable job queue on PostgreSQL (SELECT … FOR UPDATE SKIP LOCKED). Survives reboots; retries with
2+exponential backoff + jitter; dead-letter after max_attempts; checkpoints keyed by scope."""
3+from __future__ import annotations
4+
5+import json
6+import random
7+import socket
8+from datetime import UTC, datetime, timedelta
9+from typing import Any
10+
11+from findata_core.db import connection, transaction
12+from findata_core.redact import redact
13+from findata_core.config import get_settings
14+
15+WORKER_ID = f"{socket.gethostname()}:{random.randint(1000, 9999)}"
16+
17+
18+def enqueue(kind: str, dataset_id: str | None = None, params: dict[str, Any] | None = None, priority: int = 50,
19+ dedupe_key: str | None = None, max_attempts: int = 5, created_by: str = "system",
20+ run_at: datetime | None = None) -> str | None:
21+ """Insert a job; returns id, or None if a job with the same dedupe_key is already queued/running."""
22+ with transaction() as conn:
23+ if dedupe_key:
24+ r = conn.execute("SELECT id FROM ingest_jobs WHERE dedupe_key=%s", (dedupe_key,)).fetchone()
25+ if r:
26+ st = conn.execute("SELECT status FROM ingest_jobs WHERE id=%s", (r["id"],)).fetchone()["status"]
27+ if st in ("queued", "running"):
28+ return None
29+ conn.execute("UPDATE ingest_jobs SET dedupe_key=NULL WHERE id=%s", (r["id"],))
30+ r = conn.execute(
31+ "INSERT INTO ingest_jobs(kind, dataset_id, params, priority, dedupe_key, max_attempts, created_by, next_run_at) "
32+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id",
33+ (kind, dataset_id, json.dumps(params or {}), priority, dedupe_key, max_attempts, created_by,
34+ run_at or datetime.now(UTC))).fetchone()
35+ return str(r["id"])
36+
37+
38+def claim_next(kinds: list[str] | None = None) -> dict[str, Any] | None:
39+ with transaction() as conn:
40+ sql = ("SELECT * FROM ingest_jobs WHERE status='queued' AND next_run_at <= now() "
41+ + ("AND kind = ANY(%s) " if kinds else "") +
42+ "ORDER BY priority, next_run_at LIMIT 1 FOR UPDATE SKIP LOCKED")
43+ job = conn.execute(sql, (kinds,) if kinds else None).fetchone()
44+ if not job:
45+ return None
46+ conn.execute("UPDATE ingest_jobs SET status='running', attempts=attempts+1, locked_by=%s, locked_at=now(), "
47+ "started_at=COALESCE(started_at, now()) WHERE id=%s", (WORKER_ID, job["id"]))
48+ run = conn.execute("INSERT INTO ingest_runs(job_id, worker, code_version) VALUES (%s,%s,%s) RETURNING id",
49+ (job["id"], WORKER_ID, _code_version())).fetchone()
50+ job = dict(job)
51+ job["attempts"] += 1
52+ job["run_id"] = str(run["id"])
53+ return job
54+
55+
56+def finish(job: dict[str, Any], ok: bool, result: dict[str, Any] | None = None, error: str | None = None) -> None:
57+ secrets = get_settings().secret_values()
58+ with transaction() as conn:
59+ if ok:
60+ conn.execute("UPDATE ingest_jobs SET status='succeeded', finished_at=now(), locked_by=NULL, result=%s, last_error=NULL WHERE id=%s",
61+ (json.dumps(result or {}, default=str), job["id"]))
62+ conn.execute("UPDATE ingest_runs SET status='succeeded', finished_at=now(), stats=%s WHERE id=%s",
63+ (json.dumps(result or {}, default=str), job["run_id"]))
64+ return
65+ msg = redact(error or "unknown error", secrets)[:4000]
66+ dead = job["attempts"] >= job["max_attempts"]
67+ if dead:
68+ conn.execute("UPDATE ingest_jobs SET status='dead', finished_at=now(), locked_by=NULL, last_error=%s WHERE id=%s", (msg, job["id"]))
69+ else:
70+ delay = min(3600, 30 * 2 ** job["attempts"]) + random.uniform(0, 30)
71+ conn.execute("UPDATE ingest_jobs SET status='queued', locked_by=NULL, last_error=%s, next_run_at=%s WHERE id=%s",
72+ (msg, datetime.now(UTC) + timedelta(seconds=delay), job["id"]))
73+ conn.execute("UPDATE ingest_runs SET status=%s, finished_at=now(), stats=%s WHERE id=%s",
74+ ("dead" if dead else "failed", json.dumps({"error": msg}), job["run_id"]))
75+
76+
77+def progress(job_id: str, data: dict[str, Any]) -> None:
78+ with transaction() as conn:
79+ conn.execute("UPDATE ingest_jobs SET progress=%s WHERE id=%s", (json.dumps(data, default=str), job_id))
80+
81+
82+def set_checkpoint(scope: str, key: str, value: dict[str, Any]) -> None:
83+ with transaction() as conn:
84+ conn.execute("INSERT INTO checkpoints(scope, key, value) VALUES (%s,%s,%s) ON CONFLICT (scope, key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()",
85+ (scope, key, json.dumps(value, default=str)))
86+
87+
88+def get_checkpoint(scope: str, key: str) -> dict[str, Any] | None:
89+ with connection() as conn:
90+ r = conn.execute("SELECT value FROM checkpoints WHERE scope=%s AND key=%s", (scope, key)).fetchone()
91+ return r["value"] if r else None
92+
93+
94+def requeue_stale(max_age_minutes: int = 240) -> int:
95+ """Jobs left 'running' by a crashed worker are re-queued (idempotent tasks + checkpoints make this safe)."""
96+ with transaction() as conn:
97+ r = conn.execute("UPDATE ingest_jobs SET status='queued', locked_by=NULL, next_run_at=now() WHERE status='running' "
98+ "AND locked_at < now() - make_interval(mins => %s) RETURNING id", (max_age_minutes,)).fetchall()
99+ return len(r)
100+
101+
102+def _code_version() -> str:
103+ import subprocess
104+ try:
105+ return subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, timeout=5,
106+ cwd=str(get_settings().root)).stdout.strip() or "unknown"
107+ except Exception: # noqa: BLE001
108+ return "unknown"
added services/ingest/src/findata_ingest/reader.py +161 −0
@@ -0,0 +1,161 @@
1+"""Read side: DuckDB over the live Parquet file set selected from the Postgres manifest (`lake_files`).
2+
3+Adjustments are applied at query time from `corporate_actions` (explicit, reproducible):
4+ split : multiply prices by Π(1/ratio) for splits with ex_date > bar session_date; divide volume by the same factor
5+ split_div : additionally multiply by Π(1 - div/prev_close) for dividends with ex_date > session_date
6+ (prev_close = last raw close strictly before ex_date in the requested data — if unavailable the
7+ dividend factor is skipped and reported in `adjustment_notes`).
8+"""
9+from __future__ import annotations
10+
11+from datetime import date
12+from typing import Any
13+
14+import duckdb
15+import pyarrow as pa
16+
17+from findata_core.config import get_settings
18+from findata_core.db import connection
19+from findata_ingest.lake import Lake, live_files
20+from findata_schema import BARS_SCHEMA
21+
22+
23+def _con(read_only: bool = True) -> duckdb.DuckDBPyConnection:
24+ con = duckdb.connect(database=":memory:", config={"threads": 8, "memory_limit": "8GB", "TimeZone": "UTC"})
25+ return con
26+
27+
28+def _arrow(con: duckdb.DuckDBPyConnection, sql: str, params: dict[str, Any] | None) -> pa.Table:
29+ try:
30+ rel = con.sql(sql, params=params) if params else con.sql(sql)
31+ return rel.to_arrow_table()
32+ except TypeError: # older duckdb without params= on sql()
33+ return con.execute(sql, params).fetch_arrow_table()
34+
35+
36+def empty_bars() -> pa.Table:
37+ return pa.table({f.name: pa.array([], type=f.type) for f in BARS_SCHEMA}, schema=BARS_SCHEMA)
38+
39+
40+def read_bars(dataset_id: str, symbols: list[str], start: date | None, end: date | None, version: int | None = None,
41+ limit: int | None = None, columns: list[str] | None = None, lake: Lake | None = None) -> tuple[pa.Table, dict[str, Any]]:
42+ lake = lake or Lake()
43+ with connection() as conn:
44+ files = live_files(conn, dataset_id, symbols, start, end, version)
45+ ver = conn.execute("SELECT current_version FROM datasets WHERE id=%s", (dataset_id,)).fetchone()
46+ meta = {"dataset_id": dataset_id, "version": version if version is not None else (ver["current_version"] if ver else None),
47+ "files_scanned": len(files)}
48+ if not files:
49+ return empty_bars(), meta
50+ paths = [str(lake.resolve(f["path"])) for f in files]
51+ con = _con()
52+ con.register("paths", pa.table({"p": paths}))
53+ cols = ", ".join(columns) if columns else "*"
54+ where = ["symbol_as_reported IN (SELECT UNNEST($syms))"]
55+ params: dict[str, Any] = {"syms": symbols}
56+ if start:
57+ where.append("session_date >= $start")
58+ params["start"] = start
59+ if end:
60+ where.append("session_date <= $end")
61+ params["end"] = end
62+ sql = f"SELECT {cols} FROM read_parquet($paths, union_by_name=true, hive_partitioning=false) WHERE {' AND '.join(where)} ORDER BY symbol_as_reported, timestamp_utc"
63+ params["paths"] = paths
64+ if limit:
65+ sql += f" LIMIT {int(limit) + 1}"
66+ tbl = _arrow(con, sql, params)
67+ meta["truncated"] = bool(limit and tbl.num_rows > limit)
68+ if limit and tbl.num_rows > limit:
69+ tbl = tbl.slice(0, limit)
70+ if not columns:
71+ tbl = tbl.cast(BARS_SCHEMA)
72+ return tbl, meta
73+
74+
75+def apply_adjustment(table: pa.Table, adjustment: str, lake: Lake | None = None) -> tuple[pa.Table, list[str]]:
76+ """Return an adjusted copy of raw bars (`adjustment_type` column updated). Never mutates the lake."""
77+ if adjustment in ("raw", None) or table.num_rows == 0:
78+ return table, []
79+ import polars as pl
80+ df = pl.from_arrow(table)
81+ syms = df.get_column("symbol_as_reported").unique().to_list()
82+ iids = [f"firstrate:{df.filter(pl.col('symbol_as_reported') == s).get_column('asset_class')[0]}:{s}" for s in syms]
83+ with connection() as conn:
84+ acts = conn.execute("SELECT instrument_id, action_type, ex_date, value::float8 AS value FROM corporate_actions WHERE instrument_id = ANY(%s) ORDER BY ex_date",
85+ (iids,)).fetchall()
86+ notes: list[str] = []
87+ if not acts:
88+ notes.append("no corporate actions on file for requested symbols")
89+ return df.with_columns(pl.lit(adjustment).alias("adjustment_type")).to_arrow().cast(BARS_SCHEMA), notes
90+ out_frames = []
91+ for s in syms:
92+ sub = df.filter(pl.col("symbol_as_reported") == s).sort("timestamp_utc")
93+ iid = f"firstrate:{sub.get_column('asset_class')[0]}:{s}"
94+ factor = pl.Series("f", [1.0] * sub.height)
95+ sd = sub.get_column("session_date")
96+ for a in acts:
97+ if a["instrument_id"] != iid:
98+ continue
99+ if a["action_type"] == "split":
100+ f = 1.0 / float(a["value"])
101+ factor = factor * pl.Series([f if d < a["ex_date"] else 1.0 for d in sd])
102+ elif a["action_type"] == "dividend" and adjustment == "split_div":
103+ prev = sub.filter(pl.col("session_date") < a["ex_date"]).get_column("close")
104+ if prev.len() == 0:
105+ notes.append(f"{s}: dividend {a['ex_date']} skipped (no prior close in range)")
106+ continue
107+ f = 1.0 - float(a["value"]) / float(prev[-1])
108+ factor = factor * pl.Series([f if d < a["ex_date"] else 1.0 for d in sd])
109+ sub = sub.with_columns(factor.alias("_f")).with_columns(
110+ (pl.col("open") * pl.col("_f")).alias("open"), (pl.col("high") * pl.col("_f")).alias("high"),
111+ (pl.col("low") * pl.col("_f")).alias("low"), (pl.col("close") * pl.col("_f")).alias("close"),
112+ (pl.col("volume") / pl.col("_f")).alias("volume"), pl.lit(adjustment).alias("adjustment_type")).drop("_f")
113+ out_frames.append(sub)
114+ res = pl.concat(out_frames).sort(["symbol_as_reported", "timestamp_utc"])
115+ return res.to_arrow().cast(BARS_SCHEMA), notes
116+
117+
118+def run_sql(sql: str, max_rows: int, timeout_s: int, views: dict[str, list[str]] | None = None) -> tuple[pa.Table, dict[str, Any]]:
119+ """Sandboxed read-only SQL: DuckDB in-memory, external access limited to the registered dataset views."""
120+ import re
121+ import threading
122+
123+ forbidden = re.compile(r"\b(attach|copy|export|import|install|load|create|insert|update|delete|drop|alter|pragma|set|call|read_csv|read_json|read_text|glob|httpfs)\b", re.I)
124+ if forbidden.search(sql) or ";" in sql.strip().rstrip(";"):
125+ raise ValueError("only a single read-only SELECT statement is allowed")
126+ if not re.match(r"^\s*(select|with|describe|show|summarize)\b", sql, re.I):
127+ raise ValueError("statement must start with SELECT / WITH")
128+ con = _con()
129+ lake_root = str(Lake().root).replace("'", "''")
130+ con.execute(f"SET allowed_directories = ['{lake_root}/curated', '{lake_root}/_retired']")
131+ for name, paths in (views or {}).items():
132+ if not re.fullmatch(r"[a-z][a-z0-9_]*", name):
133+ raise ValueError(f"bad view name {name}")
134+ if paths:
135+ lst = ", ".join("'" + p.replace("'", "''") + "'" for p in paths)
136+ con.execute(f"CREATE VIEW {name} AS SELECT * FROM read_parquet([{lst}], union_by_name=true, hive_partitioning=false)")
137+ else:
138+ con.execute(f"CREATE VIEW {name} AS SELECT * FROM (VALUES (NULL)) t(empty) WHERE false")
139+ con.execute("SET enable_external_access = false")
140+ timer = threading.Timer(timeout_s, con.interrupt)
141+ timer.start()
142+ try:
143+ tbl = _arrow(con, f"SELECT * FROM ({sql.strip().rstrip(';')}) q LIMIT {int(max_rows) + 1}", None)
144+ except duckdb.InterruptException as e:
145+ raise TimeoutError(f"query exceeded {timeout_s}s") from e
146+ finally:
147+ timer.cancel()
148+ truncated = tbl.num_rows > max_rows
149+ return tbl.slice(0, max_rows), {"truncated": truncated}
150+
151+
152+def dataset_views() -> dict[str, list[str]]:
153+ """Map SQL view names (dataset ids with dots → underscores) to live file lists."""
154+ lake = Lake()
155+ out: dict[str, list[str]] = {}
156+ with connection() as conn:
157+ ds = conn.execute("SELECT id FROM datasets WHERE kind IN ('market_bars','derived')").fetchall()
158+ for d in ds:
159+ files = live_files(conn, d["id"])
160+ out[d["id"].replace(".", "_")] = [str(lake.abs(f["path"])) for f in files]
161+ return out
added services/ingest/src/findata_ingest/tasks/__init__.py +13 −0
@@ -0,0 +1,13 @@
1+from findata_ingest.tasks import backfill, backup, compaction, derive, fundamentals, incremental, metadata, quality # noqa: F401
2+
3+REGISTRY = {
4+ "metadata": metadata.run,
5+ "incremental": incremental.run,
6+ "backfill": backfill.run,
7+ "derive": derive.run,
8+ "compaction": compaction.run,
9+ "fundamentals": fundamentals.run,
10+ "backup": backup.run,
11+ "quality": quality.run,
12+ "gc": quality.gc,
13+}
added services/ingest/src/findata_ingest/tasks/backfill.py +111 −0
@@ -0,0 +1,111 @@
1+"""Bounded historical backfill from `period=full&ticker_range=<letter>` archives → base files per (symbol, year).
2+
3+Streams one vendor file (one ticker, full history) at a time so memory stays flat. Dates that already have a live
4+delta file are excluded (they are compacted later) so the no-duplicate invariant holds. Resumable: progress
5+checkpoint `job:<id>/done_symbols`; a restarted job skips symbols already published.
6+"""
7+from __future__ import annotations
8+
9+from typing import Any
10+
11+import pyarrow as pa
12+import pyarrow.compute as pc
13+
14+from findata_core.db import connection, transaction
15+from findata_ingest import queue
16+from findata_ingest.lake import Lake
17+from findata_ingest.tasks.common import DATASET_ASSET, live_delta_dates, record_anomalies, record_quality_report, record_raw_archive, upsert_coverage
18+from findata_market_firstrate import FirstRateMarketConnector
19+from findata_schema import Interval
20+from findata_schema.connector import FetchRequest
21+
22+
23+def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
24+ p = job.get("params") or {}
25+ asset = p.get("type", "stock")
26+ letter = p["ticker_range"]
27+ timeframe = p.get("timeframe", "1min")
28+ adjustment = p.get("adjustment", "UNADJUSTED")
29+ only = set(s.upper() for s in (p.get("symbols") or []))
30+ max_symbols = int(p.get("max_symbols") or 0)
31+ dataset_id = job.get("dataset_id") or DATASET_ASSET[asset]
32+ run_id = job.get("run_id")
33+ interval = Interval({"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"}[timeframe])
34+ conn_fr = FirstRateMarketConnector()
35+
36+ ck = queue.get_checkpoint(f"job:{job['id']}", "state") or {}
37+ done = set(ck.get("done_symbols") or [])
38+ raw_rel = ck.get("raw_path")
39+ if raw_rel and (lake.root / raw_rel).exists():
40+ from findata_schema.connector import FetchResult
41+ from datetime import datetime
42+ res = FetchResult(request=FetchRequest(dataset_id, {"type": asset, "period": "full", "ticker_range": letter, "timeframe": timeframe, "adjustment": adjustment}),
43+ path=lake.root / raw_rel, sha256=ck["sha256"], bytes=ck["bytes"], fetched_at=datetime.fromisoformat(ck["fetched_at"]),
44+ vendor_last_update=None)
45+ log.info("backfill.resume", done=len(done), archive=raw_rel)
46+ else:
47+ req = FetchRequest(dataset_id=dataset_id, params={"endpoint": "data_file", "type": asset, "period": "full", "ticker_range": letter,
48+ "timeframe": timeframe, "adjustment": adjustment})
49+ queue.progress(job["id"], {"stage": "download", "ticker_range": letter})
50+ res = conn_fr.fetch_range(req, lake.raw_dir("firstrate", dataset_id))
51+ raw_rel = str(res.path.relative_to(lake.root))
52+ with transaction() as conn:
53+ record_raw_archive(conn, "firstrate", dataset_id, req.params, raw_rel, res.sha256, res.bytes, res.file_count, res.vendor_last_update, run_id)
54+ queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": raw_rel, "sha256": res.sha256, "bytes": res.bytes,
55+ "fetched_at": res.fetched_at.isoformat(), "done_symbols": sorted(done)})
56+ log.info("backfill.downloaded", bytes=res.bytes, files=res.file_count)
57+
58+ with connection() as conn:
59+ delta_dates = live_delta_dates(conn, dataset_id)
60+ stats = {"symbols": 0, "rows": 0, "files": 0, "skipped_rows_in_delta": 0, "anomalies": 0}
61+ for batch in conn_fr.normalize(res, symbols=list(only) if only else None):
62+ if batch.symbol in done:
63+ continue
64+ if max_symbols and stats["symbols"] >= max_symbols:
65+ break
66+ rep = conn_fr.validate(batch)
67+ t = batch.table
68+ if delta_dates:
69+ mask = pc.invert(pc.is_in(t.column("session_date"), value_set=pa.array(sorted(delta_dates), pa.date32())))
70+ kept = t.filter(mask)
71+ stats["skipped_rows_in_delta"] += t.num_rows - kept.num_rows
72+ t = kept
73+ if t.num_rows == 0:
74+ done.add(batch.symbol)
75+ queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": raw_rel, "sha256": res.sha256, "bytes": res.bytes,
76+ "fetched_at": res.fetched_at.isoformat(), "done_symbols": sorted(done)})
77+ continue
78+ years = pc.year(t.column("session_date"))
79+ written = []
80+ for y in sorted(set(years.to_pylist())):
81+ ty = t.filter(pc.equal(years, y))
82+ written.append(lake.write_table(dataset_id, ty, "base", {"symbol": batch.symbol, "year": str(y)}))
83+ # retire previous base files of this symbol/year (re-backfill replaces)
84+ with connection() as conn:
85+ old = conn.execute("SELECT path FROM lake_files WHERE dataset_id=%s AND layout='base' AND version_removed IS NULL AND symbol_min=%s AND symbol_max=%s",
86+ (dataset_id, batch.symbol, batch.symbol)).fetchall()
87+ lake.publish(dataset_id, written, [o["path"] for o in old], run_id, note=f"backfill {asset} {letter} {batch.symbol}")
88+ with transaction() as conn:
89+ upsert_coverage(conn, dataset_id, t, interval)
90+ record_anomalies(conn, dataset_id, rep.anomalies, run_id, limit=500)
91+ stats["symbols"] += 1
92+ stats["rows"] += t.num_rows
93+ stats["files"] += len(written)
94+ stats["anomalies"] += len(rep.anomalies)
95+ done.add(batch.symbol)
96+ queue.set_checkpoint(f"job:{job['id']}", "state", {"raw_path": raw_rel, "sha256": res.sha256, "bytes": res.bytes,
97+ "fetched_at": res.fetched_at.isoformat(), "done_symbols": sorted(done)})
98+ queue.progress(job["id"], {"stage": "publish", **stats, "last_symbol": batch.symbol})
99+ with transaction() as conn:
100+ conn.execute("UPDATE raw_archives SET status='published' WHERE sha256=%s", (res.sha256,))
101+ record_quality_report(conn, dataset_id, run_id, stats["rows"], [])
102+ if not p.get("keep_raw", False) and p.get("delete_raw_after_publish", True):
103+ # full archives are re-downloadable; sha256 stays in raw_archives
104+ res.path.unlink(missing_ok=True)
105+ with transaction() as conn:
106+ conn.execute("UPDATE raw_archives SET status='deleted_after_publish', path=NULL WHERE sha256=%s", (res.sha256,))
107+ if interval == Interval.m1 and stats["rows"]:
108+ queue.enqueue("derive", dataset_id.replace(".1m.", ".5m."), {"source_dataset": dataset_id, "target_interval": "5m",
109+ "symbols": sorted(done)}, priority=45, dedupe_key=f"derive:{dataset_id}:5m:backfill:{job['id']}")
110+ log.info("backfill.done", **stats)
111+ return stats
added services/ingest/src/findata_ingest/tasks/backup.py +95 −0
@@ -0,0 +1,95 @@
1+"""Encrypted-at-rest-by-FileVault-free policy: backups are rsync'ed over SSH (encrypted in transit) to a second
2+LAN node, plus a pg_dump of the metadata database. Restore drill: `findata backup restore-drill`."""
3+from __future__ import annotations
4+
5+import os
6+import subprocess
7+from datetime import UTC, datetime
8+from pathlib import Path
9+from typing import Any
10+
11+from findata_core.config import get_settings
12+from findata_core.db import transaction
13+from findata_ingest.lake import Lake
14+
15+
16+def _env() -> dict[str, str]:
17+ env = dict(os.environ)
18+ env["PATH"] = "/opt/homebrew/opt/postgresql@17/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
19+ return env
20+
21+
22+def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
23+ s = get_settings()
24+ target = (job.get("params") or {}).get("target") or s.backup_target
25+ if not target:
26+ raise RuntimeError("FINDATA_BACKUP_TARGET not configured")
27+ host, _, remote_dir = target.partition(":")
28+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
29+ dumps = lake.root / "_pgdump"
30+ dumps.mkdir(exist_ok=True)
31+ dump = dumps / f"findata-{stamp}.sql.zst"
32+ url = s.database_url.get_secret_value()
33+ cmd = f"pg_dump --no-owner --no-privileges '{url}' | zstd -q -T0 -o '{dump}'"
34+ r = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, env=_env(), timeout=3600)
35+ if r.returncode != 0:
36+ raise RuntimeError(f"pg_dump failed: {r.stderr[-500:]}")
37+ for old in sorted(dumps.glob("findata-*.sql.zst"))[:-14]:
38+ old.unlink()
39+ subprocess.run(["ssh", "-o", "BatchMode=yes", host, f"mkdir -p {remote_dir}/lake {remote_dir}/pgdump"], check=True, timeout=60, env=_env())
40+ excludes = ["--exclude", "_tmp/", "--exclude", "_retired/", "--exclude", "exports/", "--exclude", ".writer.lock"]
41+ rs = subprocess.run(["rsync", "-a", "--delete", "--stats", *excludes, f"{lake.root}/", f"{host}:{remote_dir}/lake/"],
42+ capture_output=True, text=True, env=_env(), timeout=6 * 3600)
43+ if rs.returncode != 0:
44+ raise RuntimeError(f"rsync failed: {rs.stderr[-500:]}")
45+ rs2 = subprocess.run(["rsync", "-a", f"{dumps}/", f"{host}:{remote_dir}/pgdump/"], capture_output=True, text=True, env=_env(), timeout=3600)
46+ if rs2.returncode != 0:
47+ raise RuntimeError(f"rsync pgdump failed: {rs2.stderr[-500:]}")
48+ # verification: remote file count + bytes of curated/
49+ v = subprocess.run(["ssh", "-o", "BatchMode=yes", host, f"find {remote_dir}/lake/curated -type f | wc -l; du -sk {remote_dir}/lake/curated | cut -f1"],
50+ capture_output=True, text=True, env=_env(), timeout=600)
51+ remote_files, remote_kb = [x.strip() for x in v.stdout.strip().splitlines()[:2]] if v.returncode == 0 else ("?", "?")
52+ local_files = sum(1 for p in (lake.root / "curated").rglob("*") if p.is_file())
53+ stats = {"at": stamp, "target": target, "pg_dump": dump.name, "pg_dump_bytes": dump.stat().st_size,
54+ "rsync_summary": [ln for ln in rs.stdout.splitlines() if "Number of files" in ln or "Total transferred" in ln or "Total file size" in ln],
55+ "remote_curated_files": remote_files, "local_curated_files": local_files, "remote_curated_kb": remote_kb,
56+ "verified": str(local_files) == str(remote_files)}
57+ with transaction() as conn:
58+ conn.execute("INSERT INTO settings_kv(key, value) VALUES ('last_backup', %s) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()",
59+ (__import__("json").dumps(stats, default=str),))
60+ log.info("backup.done", **{k: v for k, v in stats.items() if k != "rsync_summary"})
61+ return stats
62+
63+
64+def restore_drill(target: str, dest: Path, dataset_id: str = "market_bars.stock.1m.raw") -> dict[str, Any]:
65+ """Restore the latest pg_dump into a scratch database and the lake into `dest`, then verify manifest hashes."""
66+ import hashlib
67+ import json
68+ s = get_settings()
69+ host, _, remote_dir = target.partition(":")
70+ dest.mkdir(parents=True, exist_ok=True)
71+ subprocess.run(["rsync", "-a", f"{host}:{remote_dir}/lake/curated/{dataset_id}/", f"{dest}/curated/{dataset_id}/"], check=True, env=_env(), timeout=3600)
72+ subprocess.run(["rsync", "-a", f"{host}:{remote_dir}/lake/manifests/{dataset_id}/", f"{dest}/manifests/{dataset_id}/"], check=True, env=_env(), timeout=600)
73+ latest = subprocess.run(["ssh", "-o", "BatchMode=yes", host, f"ls -1 {remote_dir}/pgdump | sort | tail -1"], capture_output=True, text=True, env=_env(), timeout=60).stdout.strip()
74+ subprocess.run(["rsync", "-a", f"{host}:{remote_dir}/pgdump/{latest}", f"{dest}/"], check=True, env=_env(), timeout=600)
75+ base_url = s.database_url.get_secret_value().rsplit("/", 1)[0]
76+ scratch = f"findata_restore_{datetime.now(UTC).strftime('%H%M%S')}"
77+ subprocess.run(["bash", "-c", f"psql '{base_url}/postgres' -qc 'CREATE DATABASE {scratch}' && zstd -dc '{dest}/{latest}' | psql -q '{base_url}/{scratch}' >/dev/null"],
78+ check=True, env=_env(), timeout=3600)
79+ out = subprocess.run(["bash", "-c", f"psql -tA '{base_url}/{scratch}' -c \"SELECT count(*) FROM lake_files WHERE dataset_id='{dataset_id}' AND version_removed IS NULL\""],
80+ capture_output=True, text=True, env=_env(), timeout=120).stdout.strip()
81+ manifests = sorted((dest / "manifests" / dataset_id).glob("v*.json"), key=lambda p: int(p.stem[1:]))
82+ verified = 0
83+ missing = 0
84+ if manifests:
85+ m = json.loads(manifests[-1].read_text())
86+ for f in m["files"]:
87+ p = dest / f["path"]
88+ if not p.exists():
89+ missing += 1
90+ continue
91+ h = hashlib.sha256(p.read_bytes()).hexdigest()
92+ verified += int(h == f["sha256"])
93+ subprocess.run(["bash", "-c", f"psql '{base_url}/postgres' -qc 'DROP DATABASE {scratch}'"], env=_env(), timeout=120)
94+ return {"pg_dump": latest, "restored_lake_files_in_db": out, "manifest_version": manifests[-1].stem if manifests else None,
95+ "files_hash_verified": verified, "files_missing": missing, "dest": str(dest)}
added services/ingest/src/findata_ingest/tasks/common.py +73 −0
@@ -0,0 +1,73 @@
1+from __future__ import annotations
2+
3+import json
4+from datetime import UTC, date, datetime
5+from typing import Any
6+
7+import pyarrow as pa
8+
9+from findata_core.db import transaction
10+from findata_ingest.lake import Lake, WrittenFile
11+from findata_quality.validators import session_coverage
12+from findata_schema import Interval
13+
14+DATASET_ASSET = {"stock": "market_bars.stock.1m.raw", "etf": "market_bars.etf.1m.raw", "index": "market_bars.index.1m.raw",
15+ "fx": "market_bars.fx.1m.raw", "crypto": "market_bars.crypto.1m.raw", "futures": "market_bars.futures.1m.continuous"}
16+
17+
18+def record_raw_archive(conn, source_id: str, dataset_id: str, request: dict[str, Any], rel_path: str, sha256: str,
19+ nbytes: int, file_count: int | None, vendor_last_update: date | None, run_id: str | None) -> tuple[str, bool]:
20+ """Insert raw archive row; returns (id, is_new). Same sha256 → already known (idempotent re-fetch)."""
21+ r = conn.execute("SELECT id, status FROM raw_archives WHERE source_id=%s AND sha256=%s", (source_id, sha256)).fetchone()
22+ if r:
23+ return str(r["id"]), False
24+ r = conn.execute(
25+ "INSERT INTO raw_archives(source_id, dataset_id, request, path, sha256, bytes, file_count, vendor_last_update, ingest_run_id, status) "
26+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,'stored') RETURNING id",
27+ (source_id, dataset_id, json.dumps(request, default=str), rel_path, sha256, nbytes, file_count, vendor_last_update, run_id)).fetchone()
28+ return str(r["id"]), True
29+
30+
31+def upsert_coverage(conn, dataset_id: str, table: pa.Table, interval: Interval) -> int:
32+ rows = session_coverage(table, interval)
33+ for r in rows:
34+ conn.execute(
35+ "INSERT INTO coverage(dataset_id, symbol, session_date, bar_count, first_ts, last_ts, expected_bars, flags) "
36+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT (dataset_id, symbol, session_date) DO UPDATE SET bar_count=EXCLUDED.bar_count, "
37+ "first_ts=EXCLUDED.first_ts, last_ts=EXCLUDED.last_ts, expected_bars=EXCLUDED.expected_bars, flags=EXCLUDED.flags, updated_at=now()",
38+ (dataset_id, r["symbol"], r["session_date"], r["bar_count"], r["first_ts"], r["last_ts"], r["expected_bars"], r["flags"]))
39+ return len(rows)
40+
41+
42+def record_anomalies(conn, dataset_id: str, anomalies: list[dict[str, Any]], run_id: str | None, limit: int = 5000) -> int:
43+ n = 0
44+ for a in anomalies[:limit]:
45+ conn.execute("INSERT INTO quality_anomalies(dataset_id, symbol, session_date, code, severity, detail, ingest_run_id) VALUES (%s,%s,%s,%s,%s,%s,%s)",
46+ (dataset_id, a.get("symbol"), a.get("session_date"), a["code"], a["severity"], json.dumps(a.get("detail", {}), default=str), run_id))
47+ n += 1
48+ return n
49+
50+
51+def record_quality_report(conn, dataset_id: str, run_id: str | None, rows: int, anomalies: list[dict[str, Any]]) -> None:
52+ summary: dict[str, int] = {}
53+ for a in anomalies:
54+ summary[a["code"]] = summary.get(a["code"], 0) + int(a.get("detail", {}).get("rows", 1))
55+ conn.execute("INSERT INTO quality_reports(dataset_id, ingest_run_id, rows_checked, anomalies, summary) VALUES (%s,%s,%s,%s,%s)",
56+ (dataset_id, run_id, rows, len(anomalies), json.dumps(summary)))
57+
58+
59+def live_delta_paths_for_dates(conn, dataset_id: str, dates: list[date]) -> list[str]:
60+ if not dates:
61+ return []
62+ rows = conn.execute("SELECT path FROM lake_files WHERE dataset_id=%s AND layout='delta' AND version_removed IS NULL AND date_min = ANY(%s)",
63+ (dataset_id, dates)).fetchall()
64+ return [r["path"] for r in rows]
65+
66+
67+def live_delta_dates(conn, dataset_id: str) -> set[date]:
68+ rows = conn.execute("SELECT DISTINCT date_min FROM lake_files WHERE dataset_id=%s AND layout='delta' AND version_removed IS NULL", (dataset_id,)).fetchall()
69+ return {r["date_min"] for r in rows}
70+
71+
72+def now() -> datetime:
73+ return datetime.now(UTC)
added services/ingest/src/findata_ingest/tasks/compaction.py +57 −0
@@ -0,0 +1,57 @@
1+"""Compaction: merge session_date delta files older than `min_age_days` into base files per (symbol, year).
2+Asserts the (instrument_id, timestamp_utc) uniqueness invariant before publishing."""
3+from __future__ import annotations
4+
5+from datetime import UTC, datetime, timedelta
6+from typing import Any
7+
8+import duckdb
9+import pyarrow as pa
10+
11+from findata_core.db import connection
12+from findata_ingest.lake import Lake
13+from findata_schema import BARS_SCHEMA
14+
15+
16+def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
17+ p = job.get("params") or {}
18+ dataset_id = job["dataset_id"]
19+ min_age = int(p.get("min_age_days", 35))
20+ max_files = int(p.get("max_delta_files", 40))
21+ cutoff = (datetime.now(UTC) - timedelta(days=min_age)).date()
22+ with connection() as conn:
23+ deltas = conn.execute("SELECT path, date_min FROM lake_files WHERE dataset_id=%s AND layout='delta' AND version_removed IS NULL AND date_max <= %s ORDER BY date_min LIMIT %s",
24+ (dataset_id, cutoff, max_files)).fetchall()
25+ if not deltas:
26+ return {"compacted_files": 0}
27+ paths = [str(lake.abs(d["path"])) for d in deltas]
28+ con = duckdb.connect()
29+ con.execute("SET threads TO 8")
30+ syms = [r[0] for r in con.sql("SELECT DISTINCT symbol_as_reported FROM read_parquet($p, hive_partitioning=false) ORDER BY 1", params={"p": paths}).fetchall()]
31+ years = sorted({d["date_min"].year for d in deltas})
32+ written, retire = [], [d["path"] for d in deltas]
33+ rows = 0
34+ for s in syms:
35+ for y in years:
36+ new = con.sql("SELECT * FROM read_parquet($p, hive_partitioning=false) WHERE symbol_as_reported=$s AND year(session_date)=$y", params={"p": paths, "s": s, "y": y}).to_arrow_table()
37+ if new.num_rows == 0:
38+ continue
39+ with connection() as conn:
40+ old = conn.execute("SELECT path FROM lake_files WHERE dataset_id=%s AND layout='base' AND version_removed IS NULL AND symbol_min=%s AND symbol_max=%s AND date_min>=%s AND date_max<=%s",
41+ (dataset_id, s, s, f"{y}-01-01", f"{y}-12-31")).fetchall()
42+ parts = [new.cast(BARS_SCHEMA)]
43+ for o in old:
44+ t = con.sql("SELECT * FROM read_parquet($p, hive_partitioning=false)", params={"p": [str(lake.abs(o["path"]))]}).to_arrow_table()
45+ parts.append(t.cast(BARS_SCHEMA))
46+ retire.append(o["path"])
47+ merged = pa.concat_tables(parts)
48+ con.register("m", merged)
49+ dup = con.sql("SELECT count(*) AS n FROM (SELECT instrument_id, timestamp_utc FROM m GROUP BY 1,2 HAVING count(*)>1)").fetchall()[0][0]
50+ con.unregister("m")
51+ if dup:
52+ raise RuntimeError(f"compaction invariant violated: {dup} duplicate (instrument_id, timestamp_utc) for {s} {y}")
53+ written.append(lake.write_table(dataset_id, merged, "base", {"symbol": s, "year": str(y)}))
54+ rows += merged.num_rows
55+ pub = lake.publish(dataset_id, written, sorted(set(retire)), job.get("run_id"), note=f"compaction {len(deltas)} delta files")
56+ log.info("compaction.done", deltas=len(deltas), base_files=len(written), rows=rows, **pub)
57+ return {"compacted_files": len(deltas), "base_files_written": len(written), "rows": rows, **pub}
added services/ingest/src/findata_ingest/tasks/derive.py +56 −0
@@ -0,0 +1,56 @@
1+"""Derive 5-minute (or other) bars from validated 1-minute bars, per session_date (delta layout, supersede)."""
2+from __future__ import annotations
3+
4+from datetime import date
5+from typing import Any
6+
7+import pyarrow as pa
8+
9+from findata_core.db import connection, transaction
10+from findata_ingest.lake import Lake, live_files
11+from findata_ingest.reader import read_bars
12+from findata_ingest.tasks.common import live_delta_paths_for_dates, upsert_coverage
13+from findata_quality import aggregate_bars
14+from findata_schema import Interval
15+
16+
17+def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
18+ p = job.get("params") or {}
19+ src = p["source_dataset"]
20+ dst = job.get("dataset_id") or src.replace(".1m.", ".5m.")
21+ target = Interval(p.get("target_interval", "5m"))
22+ dates = [date.fromisoformat(d) for d in p.get("dates", [])]
23+ symbols = p.get("symbols")
24+ run_id = job.get("run_id")
25+ with connection() as conn:
26+ if not dates:
27+ # all live source dates for requested symbols
28+ fl = live_files(conn, src, symbols)
29+ ds = set()
30+ for f in fl:
31+ d = f["date_min"]
32+ while d <= f["date_max"]:
33+ ds.add(d)
34+ d = d.fromordinal(d.toordinal() + 1)
35+ dates = sorted(ds)
36+ all_syms = symbols or [r["symbol"] for r in conn.execute("SELECT DISTINCT symbol FROM coverage WHERE dataset_id=%s AND session_date = ANY(%s)", (src, dates)).fetchall()]
37+ written, rows = [], 0
38+ tables = {}
39+ for d in dates:
40+ t, _ = read_bars(src, all_syms, d, d, lake=lake)
41+ if t.num_rows == 0:
42+ continue
43+ agg = aggregate_bars(t, target, Interval.m1)
44+ tables[d] = agg
45+ rows += agg.num_rows
46+ written.append(lake.write_table(dst, agg, "delta", {"session_date": d.isoformat()}))
47+ if not written:
48+ return {"rows": 0, "dates": len(dates)}
49+ with connection() as conn:
50+ retire = live_delta_paths_for_dates(conn, dst, list(tables)) if not symbols else []
51+ pub = lake.publish(dst, written, retire, run_id, note=f"derive {target.value} from {src}")
52+ with transaction() as conn:
53+ for t in tables.values():
54+ upsert_coverage(conn, dst, t, target)
55+ log.info("derive.done", rows=rows, files=len(written), **pub)
56+ return {"rows": rows, "files": len(written), "dates": len(tables), **pub}
added services/ingest/src/findata_ingest/tasks/fundamentals.py +78 −0
@@ -0,0 +1,78 @@
1+"""Fundamentals ingestion (FirstRate /api-f) with revision tracking. Params: tickers[] or universe (active stocks,
2+limit n). Each run: fetch quarterly + annual financials and earnings; compare with latest stored revision per
3+(entity, metric, period_end, period_type); unchanged → nothing; changed → new revision, restatement_flag, supersede."""
4+from __future__ import annotations
5+
6+from datetime import UTC, datetime
7+from typing import Any
8+
9+from findata_core.db import connection, transaction
10+from findata_fundamentals import FirstRateFundamentalsConnector, facts_from_financials
11+from findata_ingest import queue
12+from findata_ingest.lake import Lake
13+
14+
15+def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
16+ p = job.get("params") or {}
17+ tickers = [t.upper() for t in (p.get("tickers") or [])]
18+ if not tickers:
19+ n = int(p.get("limit", 50))
20+ with connection() as conn:
21+ tickers = [r["symbol"] for r in conn.execute("SELECT symbol FROM instruments WHERE asset_class='stock' AND status='active' ORDER BY symbol LIMIT %s", (n,)).fetchall()]
22+ fr = FirstRateFundamentalsConnector()
23+ observed = datetime.now(UTC)
24+ run_id = job.get("run_id")
25+ stats = {"tickers": 0, "facts_new": 0, "facts_unchanged": 0, "facts_restated": 0, "earnings": 0, "errors": 0, "pit_fallback": 0}
26+ ck = queue.get_checkpoint(f"job:{job['id']}", "state") or {}
27+ done = set(ck.get("done") or [])
28+ for tk in tickers:
29+ if tk in done:
30+ continue
31+ try:
32+ facts = []
33+ for period in ("quarterly", "annual"):
34+ payload = fr.financials_all(tk, period)
35+ facts.extend(facts_from_financials(payload, observed, tk))
36+ earnings = fr.earnings(tk)
37+ except Exception as e: # noqa: BLE001
38+ stats["errors"] += 1
39+ log.warning("fundamentals.error", ticker=tk, error=str(e)[:200])
40+ continue
41+ with transaction() as conn:
42+ conn.execute("INSERT INTO entities(entity_id, name, tickers) VALUES (%s, (SELECT name FROM instruments WHERE symbol=%s AND asset_class='stock' LIMIT 1), %s) "
43+ "ON CONFLICT (entity_id) DO UPDATE SET tickers=EXCLUDED.tickers, updated_at=now()", (f"frd:{tk}", tk, [tk]))
44+ for f in facts:
45+ if f.pop("pit_fallback", False):
46+ stats["pit_fallback"] += 1
47+ cur = conn.execute("SELECT id, value, revision_id FROM fundamentals_facts WHERE entity_id=%s AND metric=%s AND fiscal_period_end=%s AND fiscal_period_type=%s AND source_id=%s AND superseded_at IS NULL",
48+ (f["entity_id"], f["metric"], f["fiscal_period_end"], f["fiscal_period_type"], f["source_id"])).fetchone()
49+ if cur is not None:
50+ same = (cur["value"] is None and f["value"] is None) or (cur["value"] is not None and f["value"] is not None and abs(float(cur["value"]) - f["value"]) <= 1e-9 * max(1.0, abs(f["value"])))
51+ if same:
52+ stats["facts_unchanged"] += 1
53+ continue
54+ conn.execute("UPDATE fundamentals_facts SET superseded_at=%s WHERE id=%s", (observed, cur["id"]))
55+ rev = int(cur["revision_id"]) + 1
56+ restated = True
57+ stats["facts_restated"] += 1
58+ else:
59+ rev, restated = 1, False
60+ stats["facts_new"] += 1
61+ conn.execute(
62+ "INSERT INTO fundamentals_facts(entity_id, security_id, metric, value, unit, currency, fiscal_period_start, fiscal_period_end, fiscal_period_type, fiscal_year, fiscal_period, "
63+ "filing_date, published_at, available_at, observed_at, source_document, source_id, revision_id, restatement_flag, ingest_run_id) "
64+ "VALUES (%(entity_id)s,%(security_id)s,%(metric)s,%(value)s,%(unit)s,%(currency)s,%(fiscal_period_start)s,%(fiscal_period_end)s,%(fiscal_period_type)s,%(fiscal_year)s,%(fiscal_period)s,"
65+ "%(filing_date)s,%(published_at)s,%(available_at)s,%(observed_at)s,%(source_document)s,%(source_id)s,%(rev)s,%(restated)s,%(run_id)s)",
66+ {**f, "rev": rev, "restated": restated, "run_id": run_id})
67+ for e in earnings:
68+ conn.execute("INSERT INTO earnings_events(entity_id, event_date, eps_estimate, eps_reported, release_time, source_id) VALUES (%s,%s,%s,%s,%s,'firstrate_fundamentals') "
69+ "ON CONFLICT (entity_id, event_date, source_id) DO UPDATE SET eps_estimate=EXCLUDED.eps_estimate, eps_reported=EXCLUDED.eps_reported, release_time=EXCLUDED.release_time, observed_at=now()",
70+ (f"frd:{tk}", e["date"], e.get("eps_estimate"), e.get("eps_reported"), e.get("release_time")))
71+ stats["earnings"] += len(earnings)
72+ stats["tickers"] += 1
73+ done.add(tk)
74+ queue.set_checkpoint(f"job:{job['id']}", "state", {"done": sorted(done)})
75+ queue.progress(job["id"], {**stats, "last": tk})
76+ fr.close()
77+ log.info("fundamentals.done", **stats)
78+ return stats
added services/ingest/src/findata_ingest/tasks/incremental.py +106 −0
@@ -0,0 +1,106 @@
1+"""Incremental sync of an update archive (period=day|week|month) into session_date delta files.
2+
3+Idempotent: the same archive (sha256) is never published twice; a session_date already live is *superseded*
4+(old file retired, new file added, dataset version +1) so re-fetching cannot create duplicates.
5+Checkpoints: dataset:<id> / last_archive, last_vendor_update.
6+"""
7+from __future__ import annotations
8+
9+from collections import defaultdict
10+from datetime import UTC, datetime
11+from typing import Any
12+
13+import pyarrow as pa
14+
15+from findata_core.db import connection, transaction
16+from findata_ingest import queue
17+from findata_ingest.lake import Lake
18+from findata_ingest.tasks.common import (DATASET_ASSET, live_delta_paths_for_dates, record_anomalies, record_quality_report,
19+ record_raw_archive, upsert_coverage)
20+from findata_market_firstrate import FirstRateMarketConnector
21+from findata_schema import Interval
22+from findata_schema.connector import FetchRequest
23+
24+
25+def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
26+ p = job.get("params") or {}
27+ asset = p.get("type", "stock")
28+ period = p.get("period", "day")
29+ timeframe = p.get("timeframe", "1min")
30+ adjustment = p.get("adjustment", "UNADJUSTED")
31+ dataset_id = job.get("dataset_id") or DATASET_ASSET[asset]
32+ run_id = job.get("run_id")
33+ conn_fr = FirstRateMarketConnector()
34+ vendor_last = conn_fr.client.last_update(asset)
35+ ck = queue.get_checkpoint(f"dataset:{dataset_id}", f"last_vendor_update:{period}") or {}
36+ if ck.get("vendor_last_update") == vendor_last.isoformat() and not p.get("force"):
37+ log.info("incremental.skip", reason="vendor not updated since last run", vendor_last_update=str(vendor_last))
38+ return {"skipped": True, "vendor_last_update": vendor_last.isoformat()}
39+
40+ req = FetchRequest(dataset_id=dataset_id, params={"endpoint": "data_file", "type": asset, "period": period,
41+ "timeframe": timeframe, "adjustment": adjustment})
42+ queue.progress(job["id"], {"stage": "download"})
43+ res = conn_fr.fetch_range(req, lake.raw_dir("firstrate", dataset_id))
44+ rel_raw = str(res.path.relative_to(lake.root))
45+ with transaction() as conn:
46+ raw_id, is_new = record_raw_archive(conn, "firstrate", dataset_id, req.params, rel_raw, res.sha256, res.bytes, res.file_count, res.vendor_last_update, run_id)
47+ if not is_new:
48+ res.path.unlink(missing_ok=True) # identical bytes already stored immutably
49+ queue.set_checkpoint(f"dataset:{dataset_id}", f"last_vendor_update:{period}", {"vendor_last_update": vendor_last.isoformat(), "sha256": res.sha256})
50+ log.info("incremental.duplicate_archive", sha256=res.sha256[:12])
51+ return {"skipped": True, "reason": "archive already ingested", "sha256": res.sha256}
52+ log.info("incremental.downloaded", bytes=res.bytes, files=res.file_count, vendor_last_update=str(vendor_last))
53+
54+ # normalize → group rows by session_date (across all symbols)
55+ queue.progress(job["id"], {"stage": "normalize", "bytes": res.bytes})
56+ by_date: dict[Any, list[pa.Table]] = defaultdict(list)
57+ anomalies: list[dict[str, Any]] = []
58+ symbols = 0
59+ raw_rows = 0
60+ dropped = 0
61+ for batch in conn_fr.normalize(res):
62+ rep = conn_fr.validate(batch)
63+ anomalies.extend(rep.anomalies)
64+ symbols += 1
65+ raw_rows += batch.raw_rows
66+ dropped += batch.raw_rows - batch.row_count
67+ t = batch.table
68+ for d in set(t.column("session_date").to_pylist()):
69+ import pyarrow.compute as pc
70+ by_date[d].append(t.filter(pc.equal(t.column("session_date"), pa.scalar(d, pa.date32()))))
71+ if symbols % 1000 == 0:
72+ queue.progress(job["id"], {"stage": "normalize", "symbols": symbols})
73+ if not by_date:
74+ raise RuntimeError("archive produced no rows")
75+
76+ # write one delta file per session_date, then publish atomically (supersede existing dates)
77+ queue.progress(job["id"], {"stage": "write", "sessions": len(by_date)})
78+ interval = Interval({"1min": "1m", "5min": "5m", "30min": "30m", "1hour": "1h", "1day": "1d"}[timeframe])
79+ written = []
80+ tables = {}
81+ for d in sorted(by_date):
82+ t = pa.concat_tables(by_date[d])
83+ tables[d] = t
84+ written.append(lake.write_table(dataset_id, t, "delta", {"session_date": d.isoformat()}))
85+ with connection() as conn:
86+ retire = live_delta_paths_for_dates(conn, dataset_id, list(tables))
87+ pub = lake.publish(dataset_id, written, retire, run_id, note=f"incremental {asset} {period} {timeframe} {adjustment}")
88+ with transaction() as conn:
89+ conn.execute("UPDATE raw_archives SET status='published' WHERE id=%s", (raw_id,))
90+ cov = sum(upsert_coverage(conn, dataset_id, t, interval) for t in tables.values())
91+ record_anomalies(conn, dataset_id, anomalies, run_id)
92+ record_quality_report(conn, dataset_id, run_id, sum(t.num_rows for t in tables.values()), anomalies)
93+ queue.set_checkpoint(f"dataset:{dataset_id}", f"last_vendor_update:{period}", {"vendor_last_update": vendor_last.isoformat(), "sha256": res.sha256})
94+ cp = conn_fr.checkpoint(res, len(written))
95+ queue.set_checkpoint(cp.scope, cp.key, cp.value)
96+ dates = [d.isoformat() for d in sorted(tables)]
97+ # downstream: derived 5-minute bars for these sessions
98+ if interval == Interval.m1 and asset in ("stock", "etf"):
99+ queue.enqueue("derive", dataset_id.replace(".1m.", ".5m."), {"source_dataset": dataset_id, "target_interval": "5m", "dates": dates},
100+ priority=40, dedupe_key=f"derive:{dataset_id}:5m:{dates[0]}:{dates[-1]}:{pub['version']}")
101+ result = {"vendor_last_update": vendor_last.isoformat(), "archive_sha256": res.sha256, "archive_bytes": res.bytes,
102+ "symbols": symbols, "raw_rows": raw_rows, "rows_dropped_unparseable": dropped,
103+ "rows_published": sum(t.num_rows for t in tables.values()), "sessions": dates, "files_added": len(written),
104+ "files_retired": len(retire), "coverage_rows": cov, "anomalies": len(anomalies), **pub}
105+ log.info("incremental.done", **{k: v for k, v in result.items() if k != "sessions"})
106+ return result
added services/ingest/src/findata_ingest/tasks/metadata.py +52 −0
@@ -0,0 +1,52 @@
1+"""Reference data: ticker listings, delisted log, splits and dividends → instruments / corporate_actions."""
2+from __future__ import annotations
3+
4+from typing import Any
5+
6+from findata_core.db import transaction
7+from findata_ingest.lake import Lake
8+from findata_ingest.tasks.common import record_raw_archive
9+from findata_market_firstrate import FirstRateMarketConnector
10+
11+
12+def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
13+ params = job.get("params") or {}
14+ types = params.get("types") or ["stock", "etf"]
15+ conn_fr = FirstRateMarketConnector()
16+ stats: dict[str, Any] = {}
17+ for t in types:
18+ rows = conn_fr.ticker_listing(t)
19+ with transaction() as conn:
20+ for r in rows:
21+ iid = f"firstrate:{t}:{r['symbol']}"
22+ conn.execute(
23+ "INSERT INTO instruments(instrument_id, symbol, name, asset_class, venue, currency, first_date, last_date, status, source_id) "
24+ "VALUES (%s,%s,%s,%s,NULL,'USD',%s,%s,%s,'firstrate') ON CONFLICT (instrument_id) DO UPDATE SET name=EXCLUDED.name, "
25+ "first_date=EXCLUDED.first_date, last_date=EXCLUDED.last_date, status=EXCLUDED.status, updated_at=now()",
26+ (iid, r["symbol"], r["name"], t, r["first_date"], r["last_date"], r["status"]))
27+ if r["symbol"].endswith("-DELISTED"):
28+ base = r["symbol"][: -len("-DELISTED")]
29+ conn.execute("INSERT INTO instrument_aliases(instrument_id, symbol, valid_from, valid_to, source_id, note) VALUES (%s,%s,%s,%s,'firstrate','vendor -DELISTED suffix') "
30+ "ON CONFLICT DO NOTHING", (iid, base, r["first_date"], r["last_date"]))
31+ stats[f"instruments_{t}"] = len(rows)
32+ log.info("metadata.listing", type=t, rows=len(rows))
33+ if "stock" in types:
34+ dl = conn_fr.delisted_log()
35+ with transaction() as conn:
36+ for r in dl:
37+ conn.execute("UPDATE instruments SET status='delisted', last_date=COALESCE(%s, last_date), updated_at=now() WHERE instrument_id=%s",
38+ (r["last_date"], f"firstrate:stock:{r['symbol']}"))
39+ stats["delisted_log"] = len(dl)
40+ for kind in ("splits", "dividends"):
41+ res, rows = conn_fr.meta_actions("stock", kind, lake.raw_dir("firstrate", "corporate_actions.stock"))
42+ with transaction() as conn:
43+ record_raw_archive(conn, "firstrate", "corporate_actions.stock", {"endpoint": "meta_file", "type": "stock", "metafile_type": kind},
44+ str(res.path.relative_to(lake.root)), res.sha256, res.bytes, res.file_count, res.vendor_last_update, job.get("run_id"))
45+ n = 0
46+ for r in rows:
47+ conn.execute("INSERT INTO corporate_actions(instrument_id, action_type, ex_date, value, source_id, ingest_run_id) VALUES (%s,%s,%s,%s,'firstrate',%s) ON CONFLICT DO NOTHING",
48+ (f"firstrate:stock:{r['symbol']}", r["action_type"], r["ex_date"], r["value"], job.get("run_id")))
49+ n += 1
50+ stats[kind] = n
51+ log.info("metadata.actions", kind=kind, rows=n)
52+ return stats
added services/ingest/src/findata_ingest/tasks/quality.py +39 −0
@@ -0,0 +1,39 @@
1+"""Periodic quality/coverage report and garbage collection."""
2+from __future__ import annotations
3+
4+import json
5+from datetime import UTC, datetime, timedelta
6+from typing import Any
7+
8+from findata_core.db import connection, transaction
9+from findata_ingest.lake import Lake
10+
11+
12+def run(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
13+ dataset_id = job.get("dataset_id") or "market_bars.stock.1m.raw"
14+ with connection() as conn:
15+ tot = conn.execute("SELECT count(*) n, sum(bar_count) bars, count(DISTINCT symbol) syms, min(session_date) d0, max(session_date) d1 FROM coverage WHERE dataset_id=%s", (dataset_id,)).fetchone()
16+ comp = conn.execute("SELECT avg(LEAST(1.0, bar_count::float/NULLIF(expected_bars,0))) AS c FROM coverage WHERE dataset_id=%s AND expected_bars>0", (dataset_id,)).fetchone()
17+ anomalies = conn.execute("SELECT code, severity, count(*) n FROM quality_anomalies WHERE dataset_id=%s AND created_at > now() - interval '30 days' GROUP BY 1,2 ORDER BY n DESC", (dataset_id,)).fetchall()
18+ summary = {"dataset_id": dataset_id, "symbol_sessions": tot["n"], "bars": int(tot["bars"] or 0), "symbols": tot["syms"],
19+ "first_session": str(tot["d0"]), "last_session": str(tot["d1"]), "mean_regular_hours_completeness": float(comp["c"] or 0) if comp and comp["c"] is not None else None,
20+ "anomalies_30d": [dict(a) for a in anomalies]}
21+ with transaction() as conn:
22+ conn.execute("INSERT INTO quality_reports(dataset_id, ingest_run_id, rows_checked, anomalies, summary) VALUES (%s,%s,%s,%s,%s)",
23+ (dataset_id, job.get("run_id"), summary["bars"], sum(a["n"] for a in anomalies), json.dumps(summary, default=str)))
24+ return summary
25+
26+
27+def gc(job: dict[str, Any], lake: Lake, log) -> dict[str, Any]:
28+ n = lake.gc_retired(7)
29+ removed = 0
30+ with transaction() as conn:
31+ rows = conn.execute("UPDATE exports SET status='expired' WHERE status='ready' AND expires_at < now() RETURNING path").fetchall()
32+ for r in rows:
33+ if r["path"]:
34+ p = lake.abs(r["path"])
35+ if p.exists():
36+ p.unlink()
37+ removed += 1
38+ conn.execute("DELETE FROM sessions WHERE expires_at < now() - interval '7 days'")
39+ return {"retired_files_deleted": n, "exports_expired": len(rows), "export_files_removed": removed}
added services/ingest/src/findata_ingest/worker.py +89 −0
@@ -0,0 +1,89 @@
1+"""Single ingest worker: claims jobs from Postgres, holds the lake writer lock, runs the task registry.
2+Also hosts the scheduler loop (idempotent enqueue with dedupe keys) so there is exactly one writer process."""
3+from __future__ import annotations
4+
5+import signal
6+import time
7+import traceback
8+from datetime import UTC, datetime
9+from zoneinfo import ZoneInfo
10+
11+from findata_core.config import get_settings
12+from findata_core.logging import configure_logging, get_logger
13+from findata_ingest import queue
14+from findata_ingest.lake import Lake, WriterLock
15+from findata_ingest.tasks import REGISTRY
16+
17+ET = ZoneInfo("America/New_York")
18+_stop = False
19+
20+
21+def _handle(sig, frame): # noqa: ANN001
22+ global _stop
23+ _stop = True
24+
25+
26+def schedule_tick(log) -> None:
27+ """Enqueue recurring jobs (dedupe keys make this idempotent). Times in America/New_York."""
28+ now = datetime.now(ET)
29+ day = now.date().isoformat()
30+ # vendor daily update lands ~23:30–03:00 ET → poll from 02:00 every 30 min (incremental skips when vendor unchanged)
31+ if now.weekday() < 6 and (now.hour >= 2 or now.hour < 1):
32+ queue.enqueue("incremental", "market_bars.stock.1m.raw", {"type": "stock", "period": "day"}, priority=20,
33+ dedupe_key=f"incremental:stock:day:{day}:{now.hour}:{now.minute // 30}")
34+ if now.weekday() == 5 and now.hour == 6: # Saturday 06:00 ET — week archive repairs any missed day
35+ queue.enqueue("incremental", "market_bars.stock.1m.raw", {"type": "stock", "period": "week", "force": True}, priority=25,
36+ dedupe_key=f"incremental:stock:week:{day}")
37+ if now.weekday() == 6 and now.hour == 7:
38+ queue.enqueue("metadata", None, {"types": ["stock", "etf"]}, priority=30, dedupe_key=f"metadata:{day}")
39+ queue.enqueue("compaction", "market_bars.stock.1m.raw", {}, priority=60, dedupe_key=f"compaction:1m:{day}")
40+ queue.enqueue("compaction", "market_bars.stock.5m.raw", {}, priority=61, dedupe_key=f"compaction:5m:{day}")
41+ if now.hour == 5 and now.minute < 30:
42+ queue.enqueue("fundamentals", "fundamentals.financials", {"tracked": True}, priority=50, dedupe_key=f"fundamentals:{day}")
43+ if now.hour == 4 and now.minute < 30 and get_settings().backup_target:
44+ queue.enqueue("backup", None, {}, priority=70, dedupe_key=f"backup:{day}")
45+ if now.hour == 6 and now.minute < 30:
46+ queue.enqueue("quality", "market_bars.stock.1m.raw", {}, priority=80, dedupe_key=f"quality:{day}")
47+ queue.enqueue("gc", None, {}, priority=90, dedupe_key=f"gc:{day}")
48+
49+
50+def main(once: bool = False, kinds: list[str] | None = None, with_scheduler: bool = True) -> None:
51+ configure_logging()
52+ log = get_logger("worker")
53+ signal.signal(signal.SIGTERM, _handle)
54+ signal.signal(signal.SIGINT, _handle)
55+ lake = Lake()
56+ requeued = queue.requeue_stale()
57+ if requeued:
58+ log.warning("worker.requeued_stale", n=requeued)
59+ last_tick = 0.0
60+ with WriterLock(lake.root):
61+ log.info("worker.start", lake=str(lake.root), free_gb=round(lake.free_gb(), 1))
62+ while not _stop:
63+ if with_scheduler and time.monotonic() - last_tick > 300:
64+ try:
65+ schedule_tick(log)
66+ except Exception as e: # noqa: BLE001
67+ log.error("scheduler.error", error=str(e))
68+ last_tick = time.monotonic()
69+ job = queue.claim_next(kinds)
70+ if job is None:
71+ if once:
72+ break
73+ time.sleep(5)
74+ continue
75+ jl = log.bind(job_id=str(job["id"]), kind=job["kind"], dataset=job.get("dataset_id"), attempt=job["attempts"])
76+ jl.info("job.start")
77+ t0 = time.monotonic()
78+ try:
79+ fn = REGISTRY[job["kind"]]
80+ result = fn(job, lake, jl)
81+ queue.finish(job, True, {**(result or {}), "seconds": round(time.monotonic() - t0, 1)})
82+ jl.info("job.done", seconds=round(time.monotonic() - t0, 1))
83+ except Exception as e: # noqa: BLE001
84+ err = f"{type(e).__name__}: {e}\n{traceback.format_exc()[-1500:]}"
85+ queue.finish(job, False, error=err)
86+ jl.error("job.failed", error=str(e)[:300], seconds=round(time.monotonic() - t0, 1))
87+ if once:
88+ break
89+ log.info("worker.stop")
added services/quality/pyproject.toml +12 −0
@@ -0,0 +1,12 @@
1+[project]
2+name = "findata-quality"
3+version = "0.1.0"
4+requires-python = ">=3.12"
5+dependencies = ["findata-schema", "pyarrow>=18.0", "polars>=1.12", "exchange-calendars>=4.5"]
6+[tool.uv.sources]
7+findata-schema = { workspace = true }
8+[build-system]
9+requires = ["hatchling"]
10+build-backend = "hatchling.build"
11+[tool.hatch.build.targets.wheel]
12+packages = ["src/findata_quality"]
added services/quality/src/findata_quality/__init__.py +3 −0
@@ -0,0 +1,3 @@
1+from findata_quality.calendar import SessionCalendar, ET # noqa: F401
2+from findata_quality.validators import validate_bars # noqa: F401
3+from findata_quality.aggregate import aggregate_bars # noqa: F401
added services/quality/src/findata_quality/aggregate.py +32 −0
@@ -0,0 +1,32 @@
1+"""Deterministic bar aggregation (1m → 5m/30m/1h). Buckets are aligned on the ET wall clock so that the
2+09:30 bucket covers 09:30:00–09:34:59 regardless of DST. Never fills empty buckets."""
3+from __future__ import annotations
4+
5+import polars as pl
6+import pyarrow as pa
7+
8+from findata_schema import BARS_SCHEMA, INTERVAL_SECONDS, Interval
9+
10+
11+def aggregate_bars(table: pa.Table, target: Interval, source: Interval = Interval.m1) -> pa.Table:
12+ if INTERVAL_SECONDS[target] % INTERVAL_SECONDS[source] != 0:
13+ raise ValueError(f"{target} is not a multiple of {source}")
14+ df = pl.from_arrow(table)
15+ if df.height == 0:
16+ return table.cast(BARS_SCHEMA)
17+ every = f"{INTERVAL_SECONDS[target]}s"
18+ # Truncate in local exchange time, then convert back: bucket boundaries follow the wall clock.
19+ local = pl.col("timestamp_utc").dt.convert_time_zone("America/New_York")
20+ df = df.with_columns(local.dt.truncate(every).dt.convert_time_zone("UTC").alias("bucket"))
21+ df = df.sort(["symbol_as_reported", "timestamp_utc"])
22+ agg = (df.group_by(["instrument_id", "symbol_as_reported", "venue", "asset_class", "session_date", "currency",
23+ "adjustment_type", "source_id", "bucket"], maintain_order=True)
24+ .agg(pl.col("open").first(), pl.col("high").max(), pl.col("low").min(), pl.col("close").last(),
25+ pl.col("volume").sum(), pl.len().cast(pl.Int32).alias("trade_count"),
26+ ((pl.col("close") * pl.col("volume")).sum() / pl.col("volume").sum()).alias("vwap"),
27+ pl.col("ingested_at").max(), pl.col("quality_flags").list.explode(keep_nulls=False, empty_as_null=False).drop_nulls().unique().alias("quality_flags"),
28+ pl.col("revision_id").max()))
29+ agg = agg.rename({"bucket": "timestamp_utc"}).with_columns(pl.lit(target.value).alias("interval"))
30+ agg = agg.with_columns(pl.when(pl.col("volume") > 0).then(pl.col("vwap")).otherwise(None).alias("vwap"))
31+ out = agg.select(BARS_SCHEMA.names).to_arrow()
32+ return out.cast(BARS_SCHEMA)
added services/quality/src/findata_quality/calendar.py +65 −0
@@ -0,0 +1,65 @@
1+"""Market session calendar for US equities (XNYS) with extended hours and DST handled via zoneinfo.
2+
3+Policy (documented in docs/DATA_DICTIONARY.md):
4+- Regular session 09:30–16:00 ET; vendor extended hours 04:00–20:00 ET (last 1-minute bar opens 19:59).
5+- Early closes (e.g. day after Thanksgiving, Christmas Eve) come from exchange_calendars; regular-hours
6+ expectations use the calendar's actual close for that date.
7+- `expected_regular_bars(date)` = minutes between open and close (inclusive of the 16:00 bar which the vendor
8+ emits as the closing auction print) — used for coverage %, never to manufacture bars.
9+"""
10+from __future__ import annotations
11+
12+from datetime import date, datetime, time, timedelta
13+from functools import lru_cache
14+from zoneinfo import ZoneInfo
15+
16+import exchange_calendars as xcals
17+import pandas as pd
18+
19+ET = ZoneInfo("America/New_York")
20+UTC = ZoneInfo("UTC")
21+
22+EXT_OPEN = time(4, 0)
23+EXT_CLOSE = time(20, 0) # exclusive
24+
25+
26+class SessionCalendar:
27+ def __init__(self, name: str = "XNYS", start: str = "1999-01-01", end: str = "2030-12-31"):
28+ self.name = name
29+ self.cal = xcals.get_calendar(name, start=start, end=end)
30+ self._sessions = set(d.date() for d in self.cal.sessions)
31+
32+ def is_session(self, d: date) -> bool:
33+ return d in self._sessions
34+
35+ def regular_open_close(self, d: date) -> tuple[datetime, datetime]:
36+ """Open/close as timezone-aware ET datetimes for session `d` (handles early closes and DST)."""
37+ ts = pd.Timestamp(d)
38+ o = self.cal.session_open(ts).tz_convert("America/New_York").to_pydatetime()
39+ c = self.cal.session_close(ts).tz_convert("America/New_York").to_pydatetime()
40+ return o, c
41+
42+ def expected_regular_bars(self, d: date, interval_seconds: int = 60) -> int:
43+ if not self.is_session(d):
44+ return 0
45+ o, c = self.regular_open_close(d)
46+ return int((c - o).total_seconds() // interval_seconds) + 1 # + closing print bar
47+
48+ def extended_bounds_utc(self, d: date) -> tuple[datetime, datetime]:
49+ start = datetime.combine(d, EXT_OPEN, tzinfo=ET).astimezone(UTC)
50+ end = datetime.combine(d, EXT_CLOSE, tzinfo=ET).astimezone(UTC)
51+ return start, end
52+
53+ def sessions_between(self, start: date, end: date) -> list[date]:
54+ return [d for d in sorted(self._sessions) if start <= d <= end]
55+
56+ def previous_session(self, d: date) -> date:
57+ cur = d - timedelta(days=1)
58+ while cur not in self._sessions:
59+ cur -= timedelta(days=1)
60+ return cur
61+
62+
63+@lru_cache
64+def xnys() -> SessionCalendar:
65+ return SessionCalendar("XNYS")
added services/quality/src/findata_quality/validators.py +96 −0
@@ -0,0 +1,96 @@
1+"""Row-level and session-level validation of canonical bars. Flags, never mutates prices."""
2+from __future__ import annotations
3+
4+from datetime import date
5+from typing import Any
6+
7+import polars as pl
8+import pyarrow as pa
9+
10+from findata_quality.calendar import SessionCalendar, xnys
11+from findata_schema import INTERVAL_SECONDS, Interval, QualityFlag
12+
13+PRICE_JUMP_THRESHOLD = 0.25 # 25 % between consecutive 1-minute closes inside a session (flag only)
14+
15+
16+def validate_bars(table: pa.Table, interval: Interval, calendar: SessionCalendar | None = None,
17+ check_sessions: bool = True) -> tuple[pa.Table, list[dict[str, Any]]]:
18+ """Return (table with `quality_flags` filled, anomalies list). Input must follow BARS_SCHEMA columns."""
19+ cal = calendar or xnys()
20+ step = INTERVAL_SECONDS[interval]
21+ df = pl.from_arrow(table)
22+ if df.height == 0:
23+ return table, []
24+ df = df.sort(["symbol_as_reported", "timestamp_utc"])
25+ ts_s = pl.col("timestamp_utc").dt.epoch("s")
26+ flags: list[pl.Expr] = [
27+ pl.when((pl.col("high") < pl.max_horizontal("open", "close")) | (pl.col("low") > pl.min_horizontal("open", "close"))
28+ | (pl.col("high") < pl.col("low"))).then(pl.lit(QualityFlag.ohlc_inconsistent.value)),
29+ pl.when(pl.min_horizontal("open", "high", "low", "close") <= 0).then(pl.lit(QualityFlag.nonpositive_price.value)),
30+ pl.when(pl.col("volume") < 0).then(pl.lit(QualityFlag.negative_volume.value)),
31+ pl.when(pl.col("volume") == 0).then(pl.lit(QualityFlag.zero_volume.value)),
32+ pl.when(pl.col("volume") != pl.col("volume").floor()).then(pl.lit(QualityFlag.fractional_volume.value)),
33+ pl.when((ts_s % step) != 0).then(pl.lit(QualityFlag.off_interval.value)),
34+ pl.when(pl.struct(["symbol_as_reported", "timestamp_utc"]).is_duplicated()).then(pl.lit(QualityFlag.duplicate.value)),
35+ ]
36+ if interval != Interval.d1:
37+ prev_close = pl.col("close").shift(1).over(["symbol_as_reported", "session_date"])
38+ flags.append(pl.when(((pl.col("close") / prev_close) - 1).abs() > PRICE_JUMP_THRESHOLD)
39+ .then(pl.lit(QualityFlag.price_jump.value)))
40+ if check_sessions and interval != Interval.d1:
41+ # outside extended hours / non trading day: computed per distinct session_date
42+ dates = df.get_column("session_date").unique().to_list()
43+ bounds = {d: cal.extended_bounds_utc(d) for d in dates}
44+ non_sessions = [d for d in dates if not cal.is_session(d)]
45+ lo = pl.col("session_date").replace_strict({d: b[0] for d, b in bounds.items()}, return_dtype=pl.Datetime("us", "UTC"))
46+ hi = pl.col("session_date").replace_strict({d: b[1] for d, b in bounds.items()}, return_dtype=pl.Datetime("us", "UTC"))
47+ flags.append(pl.when((pl.col("timestamp_utc") < lo) | (pl.col("timestamp_utc") >= hi))
48+ .then(pl.lit(QualityFlag.outside_session.value)))
49+ if non_sessions:
50+ flags.append(pl.when(pl.col("session_date").is_in(non_sessions)).then(pl.lit(QualityFlag.non_trading_day.value)))
51+ df = df.with_columns(
52+ pl.concat_list([f.otherwise(None) for f in flags]).list.drop_nulls().alias("quality_flags")
53+ )
54+ anomalies = _summarize(df)
55+ out = df.to_arrow()
56+ # cast back to the canonical schema (polars may widen list/int types)
57+ out = out.select(table.column_names).cast(table.schema)
58+ return out, anomalies
59+
60+
61+def _summarize(df: pl.DataFrame) -> list[dict[str, Any]]:
62+ flagged = df.filter(pl.col("quality_flags").list.len() > 0)
63+ if flagged.height == 0:
64+ return []
65+ agg = (flagged.explode("quality_flags")
66+ .group_by(["symbol_as_reported", "session_date", "quality_flags"]).agg(pl.len().alias("n"))
67+ .sort(["symbol_as_reported", "session_date", "quality_flags"]))
68+ sev = {QualityFlag.zero_volume.value: "info", QualityFlag.fractional_volume.value: "info",
69+ QualityFlag.price_jump.value: "warning", QualityFlag.outside_session.value: "warning",
70+ QualityFlag.gap.value: "info"}
71+ return [{"symbol": r["symbol_as_reported"], "session_date": r["session_date"], "code": r["quality_flags"],
72+ "severity": sev.get(r["quality_flags"], "error"), "detail": {"rows": r["n"]}}
73+ for r in agg.iter_rows(named=True)]
74+
75+
76+def session_coverage(table: pa.Table, interval: Interval, calendar: SessionCalendar | None = None) -> list[dict[str, Any]]:
77+ """Per (symbol, session_date): bar count, first/last ts, expected regular-hours bars, gap flags."""
78+ cal = calendar or xnys()
79+ step = INTERVAL_SECONDS[interval]
80+ df = pl.from_arrow(table)
81+ if df.height == 0:
82+ return []
83+ g = (df.group_by(["symbol_as_reported", "session_date"])
84+ .agg(pl.len().alias("bar_count"), pl.col("timestamp_utc").min().alias("first_ts"),
85+ pl.col("timestamp_utc").max().alias("last_ts"))
86+ .sort(["symbol_as_reported", "session_date"]))
87+ rows = []
88+ for r in g.iter_rows(named=True):
89+ d: date = r["session_date"]
90+ exp = cal.expected_regular_bars(d, step) if interval != Interval.d1 else 1
91+ flags = []
92+ if not cal.is_session(d):
93+ flags.append(QualityFlag.non_trading_day.value)
94+ rows.append({"symbol": r["symbol_as_reported"], "session_date": d, "bar_count": r["bar_count"],
95+ "first_ts": r["first_ts"], "last_ts": r["last_ts"], "expected_bars": exp, "flags": flags})
96+ return rows
added tests/__init__.py +0 −0
added tests/conftest.py +158 −0
@@ -0,0 +1,158 @@
1+"""Test harness: isolated lake in a temp dir, a fresh `findata_test` PostgreSQL database (localhost), and a mocked
2+FirstRate transport built from the licensed fixture excerpts (one session, three tickers). No real vendor calls."""
3+from __future__ import annotations
4+
5+import io
6+import os
7+import subprocess
8+import tempfile
9+import zipfile
10+from pathlib import Path
11+
12+TEST_USERID = "test-userid-SECRET-9f8e7d6c"
13+ROOT = Path(__file__).resolve().parents[1]
14+LAKE_DIR = Path(os.environ.get("FINDATA_LAKE") or tempfile.mkdtemp(prefix="findata-lake-"))
15+os.environ.setdefault("FINDATA_LAKE", str(LAKE_DIR))
16+os.environ.update({
17+ "FINDATA_ENV": "test",
18+ "FINDATA_ROOT": str(ROOT),
19+ "FINDATA_DATABASE_URL": os.environ.get("FINDATA_TEST_DATABASE_URL", "postgresql://127.0.0.1:5432/findata_test"),
20+ "FINDATA_SECRET_KEY": "test-secret-key-test-secret-key-0123456789",
21+ "FINDATA_PUBLIC_URL": "http://testserver",
22+ "FRD_USERID": TEST_USERID,
23+ "SEC_EDGAR_USER_AGENT": "",
24+ "FINDATA_LAKE_HARD_MIN_FREE_GB": "1",
25+ "FINDATA_BACKUP_TARGET": "",
26+})
27+
28+import httpx # noqa: E402
29+import pytest # noqa: E402
30+
31+FIX = ROOT / "tests" / "fixtures"
32+
33+
34+def _reset_db() -> None:
35+ url = os.environ["FINDATA_DATABASE_URL"]
36+ subprocess.run(["psql", url, "-q", "-c", "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"], check=True, capture_output=True)
37+ from findata_core.db import close_pool, migrate
38+ close_pool()
39+ migrate(verbose=False)
40+
41+
42+@pytest.fixture(scope="session", autouse=True)
43+def database():
44+ _reset_db()
45+ yield
46+ from findata_core.db import close_pool
47+ close_pool()
48+
49+
50+def build_update_zip(tickers: list[str] | None = None, mutate: dict[str, str] | None = None, period: str = "day") -> bytes:
51+ """Zip shaped like the vendor's `stock_update_day_1min_UNADJUSTED.zip`."""
52+ tickers = tickers or ["AAPL", "A", "AAL"]
53+ buf = io.BytesIO()
54+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
55+ for t in tickers:
56+ data = (FIX / "firstrate" / f"{t}_day_1min_UNADJUSTED.txt").read_text()
57+ if mutate and t in mutate:
58+ data = mutate[t]
59+ zf.writestr(zipfile.ZipInfo(f"{t}_{period}_1min_UNADJUSTED.txt", date_time=(2026, 10, 30, 18, 6, 0)), data, compress_type=zipfile.ZIP_DEFLATED)
60+ return buf.getvalue()
61+
62+
63+def build_full_zip(tickers: list[str]) -> bytes:
64+ buf = io.BytesIO()
65+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
66+ for t in tickers:
67+ zf.writestr(zipfile.ZipInfo(f"{t}_1min.txt", date_time=(2026, 10, 30, 18, 6, 0)), (FIX / "firstrate" / f"{t}_day_1min_UNADJUSTED.txt").read_text(), compress_type=zipfile.ZIP_DEFLATED)
68+ return buf.getvalue()
69+
70+
71+class VendorMock:
72+ """httpx.MockTransport handler emulating FirstRate endpoints; records sanitized calls; asserts auth."""
73+
74+ def __init__(self):
75+ self.calls: list[str] = []
76+ self.last_update = "2026-10-30"
77+ self.archive_override: bytes | None = None
78+ self.fail_next_download = 0
79+
80+ def __call__(self, request: httpx.Request) -> httpx.Response:
81+ url = request.url
82+ assert url.params.get("userid") == TEST_USERID, "vendor calls must carry the userid"
83+ self.calls.append(f"{url.path}?{'&'.join(f'{k}={v}' for k, v in sorted(url.params.items()) if k != 'userid')}")
84+ if url.path == "/api/last_update":
85+ return httpx.Response(200, text=self.last_update if url.params.get("type") != "fx" else self.last_update.replace("-", ""))
86+ if url.path == "/api/ticker_listing":
87+ t = url.params.get("type")
88+ body = "Ticker,Name,First Date,Last Date\n"
89+ if t == "stock":
90+ body += "A,Agilent Technologies Inc,2000-01-04,2026-10-30\nAAL,American Airlines,2005-09-27,2026-10-30\nAAPL,Apple Inc,2000-01-03,2026-10-30\nTWTR-DELISTED,Twitter Inc,2013-11-07,2022-10-27\n"
91+ else:
92+ body += "SPY,SPDR S&P 500,2000-01-03,2026-10-30\n"
93+ return httpx.Response(200, text=body)
94+ if url.path == "/api/update_log":
95+ return httpx.Response(200, text="Ticker,Last Date\nTWTR,2022-10-27\n")
96+ if url.path == "/api/meta_file":
97+ kind = url.params.get("metafile_type")
98+ buf = io.BytesIO()
99+ with zipfile.ZipFile(buf, "w") as zf:
100+ if kind == "splits":
101+ zf.writestr("AAPL.txt", "2020-08-31,4\n2014-06-09,7\n2005-02-28,2\n2000-06-21,2\n")
102+ zf.writestr("_splits_readme.txt", "readme")
103+ else:
104+ zf.writestr("AAPL_divs.txt", "2026-08-10,0.27\n2026-05-11,0.27\n")
105+ return httpx.Response(302, headers={"location": "https://cdn.example/meta.zip"}) if False else httpx.Response(200, content=buf.getvalue(), headers={"content-type": "application/zip"})
106+ if url.path == "/api/data_file":
107+ if self.fail_next_download:
108+ self.fail_next_download -= 1
109+ return httpx.Response(503, text="busy")
110+ if url.params.get("period") == "full":
111+ letter = url.params.get("ticker_range")
112+ tick = [t for t in ["A", "AAL", "AAPL"] if t.startswith(letter)]
113+ return httpx.Response(200, content=build_full_zip(tick), headers={"content-type": "application/zip"})
114+ body = self.archive_override or build_update_zip(period=url.params.get("period", "day"))
115+ # emulate the CDN redirect
116+ if url.host == "firstratedata.com":
117+ return httpx.Response(302, headers={"location": f"https://x01.frd-data.com/stock_update_{url.params.get('period')}_1min_UNADJUSTED_tok.zip?userid={TEST_USERID}"})
118+ return httpx.Response(200, content=body, headers={"content-type": "application/zip"})
119+ if url.host == "x01.frd-data.com":
120+ body = self.archive_override or build_update_zip()
121+ return httpx.Response(200, content=body, headers={"content-type": "application/zip"})
122+ if url.path.startswith("/api-f/v1/financials/all/"):
123+ p = (FIX / "fundamentals" / "financials_all_AAPL_quarterly.json").read_text()
124+ return httpx.Response(200, text=p, headers={"content-type": "application/json"})
125+ if url.path.startswith("/api-f/v1/earnings_release/t/"):
126+ return httpx.Response(200, text=(FIX / "fundamentals" / "earnings_AAPL.json").read_text(), headers={"content-type": "application/json"})
127+ return httpx.Response(404, text=f"no route {url.path} userid={url.params.get('userid')}")
128+
129+
130+@pytest.fixture
131+def vendor(monkeypatch):
132+ vm = VendorMock()
133+ import findata_market_firstrate.client as C
134+ import findata_fundamentals.firstrate as F
135+ monkeypatch.setattr(C, "DEFAULT_TRANSPORT", httpx.MockTransport(vm))
136+ monkeypatch.setattr(F, "DEFAULT_TRANSPORT", httpx.MockTransport(vm))
137+ return vm
138+
139+
140+@pytest.fixture
141+def lake():
142+ from findata_ingest.lake import Lake
143+ return Lake(LAKE_DIR)
144+
145+
146+class Log:
147+ def __getattr__(self, name):
148+ def f(*a, **k):
149+ return None
150+ return f
151+
152+ def bind(self, **k):
153+ return self
154+
155+
156+@pytest.fixture
157+def log():
158+ return Log()
added tests/fixtures/firstrate/AAL_day_1min_UNADJUSTED.txt +554 −0
@@ -0,0 +1,554 @@
1+2026-10-30 04:00:00,12.6,12.67,12.6,12.67,6326
2+2026-10-30 04:01:00,12.67,12.68,12.59,12.6,14638
3+2026-10-30 04:02:00,12.66,12.66,12.66,12.66,1345
4+2026-10-30 04:03:00,12.62,12.68,12.6095,12.65,5984
5+2026-10-30 04:04:00,12.67,12.67,12.67,12.67,800
6+2026-10-30 04:27:00,12.6077,12.6077,12.6077,12.6077,700
7+2026-10-30 04:46:00,12.67,12.68,12.67,12.68,1498
8+2026-10-30 04:51:00,12.68,12.68,12.68,12.68,195
9+2026-10-30 04:54:00,12.72,12.72,12.72,12.72,300
10+2026-10-30 05:10:00,12.69,12.69,12.69,12.69,1000
11+2026-10-30 05:45:00,12.65,12.65,12.64,12.64,2076
12+2026-10-30 05:53:00,12.62,12.62,12.62,12.62,1090
13+2026-10-30 06:02:00,12.64,12.64,12.64,12.64,632
14+2026-10-30 06:03:00,12.62,12.62,12.59,12.6,9694
15+2026-10-30 06:15:00,12.5799,12.5799,12.5799,12.5799,536
16+2026-10-30 06:28:00,12.61,12.61,12.61,12.61,1313
17+2026-10-30 06:39:00,12.59,12.59,12.59,12.59,331
18+2026-10-30 06:48:00,12.5748,12.5748,12.5748,12.5748,120
19+2026-10-30 06:49:00,12.59,12.59,12.59,12.59,100
20+2026-10-30 06:50:00,12.59,12.59,12.59,12.59,100
21+2026-10-30 06:51:00,12.58,12.58,12.58,12.58,239
22+2026-10-30 07:00:00,12.58,12.595,12.58,12.595,655
23+2026-10-30 07:02:00,12.595,12.595,12.595,12.595,150
24+2026-10-30 07:03:00,12.5846,12.5846,12.5846,12.5846,500
25+2026-10-30 07:06:00,12.5885,12.5885,12.5815,12.5815,2500
26+2026-10-30 07:07:00,12.585,12.585,12.58,12.58,200
27+2026-10-30 07:08:00,12.595,12.595,12.595,12.595,1500
28+2026-10-30 07:09:00,12.6054,12.6054,12.6054,12.6054,100
29+2026-10-30 07:14:00,12.6,12.6,12.6,12.6,1940
30+2026-10-30 07:21:00,12.61,12.61,12.61,12.61,100
31+2026-10-30 07:23:00,12.5901,12.6,12.5901,12.6,1100
32+2026-10-30 07:26:00,12.6,12.6,12.6,12.6,1000
33+2026-10-30 07:27:00,12.59,12.59,12.59,12.59,955
34+2026-10-30 07:33:00,12.6,12.6,12.6,12.6,797
35+2026-10-30 07:34:00,12.59,12.59,12.59,12.59,120
36+2026-10-30 07:35:00,12.595,12.595,12.595,12.595,2375
37+2026-10-30 07:36:00,12.58,12.58,12.58,12.58,100
38+2026-10-30 07:37:00,12.6,12.6,12.6,12.6,300
39+2026-10-30 07:40:00,12.58,12.58,12.57,12.57,9937
40+2026-10-30 07:42:00,12.57,12.57,12.56,12.56,4043
41+2026-10-30 07:43:00,12.56,12.57,12.56,12.57,1038
42+2026-10-30 07:46:00,12.61,12.61,12.61,12.61,350
43+2026-10-30 07:56:00,12.55,12.55,12.55,12.55,2000
44+2026-10-30 08:03:00,12.57,12.57,12.57,12.57,2500
45+2026-10-30 08:04:00,12.57,12.57,12.57,12.57,377
46+2026-10-30 08:05:00,12.5505,12.56,12.5505,12.56,250
47+2026-10-30 08:10:00,12.57,12.57,12.57,12.57,2000
48+2026-10-30 08:14:00,12.55,12.55,12.55,12.55,10000
49+2026-10-30 08:15:00,12.55,12.55,12.55,12.55,3169
50+2026-10-30 08:16:00,12.53,12.53,12.53,12.53,250
51+2026-10-30 08:17:00,12.53,12.53,12.52,12.52,1147
52+2026-10-30 08:20:00,12.56,12.56,12.53,12.53,972
53+2026-10-30 08:22:00,12.5798,12.5798,12.5798,12.5798,100
54+2026-10-30 08:23:00,12.55,12.55,12.55,12.55,150
55+2026-10-30 08:25:00,12.5305,12.5305,12.5305,12.5305,399
56+2026-10-30 08:27:00,12.5755,12.5755,12.5755,12.5755,300
57+2026-10-30 08:28:00,12.57,12.57,12.57,12.57,500
58+2026-10-30 08:33:00,12.57,12.5795,12.57,12.5795,976
59+2026-10-30 08:34:00,12.56,12.56,12.56,12.56,100
60+2026-10-30 08:37:00,12.55,12.55,12.55,12.55,100
61+2026-10-30 08:39:00,12.57,12.58,12.57,12.58,400
62+2026-10-30 08:41:00,12.58,12.58,12.58,12.58,1000
63+2026-10-30 08:44:00,12.5362,12.5362,12.5362,12.5362,100
64+2026-10-30 08:46:00,12.56,12.57,12.5412,12.5412,3000
65+2026-10-30 08:47:00,12.5678,12.57,12.5635,12.5635,2200
66+2026-10-30 08:48:00,12.5438,12.5438,12.5438,12.5438,100
67+2026-10-30 08:49:00,12.57,12.57,12.57,12.57,1000
68+2026-10-30 08:51:00,12.55,12.55,12.52,12.52,29858
69+2026-10-30 08:54:00,12.5753,12.5753,12.5753,12.5753,125
70+2026-10-30 08:57:00,12.5519,12.5519,12.5519,12.5519,400
71+2026-10-30 08:58:00,12.57,12.575,12.56,12.56,5382
72+2026-10-30 08:59:00,12.6,12.6,12.6,12.6,500
73+2026-10-30 09:00:00,12.59,12.59,12.59,12.59,114
74+2026-10-30 09:01:00,12.5782,12.5782,12.5782,12.5782,200
75+2026-10-30 09:08:00,12.66,12.66,12.66,12.66,1000
76+2026-10-30 09:09:00,12.6,12.6,12.58,12.58,842
77+2026-10-30 09:11:00,12.6461,12.6461,12.6,12.6097,540
78+2026-10-30 09:14:00,12.59,12.59,12.59,12.59,200
79+2026-10-30 09:15:00,12.59,12.59,12.59,12.59,446
80+2026-10-30 09:17:00,12.5907,12.5907,12.5907,12.5907,1500
81+2026-10-30 09:19:00,12.59,12.59,12.59,12.59,100
82+2026-10-30 09:20:00,12.5905,12.5905,12.5905,12.5905,2000
83+2026-10-30 09:21:00,12.5844,12.59,12.5844,12.59,2200
84+2026-10-30 09:23:00,12.6008,12.6008,12.6008,12.6008,1000
85+2026-10-30 09:26:00,12.5908,12.5908,12.59,12.59,8050
86+2026-10-30 09:30:00,12.64,12.64,12.565,12.5869,312475
87+2026-10-30 09:31:00,12.59,12.615,12.5404,12.59,126137
88+2026-10-30 09:32:00,12.595,12.62,12.5906,12.595,82323
89+2026-10-30 09:33:00,12.595,12.595,12.55,12.55,60686
90+2026-10-30 09:34:00,12.56,12.61,12.56,12.585,50571
91+2026-10-30 09:35:00,12.585,12.615,12.58,12.59,48462
92+2026-10-30 09:36:00,12.6,12.6,12.565,12.575,45302
93+2026-10-30 09:37:00,12.576,12.6,12.56,12.6,85717
94+2026-10-30 09:38:00,12.605,12.615,12.5901,12.5901,63026
95+2026-10-30 09:39:00,12.6,12.6299,12.6,12.615,83049
96+2026-10-30 09:40:00,12.6199,12.635,12.585,12.588,95944
97+2026-10-30 09:41:00,12.58,12.615,12.58,12.6101,23249
98+2026-10-30 09:42:00,12.61,12.62,12.59,12.59,56949
99+2026-10-30 09:43:00,12.59,12.6,12.5807,12.59,24447
100+2026-10-30 09:44:00,12.59,12.5999,12.575,12.58,26380
101+2026-10-30 09:45:00,12.585,12.595,12.575,12.595,51091
102+2026-10-30 09:46:00,12.6,12.6162,12.58,12.585,25159
103+2026-10-30 09:47:00,12.585,12.59,12.55,12.555,73495
104+2026-10-30 09:48:00,12.55,12.555,12.5,12.51,103158
105+2026-10-30 09:49:00,12.505,12.515,12.46,12.46,90746
106+2026-10-30 09:50:00,12.4629,12.48,12.45,12.45,83953
107+2026-10-30 09:51:00,12.45,12.475,12.43,12.465,124267
108+2026-10-30 09:52:00,12.4641,12.48,12.44,12.45,111400
109+2026-10-30 09:53:00,12.45,12.4599,12.435,12.44,43499
110+2026-10-30 09:54:00,12.4369,12.4799,12.4369,12.455,120719
111+2026-10-30 09:55:00,12.455,12.4669,12.455,12.455,58941
112+2026-10-30 09:56:00,12.455,12.48,12.45,12.475,154597
113+2026-10-30 09:57:00,12.475,12.475,12.44,12.465,50563
114+2026-10-30 09:58:00,12.46,12.467,12.45,12.46,39496
115+2026-10-30 09:59:00,12.45,12.465,12.45,12.45,73910
116+2026-10-30 10:00:00,12.4501,12.4777,12.445,12.465,84117
117+2026-10-30 10:01:00,12.465,12.5,12.46,12.475,243643
118+2026-10-30 10:02:00,12.47,12.475,12.445,12.4468,115598
119+2026-10-30 10:03:00,12.44,12.46,12.44,12.4501,108332
120+2026-10-30 10:04:00,12.45,12.4599,12.425,12.435,78280
121+2026-10-30 10:05:00,12.43,12.44,12.42,12.42,82260
122+2026-10-30 10:06:00,12.425,12.43,12.41,12.415,292032
123+2026-10-30 10:07:00,12.415,12.4199,12.385,12.395,80506
124+2026-10-30 10:08:00,12.3999,12.4,12.36,12.3862,226015
125+2026-10-30 10:09:00,12.381,12.3851,12.37,12.38,46281
126+2026-10-30 10:10:00,12.375,12.39,12.365,12.365,46951
127+2026-10-30 10:11:00,12.365,12.3796,12.34,12.355,131996
128+2026-10-30 10:12:00,12.355,12.36,12.323,12.323,220706
129+2026-10-30 10:13:00,12.325,12.35,12.32,12.335,118042
130+2026-10-30 10:14:00,12.34,12.34,12.32,12.335,156878
131+2026-10-30 10:15:00,12.33,12.34,12.325,12.33,67977
132+2026-10-30 10:16:00,12.325,12.33,12.32,12.32,72957
133+2026-10-30 10:17:00,12.32,12.3488,12.31,12.345,96474
134+2026-10-30 10:18:00,12.34,12.3676,12.34,12.355,105932
135+2026-10-30 10:19:00,12.355,12.3576,12.34,12.34,70973
136+2026-10-30 10:20:00,12.345,12.38,12.345,12.365,105602
137+2026-10-30 10:21:00,12.3627,12.385,12.36,12.375,85871
138+2026-10-30 10:22:00,12.375,12.375,12.35,12.35,52030
139+2026-10-30 10:23:00,12.35,12.3699,12.345,12.365,145825
140+2026-10-30 10:24:00,12.365,12.365,12.34,12.345,164171
141+2026-10-30 10:25:00,12.3401,12.35,12.34,12.3497,35644
142+2026-10-30 10:26:00,12.345,12.35,12.34,12.345,45706
143+2026-10-30 10:27:00,12.34,12.35,12.33,12.345,86430
144+2026-10-30 10:28:00,12.345,12.36,12.34,12.3598,64145
145+2026-10-30 10:29:00,12.35,12.365,12.35,12.355,71823
146+2026-10-30 10:30:00,12.355,12.37,12.35,12.36,79520
147+2026-10-30 10:31:00,12.35,12.37,12.35,12.365,131109
148+2026-10-30 10:32:00,12.365,12.365,12.35,12.36,66447
149+2026-10-30 10:33:00,12.355,12.375,12.35,12.375,72464
150+2026-10-30 10:34:00,12.38,12.39,12.375,12.385,53844
151+2026-10-30 10:35:00,12.3801,12.385,12.365,12.369,87620
152+2026-10-30 10:36:00,12.365,12.38,12.365,12.3799,83428
153+2026-10-30 10:37:00,12.3765,12.38,12.37,12.3794,88724
154+2026-10-30 10:38:00,12.3701,12.4,12.3701,12.395,137147
155+2026-10-30 10:39:00,12.39,12.41,12.38,12.395,147802
156+2026-10-30 10:40:00,12.39,12.42,12.38,12.415,141524
157+2026-10-30 10:41:00,12.415,12.43,12.415,12.425,207009
158+2026-10-30 10:42:00,12.4207,12.43,12.42,12.42,83010
159+2026-10-30 10:43:00,12.4272,12.4272,12.41,12.425,131388
160+2026-10-30 10:44:00,12.42,12.4201,12.395,12.3999,68927
161+2026-10-30 10:45:00,12.4,12.4151,12.39,12.405,223566
162+2026-10-30 10:46:00,12.4,12.41,12.39,12.405,80114
163+2026-10-30 10:47:00,12.4,12.43,12.4,12.425,149756
164+2026-10-30 10:48:00,12.425,12.445,12.42,12.435,125975
165+2026-10-30 10:49:00,12.435,12.45,12.43,12.4498,80010
166+2026-10-30 10:50:00,12.45,12.455,12.4201,12.425,234061
167+2026-10-30 10:51:00,12.425,12.44,12.42,12.43,94008
168+2026-10-30 10:52:00,12.435,12.435,12.41,12.42,135523
169+2026-10-30 10:53:00,12.415,12.42,12.41,12.415,57118
170+2026-10-30 10:54:00,12.41,12.43,12.41,12.425,98023
171+2026-10-30 10:55:00,12.42,12.425,12.41,12.42,140065
172+2026-10-30 10:56:00,12.4199,12.425,12.415,12.425,95996
173+2026-10-30 10:57:00,12.4271,12.435,12.415,12.43,105659
174+2026-10-30 10:58:00,12.43,12.45,12.425,12.45,125560
175+2026-10-30 10:59:00,12.45,12.45,12.44,12.445,51129
176+2026-10-30 11:00:00,12.45,12.4651,12.44,12.4419,138485
177+2026-10-30 11:01:00,12.44,12.45,12.425,12.43,116718
178+2026-10-30 11:02:00,12.4299,12.43,12.42,12.425,126969
179+2026-10-30 11:03:00,12.425,12.425,12.4,12.405,75312
180+2026-10-30 11:04:00,12.4,12.41,12.4,12.405,54673
181+2026-10-30 11:05:00,12.4,12.41,12.4,12.4087,122650
182+2026-10-30 11:06:00,12.405,12.4151,12.4,12.41,93765
183+2026-10-30 11:07:00,12.405,12.405,12.38,12.3899,127830
184+2026-10-30 11:08:00,12.39,12.3999,12.38,12.395,111754
185+2026-10-30 11:09:00,12.4,12.4,12.39,12.39,39279
186+2026-10-30 11:10:00,12.39,12.395,12.37,12.375,50450
187+2026-10-30 11:11:00,12.375,12.38,12.37,12.375,97567
188+2026-10-30 11:12:00,12.375,12.38,12.37,12.38,32922
189+2026-10-30 11:13:00,12.38,12.39,12.3701,12.375,137943
190+2026-10-30 11:14:00,12.375,12.38,12.37,12.375,33369
191+2026-10-30 11:15:00,12.375,12.3892,12.3741,12.385,55867
192+2026-10-30 11:16:00,12.38,12.4,12.38,12.3901,271217
193+2026-10-30 11:17:00,12.39,12.3963,12.39,12.39,50507
194+2026-10-30 11:18:00,12.395,12.395,12.38,12.385,51679
195+2026-10-30 11:19:00,12.385,12.39,12.37,12.375,59229
196+2026-10-30 11:20:00,12.375,12.39,12.375,12.375,86199
197+2026-10-30 11:21:00,12.375,12.38,12.37,12.375,91356
198+2026-10-30 11:22:00,12.37,12.395,12.37,12.3807,120087
199+2026-10-30 11:23:00,12.385,12.43,12.38,12.415,213199
200+2026-10-30 11:24:00,12.415,12.4184,12.4001,12.4051,96936
201+2026-10-30 11:25:00,12.405,12.4074,12.39,12.3909,46506
202+2026-10-30 11:26:00,12.395,12.3951,12.38,12.395,102436
203+2026-10-30 11:27:00,12.399,12.4051,12.395,12.4034,42077
204+2026-10-30 11:28:00,12.405,12.405,12.395,12.395,78322
205+2026-10-30 11:29:00,12.395,12.405,12.3927,12.405,78328
206+2026-10-30 11:30:00,12.4,12.405,12.3948,12.405,74649
207+2026-10-30 11:31:00,12.405,12.405,12.38,12.38,105989
208+2026-10-30 11:32:00,12.3832,12.4,12.38,12.391,134944
209+2026-10-30 11:33:00,12.39,12.4,12.385,12.395,127249
210+2026-10-30 11:34:00,12.4,12.4,12.39,12.4,22331
211+2026-10-30 11:35:00,12.395,12.4,12.385,12.395,89939
212+2026-10-30 11:36:00,12.395,12.4,12.385,12.3901,114434
213+2026-10-30 11:37:00,12.3901,12.3957,12.38,12.385,94172
214+2026-10-30 11:38:00,12.3801,12.3865,12.36,12.3653,50454
215+2026-10-30 11:39:00,12.365,12.37,12.36,12.362,86341
216+2026-10-30 11:40:00,12.365,12.37,12.35,12.35,68320
217+2026-10-30 11:41:00,12.35,12.355,12.345,12.345,73876
218+2026-10-30 11:42:00,12.35,12.355,12.34,12.355,102804
219+2026-10-30 11:43:00,12.35,12.3661,12.35,12.3601,110810
220+2026-10-30 11:44:00,12.365,12.3799,12.36,12.3705,114875
221+2026-10-30 11:45:00,12.3701,12.38,12.3613,12.375,76815
222+2026-10-30 11:46:00,12.375,12.38,12.37,12.37,71352
223+2026-10-30 11:47:00,12.3712,12.375,12.36,12.365,90510
224+2026-10-30 11:48:00,12.368,12.3799,12.36,12.37,72009
225+2026-10-30 11:49:00,12.375,12.3851,12.3701,12.375,97774
226+2026-10-30 11:50:00,12.375,12.38,12.3701,12.375,14239
227+2026-10-30 11:51:00,12.375,12.379,12.36,12.3699,154106
228+2026-10-30 11:52:00,12.3699,12.38,12.365,12.3701,89326
229+2026-10-30 11:53:00,12.37,12.375,12.36,12.365,109028
230+2026-10-30 11:54:00,12.365,12.37,12.36,12.37,77555
231+2026-10-30 11:55:00,12.365,12.37,12.36,12.37,94848
232+2026-10-30 11:56:00,12.365,12.375,12.36,12.3685,94047
233+2026-10-30 11:57:00,12.365,12.37,12.36,12.365,84622
234+2026-10-30 11:58:00,12.36,12.375,12.36,12.3735,80038
235+2026-10-30 11:59:00,12.375,12.38,12.37,12.375,101235
236+2026-10-30 12:00:00,12.375,12.38,12.35,12.36,192945
237+2026-10-30 12:01:00,12.35,12.36,12.34,12.345,149771
238+2026-10-30 12:02:00,12.345,12.36,12.34,12.35,174225
239+2026-10-30 12:03:00,12.345,12.36,12.345,12.3493,103782
240+2026-10-30 12:04:00,12.35,12.36,12.34,12.35,69364
241+2026-10-30 12:05:00,12.3523,12.36,12.35,12.3576,64091
242+2026-10-30 12:06:00,12.36,12.37,12.35,12.355,106083
243+2026-10-30 12:07:00,12.355,12.36,12.35,12.355,37198
244+2026-10-30 12:08:00,12.36,12.37,12.3513,12.365,120916
245+2026-10-30 12:09:00,12.37,12.37,12.355,12.36,125506
246+2026-10-30 12:10:00,12.35,12.36,12.35,12.355,64076
247+2026-10-30 12:11:00,12.355,12.355,12.335,12.335,143741
248+2026-10-30 12:12:00,12.34,12.35,12.3345,12.345,154263
249+2026-10-30 12:13:00,12.345,12.35,12.34,12.35,70184
250+2026-10-30 12:14:00,12.3499,12.36,12.345,12.3501,85629
251+2026-10-30 12:15:00,12.355,12.36,12.3527,12.355,52549
252+2026-10-30 12:16:00,12.35,12.355,12.35,12.355,35217
253+2026-10-30 12:17:00,12.36,12.37,12.355,12.36,121984
254+2026-10-30 12:18:00,12.36,12.37,12.36,12.365,37974
255+2026-10-30 12:19:00,12.37,12.37,12.36,12.36,27307
256+2026-10-30 12:20:00,12.365,12.37,12.35,12.35,70736
257+2026-10-30 12:21:00,12.3582,12.36,12.34,12.345,91549
258+2026-10-30 12:22:00,12.345,12.36,12.34,12.3504,109706
259+2026-10-30 12:23:00,12.355,12.36,12.35,12.35,69957
260+2026-10-30 12:24:00,12.355,12.36,12.35,12.355,26901
261+2026-10-30 12:25:00,12.35,12.36,12.35,12.355,8253
262+2026-10-30 12:26:00,12.355,12.36,12.35,12.35,23175
263+2026-10-30 12:27:00,12.35,12.36,12.35,12.355,27638
264+2026-10-30 12:28:00,12.35,12.38,12.35,12.3601,141496
265+2026-10-30 12:29:00,12.365,12.37,12.36,12.37,86016
266+2026-10-30 12:30:00,12.365,12.375,12.355,12.375,87236
267+2026-10-30 12:31:00,12.375,12.38,12.37,12.371,27142
268+2026-10-30 12:32:00,12.37,12.38,12.37,12.375,66342
269+2026-10-30 12:33:00,12.375,12.38,12.365,12.3799,98015
270+2026-10-30 12:34:00,12.38,12.38,12.37,12.375,96845
271+2026-10-30 12:35:00,12.37,12.38,12.365,12.375,104227
272+2026-10-30 12:36:00,12.375,12.3766,12.365,12.365,159455
273+2026-10-30 12:37:00,12.365,12.37,12.36,12.365,25275
274+2026-10-30 12:38:00,12.3666,12.3666,12.3535,12.355,156409
275+2026-10-30 12:39:00,12.36,12.38,12.355,12.37,86975
276+2026-10-30 12:40:00,12.375,12.3798,12.37,12.375,118375
277+2026-10-30 12:41:00,12.3741,12.3797,12.365,12.365,66889
278+2026-10-30 12:42:00,12.365,12.37,12.35,12.355,86943
279+2026-10-30 12:43:00,12.355,12.37,12.35,12.37,86497
280+2026-10-30 12:44:00,12.36,12.37,12.36,12.3618,81956
281+2026-10-30 12:45:00,12.365,12.365,12.35,12.35,125789
282+2026-10-30 12:46:00,12.355,12.355,12.33,12.3391,239347
283+2026-10-30 12:47:00,12.335,12.35,12.335,12.35,122310
284+2026-10-30 12:48:00,12.345,12.345,12.33,12.335,41876
285+2026-10-30 12:49:00,12.33,12.3453,12.33,12.345,136173
286+2026-10-30 12:50:00,12.3401,12.35,12.335,12.335,65524
287+2026-10-30 12:51:00,12.33,12.34,12.33,12.335,71441
288+2026-10-30 12:52:00,12.33,12.34,12.33,12.335,19081
289+2026-10-30 12:53:00,12.3384,12.34,12.32,12.325,172250
290+2026-10-30 12:54:00,12.325,12.3351,12.3212,12.33,86123
291+2026-10-30 12:55:00,12.3299,12.335,12.32,12.335,19622
292+2026-10-30 12:56:00,12.33,12.34,12.33,12.3335,70025
293+2026-10-30 12:57:00,12.335,12.3371,12.33,12.3351,24788
294+2026-10-30 12:58:00,12.335,12.3351,12.32,12.3269,27663
295+2026-10-30 12:59:00,12.325,12.33,12.32,12.32,28877
296+2026-10-30 13:00:00,12.32,12.325,12.31,12.315,77245
297+2026-10-30 13:01:00,12.315,12.3251,12.31,12.3162,84106
298+2026-10-30 13:02:00,12.3181,12.33,12.31,12.33,72317
299+2026-10-30 13:03:00,12.325,12.3269,12.31,12.3251,95085
300+2026-10-30 13:04:00,12.32,12.3251,12.31,12.315,158364
301+2026-10-30 13:05:00,12.315,12.34,12.315,12.3302,280734
302+2026-10-30 13:06:00,12.33,12.337,12.32,12.325,117504
303+2026-10-30 13:07:00,12.325,12.34,12.3105,12.3105,160283
304+2026-10-30 13:08:00,12.315,12.32,12.31,12.3108,103170
305+2026-10-30 13:09:00,12.315,12.317,12.31,12.315,9451
306+2026-10-30 13:10:00,12.31,12.32,12.31,12.315,116515
307+2026-10-30 13:11:00,12.3161,12.32,12.3,12.305,86429
308+2026-10-30 13:12:00,12.305,12.3051,12.29,12.295,146834
309+2026-10-30 13:13:00,12.29,12.3,12.29,12.29,92200
310+2026-10-30 13:14:00,12.2916,12.3,12.28,12.29,185040
311+2026-10-30 13:15:00,12.29,12.31,12.29,12.305,243772
312+2026-10-30 13:16:00,12.3,12.31,12.29,12.305,155658
313+2026-10-30 13:17:00,12.305,12.31,12.3,12.3,52362
314+2026-10-30 13:18:00,12.305,12.32,12.3,12.3101,107572
315+2026-10-30 13:19:00,12.3151,12.32,12.31,12.315,112461
316+2026-10-30 13:20:00,12.3126,12.3196,12.3,12.315,258466
317+2026-10-30 13:21:00,12.315,12.32,12.315,12.315,67277
318+2026-10-30 13:22:00,12.31,12.33,12.31,12.32,175357
319+2026-10-30 13:23:00,12.325,12.33,12.315,12.325,172307
320+2026-10-30 13:24:00,12.32,12.325,12.31,12.315,143751
321+2026-10-30 13:25:00,12.315,12.32,12.31,12.315,114545
322+2026-10-30 13:26:00,12.316,12.32,12.31,12.3144,68903
323+2026-10-30 13:27:00,12.315,12.3251,12.315,12.325,67712
324+2026-10-30 13:28:00,12.325,12.33,12.32,12.325,12680
325+2026-10-30 13:29:00,12.325,12.3351,12.32,12.335,102446
326+2026-10-30 13:30:00,12.34,12.34,12.315,12.315,270161
327+2026-10-30 13:31:00,12.315,12.32,12.31,12.315,30735
328+2026-10-30 13:32:00,12.315,12.32,12.3,12.31,91311
329+2026-10-30 13:33:00,12.315,12.32,12.31,12.315,134370
330+2026-10-30 13:34:00,12.315,12.3197,12.3,12.305,239586
331+2026-10-30 13:35:00,12.305,12.31,12.2901,12.3051,162592
332+2026-10-30 13:36:00,12.305,12.31,12.3,12.31,126589
333+2026-10-30 13:37:00,12.305,12.32,12.305,12.32,198840
334+2026-10-30 13:38:00,12.32,12.3391,12.32,12.33,188230
335+2026-10-30 13:39:00,12.335,12.34,12.33,12.335,55359
336+2026-10-30 13:40:00,12.335,12.35,12.335,12.345,189828
337+2026-10-30 13:41:00,12.345,12.36,12.3335,12.34,182750
338+2026-10-30 13:42:00,12.335,12.34,12.32,12.3293,120573
339+2026-10-30 13:43:00,12.32,12.3391,12.32,12.33,243497
340+2026-10-30 13:44:00,12.335,12.365,12.32,12.365,159684
341+2026-10-30 13:45:00,12.36,12.365,12.33,12.3499,316776
342+2026-10-30 13:46:00,12.345,12.35,12.33,12.335,244947
343+2026-10-30 13:47:00,12.335,12.335,12.32,12.3289,184980
344+2026-10-30 13:48:00,12.33,12.34,12.325,12.335,198439
345+2026-10-30 13:49:00,12.335,12.34,12.33,12.3336,77899
346+2026-10-30 13:50:00,12.34,12.34,12.32,12.325,148562
347+2026-10-30 13:51:00,12.3207,12.33,12.31,12.315,89411
348+2026-10-30 13:52:00,12.315,12.32,12.31,12.3151,133648
349+2026-10-30 13:53:00,12.31,12.32,12.3,12.31,124329
350+2026-10-30 13:54:00,12.305,12.31,12.3,12.31,107178
351+2026-10-30 13:55:00,12.31,12.31,12.3,12.305,64228
352+2026-10-30 13:56:00,12.305,12.31,12.3,12.305,22808
353+2026-10-30 13:57:00,12.3,12.3151,12.3,12.315,225172
354+2026-10-30 13:58:00,12.315,12.32,12.31,12.315,131424
355+2026-10-30 13:59:00,12.3112,12.32,12.31,12.32,85302
356+2026-10-30 14:00:00,12.315,12.32,12.3,12.315,270024
357+2026-10-30 14:01:00,12.31,12.315,12.3,12.305,136999
358+2026-10-30 14:02:00,12.31,12.32,12.3,12.31,200578
359+2026-10-30 14:03:00,12.31,12.31,12.3,12.31,124643
360+2026-10-30 14:04:00,12.305,12.31,12.3,12.305,45474
361+2026-10-30 14:05:00,12.3,12.31,12.295,12.295,197392
362+2026-10-30 14:06:00,12.3,12.31,12.2901,12.305,193554
363+2026-10-30 14:07:00,12.305,12.3163,12.3,12.3163,118466
364+2026-10-30 14:08:00,12.31,12.3151,12.3,12.3,124737
365+2026-10-30 14:09:00,12.3,12.3099,12.29,12.295,133387
366+2026-10-30 14:10:00,12.295,12.3,12.29,12.2901,34248
367+2026-10-30 14:11:00,12.2901,12.3,12.28,12.3,211512
368+2026-10-30 14:12:00,12.3,12.3,12.28,12.2882,125841
369+2026-10-30 14:13:00,12.2897,12.3,12.2816,12.285,119816
370+2026-10-30 14:14:00,12.285,12.2988,12.28,12.295,81450
371+2026-10-30 14:15:00,12.2901,12.297,12.29,12.295,42992
372+2026-10-30 14:16:00,12.29,12.2991,12.2822,12.29,168406
373+2026-10-30 14:17:00,12.285,12.2978,12.28,12.295,135992
374+2026-10-30 14:18:00,12.2995,12.3,12.29,12.2988,77138
375+2026-10-30 14:19:00,12.295,12.31,12.29,12.31,159592
376+2026-10-30 14:20:00,12.305,12.31,12.29,12.295,236848
377+2026-10-30 14:21:00,12.295,12.31,12.2914,12.305,117047
378+2026-10-30 14:22:00,12.305,12.31,12.3,12.305,40164
379+2026-10-30 14:23:00,12.3,12.32,12.3,12.305,180363
380+2026-10-30 14:24:00,12.3049,12.32,12.3,12.3099,168919
381+2026-10-30 14:25:00,12.3097,12.3151,12.3,12.3051,155759
382+2026-10-30 14:26:00,12.3005,12.31,12.3,12.305,43263
383+2026-10-30 14:27:00,12.31,12.31,12.3,12.3,118559
384+2026-10-30 14:28:00,12.305,12.3051,12.29,12.3046,125709
385+2026-10-30 14:29:00,12.305,12.32,12.305,12.3162,162139
386+2026-10-30 14:30:00,12.315,12.3151,12.3,12.3128,161254
387+2026-10-30 14:31:00,12.315,12.315,12.2911,12.295,269025
388+2026-10-30 14:32:00,12.3,12.3051,12.29,12.295,173322
389+2026-10-30 14:33:00,12.3,12.3,12.285,12.285,164922
390+2026-10-30 14:34:00,12.29,12.29,12.28,12.281,47986
391+2026-10-30 14:35:00,12.28,12.29,12.28,12.2875,51876
392+2026-10-30 14:36:00,12.285,12.29,12.28,12.2899,57887
393+2026-10-30 14:37:00,12.29,12.31,12.28,12.295,228408
394+2026-10-30 14:38:00,12.2953,12.3,12.28,12.295,204847
395+2026-10-30 14:39:00,12.29,12.31,12.29,12.295,113558
396+2026-10-30 14:40:00,12.29,12.3,12.29,12.295,58478
397+2026-10-30 14:41:00,12.295,12.2985,12.28,12.2889,112870
398+2026-10-30 14:42:00,12.2881,12.29,12.2741,12.275,140560
399+2026-10-30 14:43:00,12.28,12.29,12.2738,12.28,102116
400+2026-10-30 14:44:00,12.28,12.29,12.275,12.285,187111
401+2026-10-30 14:45:00,12.285,12.29,12.275,12.275,162722
402+2026-10-30 14:46:00,12.28,12.28,12.27,12.275,218079
403+2026-10-30 14:47:00,12.275,12.29,12.275,12.29,175262
404+2026-10-30 14:48:00,12.288,12.3,12.28,12.295,84457
405+2026-10-30 14:49:00,12.295,12.3,12.285,12.2901,93464
406+2026-10-30 14:50:00,12.2901,12.2901,12.28,12.285,130695
407+2026-10-30 14:51:00,12.285,12.29,12.27,12.28,105050
408+2026-10-30 14:52:00,12.275,12.28,12.27,12.275,14436
409+2026-10-30 14:53:00,12.275,12.28,12.27,12.275,140253
410+2026-10-30 14:54:00,12.275,12.28,12.27,12.275,53898
411+2026-10-30 14:55:00,12.28,12.3,12.275,12.2901,240954
412+2026-10-30 14:56:00,12.29,12.32,12.29,12.3021,114520
413+2026-10-30 14:57:00,12.3097,12.31,12.3,12.305,41021
414+2026-10-30 14:58:00,12.3,12.31,12.3,12.305,42819
415+2026-10-30 14:59:00,12.3001,12.3001,12.29,12.2901,95874
416+2026-10-30 15:00:00,12.2901,12.2982,12.28,12.285,125243
417+2026-10-30 15:01:00,12.285,12.29,12.28,12.28,78260
418+2026-10-30 15:02:00,12.28,12.3,12.28,12.29,135155
419+2026-10-30 15:03:00,12.295,12.3151,12.29,12.3151,108716
420+2026-10-30 15:04:00,12.315,12.3239,12.31,12.315,264320
421+2026-10-30 15:05:00,12.32,12.33,12.31,12.325,185514
422+2026-10-30 15:06:00,12.3224,12.345,12.32,12.34,189876
423+2026-10-30 15:07:00,12.345,12.35,12.33,12.33,143931
424+2026-10-30 15:08:00,12.335,12.365,12.325,12.365,443232
425+2026-10-30 15:09:00,12.365,12.39,12.36,12.39,395350
426+2026-10-30 15:10:00,12.385,12.39,12.37,12.3713,195434
427+2026-10-30 15:11:00,12.3701,12.405,12.36,12.405,460899
428+2026-10-30 15:12:00,12.4018,12.4051,12.39,12.4,324798
429+2026-10-30 15:13:00,12.39,12.41,12.39,12.395,269073
430+2026-10-30 15:14:00,12.39,12.42,12.39,12.42,432502
431+2026-10-30 15:15:00,12.4101,12.435,12.4,12.4318,205760
432+2026-10-30 15:16:00,12.43,12.43,12.41,12.414,229658
433+2026-10-30 15:17:00,12.41,12.4151,12.4,12.4001,131804
434+2026-10-30 15:18:00,12.4,12.4157,12.37,12.415,2946463
435+2026-10-30 15:19:00,12.41,12.45,12.41,12.44,320423
436+2026-10-30 15:20:00,12.445,12.47,12.44,12.465,307541
437+2026-10-30 15:21:00,12.465,12.5,12.46,12.4657,1152858
438+2026-10-30 15:22:00,12.4651,12.49,12.46,12.485,382938
439+2026-10-30 15:23:00,12.485,12.51,12.485,12.495,591782
440+2026-10-30 15:24:00,12.5,12.51,12.5,12.5,204022
441+2026-10-30 15:25:00,12.505,12.52,12.5,12.515,297458
442+2026-10-30 15:26:00,12.515,12.52,12.5,12.515,244869
443+2026-10-30 15:27:00,12.515,12.53,12.5,12.505,405770
444+2026-10-30 15:28:00,12.505,12.51,12.49,12.495,259409
445+2026-10-30 15:29:00,12.5,12.5,12.48,12.485,349296
446+2026-10-30 15:30:00,12.4807,12.49,12.475,12.48,294521
447+2026-10-30 15:31:00,12.48,12.49,12.47,12.4899,152480
448+2026-10-30 15:32:00,12.4899,12.51,12.4811,12.485,348746
449+2026-10-30 15:33:00,12.485,12.5,12.48,12.485,205981
450+2026-10-30 15:34:00,12.4871,12.5,12.48,12.5,200330
451+2026-10-30 15:35:00,12.495,12.5,12.4899,12.4899,175994
452+2026-10-30 15:36:00,12.485,12.5,12.48,12.5,130454
453+2026-10-30 15:37:00,12.4989,12.51,12.4919,12.505,174336
454+2026-10-30 15:38:00,12.505,12.505,12.48,12.4836,331109
455+2026-10-30 15:39:00,12.485,12.49,12.48,12.485,45345
456+2026-10-30 15:40:00,12.485,12.5,12.47,12.473,142147
457+2026-10-30 15:41:00,12.475,12.48,12.455,12.465,132664
458+2026-10-30 15:42:00,12.4689,12.49,12.46,12.485,180629
459+2026-10-30 15:43:00,12.485,12.49,12.48,12.49,84551
460+2026-10-30 15:44:00,12.49,12.49,12.48,12.48,95932
461+2026-10-30 15:45:00,12.485,12.5,12.48,12.495,168881
462+2026-10-30 15:46:00,12.495,12.5,12.49,12.495,57316
463+2026-10-30 15:47:00,12.495,12.5,12.49,12.4901,98480
464+2026-10-30 15:48:00,12.495,12.5,12.48,12.49,100421
465+2026-10-30 15:49:00,12.485,12.5,12.48,12.495,119030
466+2026-10-30 15:50:00,12.5,12.53,12.5,12.53,343075
467+2026-10-30 15:51:00,12.5299,12.54,12.52,12.525,401289
468+2026-10-30 15:52:00,12.525,12.56,12.525,12.5565,255093
469+2026-10-30 15:53:00,12.5599,12.58,12.555,12.575,363695
470+2026-10-30 15:54:00,12.58,12.58,12.55,12.555,377488
471+2026-10-30 15:55:00,12.5596,12.59,12.555,12.585,271762
472+2026-10-30 15:56:00,12.585,12.6,12.56,12.565,545413
473+2026-10-30 15:57:00,12.5679,12.6,12.565,12.595,340487
474+2026-10-30 15:58:00,12.595,12.6,12.58,12.585,735276
475+2026-10-30 15:59:00,12.58,12.62,12.57,12.605,859867
476+2026-10-30 16:00:00,12.61,12.61,12.56,12.56,2555045
477+2026-10-30 16:02:00,12.57,12.58,12.57,12.58,799
478+2026-10-30 16:07:00,12.57,12.57,12.57,12.57,1052
479+2026-10-30 16:08:00,12.5375,12.5375,12.5375,12.5375,100
480+2026-10-30 16:09:00,12.555,12.555,12.5297,12.5297,2032
481+2026-10-30 16:12:00,12.56,12.57,12.56,12.57,1719
482+2026-10-30 16:13:00,12.5685,12.5685,12.5622,12.5622,200
483+2026-10-30 16:15:00,12.565,12.565,12.565,12.565,100
484+2026-10-30 16:16:00,12.565,12.565,12.565,12.565,100
485+2026-10-30 16:18:00,12.56,12.56,12.56,12.56,1171
486+2026-10-30 16:20:00,12.56,12.56,12.555,12.555,1057
487+2026-10-30 16:26:00,12.54,12.54,12.54,12.54,10066
488+2026-10-30 16:28:00,12.54,12.54,12.535,12.5399,52633
489+2026-10-30 16:29:00,12.54,12.54,12.53,12.54,10818
490+2026-10-30 16:32:00,12.535,12.54,12.52,12.54,51205
491+2026-10-30 16:35:00,12.53,12.53,12.51,12.51,380
492+2026-10-30 16:37:00,12.52,12.52,12.52,12.52,10000
493+2026-10-30 16:38:00,12.52,12.52,12.52,12.52,200
494+2026-10-30 16:39:00,12.5199,12.52,12.5158,12.52,15200
495+2026-10-30 16:40:00,12.54,12.54,12.54,12.54,2230
496+2026-10-30 16:42:00,12.53,12.53,12.53,12.53,100
497+2026-10-30 16:44:00,12.53,12.53,12.53,12.53,3000
498+2026-10-30 16:45:00,12.5457,12.5469,12.5454,12.5454,300
499+2026-10-30 16:46:00,12.55,12.55,12.55,12.55,100
500+2026-10-30 16:47:00,12.54,12.54,12.54,12.54,400
501+2026-10-30 16:49:00,12.54,12.54,12.54,12.54,100
502+2026-10-30 16:52:00,12.54,12.54,12.54,12.54,259
503+2026-10-30 16:53:00,12.555,12.58,12.54,12.54,8876
504+2026-10-30 16:55:00,12.58,12.58,12.55,12.55,1000
505+2026-10-30 16:58:00,12.56,12.56,12.56,12.56,4200
506+2026-10-30 16:59:00,12.56,12.56,12.55,12.55,1300
507+2026-10-30 17:00:00,12.56,12.56,12.56,12.56,1200
508+2026-10-30 17:03:00,12.57,12.57,12.56,12.56,25100
509+2026-10-30 17:04:00,12.57,12.57,12.57,12.57,100
510+2026-10-30 17:05:00,12.57,12.57,12.5608,12.5608,6600
511+2026-10-30 17:07:00,12.56,12.56,12.55,12.55,1500
512+2026-10-30 17:09:00,12.55,12.55,12.55,12.55,100
513+2026-10-30 17:11:00,12.53,12.53,12.53,12.53,100
514+2026-10-30 17:13:00,12.55,12.55,12.55,12.55,200
515+2026-10-30 17:17:00,12.53,12.53,12.53,12.53,100
516+2026-10-30 17:41:00,12.57,12.57,12.57,12.57,150
517+2026-10-30 17:44:00,12.56,12.56,12.5531,12.5531,1000
518+2026-10-30 17:45:00,12.56,12.56,12.56,12.56,500
519+2026-10-30 17:48:00,12.55,12.55,12.55,12.55,2800
520+2026-10-30 17:52:00,12.56,12.56,12.56,12.56,100
521+2026-10-30 17:54:00,12.56,12.56,12.56,12.56,100
522+2026-10-30 17:55:00,12.55,12.55,12.55,12.55,300
523+2026-10-30 17:58:00,12.56,12.56,12.56,12.56,2188
524+2026-10-30 17:59:00,12.56,12.56,12.56,12.56,140
525+2026-10-30 18:04:00,12.5503,12.5503,12.5503,12.5503,3000
526+2026-10-30 18:06:00,12.56,12.56,12.56,12.56,500
527+2026-10-30 18:11:00,12.555,12.555,12.555,12.555,1000
528+2026-10-30 18:13:00,12.5531,12.5531,12.5531,12.5531,1000
529+2026-10-30 18:27:00,12.5531,12.5531,12.5531,12.5531,1000
530+2026-10-30 18:43:00,12.5512,12.5512,12.5512,12.5512,160
531+2026-10-30 18:49:00,12.56,12.56,12.56,12.56,1032
532+2026-10-30 18:59:00,12.5512,12.5512,12.55,12.551,16150
533+2026-10-30 19:03:00,12.56,12.56,12.56,12.56,320
534+2026-10-30 19:05:00,12.5607,12.5607,12.5607,12.5607,306
535+2026-10-30 19:06:00,12.56,12.5605,12.56,12.5605,600
536+2026-10-30 19:17:00,12.5604,12.5604,12.5604,12.5604,300
537+2026-10-30 19:20:00,12.57,12.57,12.5667,12.5667,698
538+2026-10-30 19:21:00,12.56,12.56,12.56,12.56,500
539+2026-10-30 19:22:00,12.56,12.56,12.56,12.56,803
540+2026-10-30 19:32:00,12.56,12.56,12.56,12.56,4000
541+2026-10-30 19:35:00,12.5699,12.5699,12.5699,12.5699,150
542+2026-10-30 19:38:00,12.57,12.57,12.57,12.57,1000
543+2026-10-30 19:39:00,12.55,12.55,12.55,12.55,1200
544+2026-10-30 19:40:00,12.55,12.55,12.55,12.55,2000
545+2026-10-30 19:41:00,12.55,12.55,12.55,12.55,3000
546+2026-10-30 19:42:00,12.56,12.56,12.55,12.55,4000
547+2026-10-30 19:43:00,12.55,12.55,12.55,12.55,2499
548+2026-10-30 19:45:00,12.55,12.55,12.54,12.54,5021
549+2026-10-30 19:46:00,12.56,12.56,12.56,12.56,500
550+2026-10-30 19:47:00,12.53,12.53,12.53,12.53,1000
551+2026-10-30 19:51:00,12.5585,12.56,12.5535,12.5537,3670
552+2026-10-30 19:55:00,12.54,12.54,12.54,12.54,500
553+2026-10-30 19:56:00,12.56,12.56,12.56,12.56,2000
554+2026-10-30 19:59:00,12.56,12.56,12.56,12.56,500
\ No newline at end of file
added tests/fixtures/firstrate/AAPL_day_1min_UNADJUSTED.txt +900 −0
@@ -0,0 +1,900 @@
1+2026-10-30 04:00:00,350.76,351.6499,350.04,351.6499,4802
2+2026-10-30 04:01:00,351.51,351.64,349.95,351.275,6240
3+2026-10-30 04:02:00,351.175,351.175,349.9,350.14,5035
4+2026-10-30 04:03:00,351.17,351.56,349.92,350.86,13813
5+2026-10-30 04:04:00,350.61,351.47,350.61,350.87,1559
6+2026-10-30 04:05:00,351.06,351.24,351.06,351.24,169
7+2026-10-30 04:06:00,351.22,351.3,351.18,351.29,3083
8+2026-10-30 04:07:00,350.9,351.2,350.43,351.1745,580
9+2026-10-30 04:08:00,351.42,351.45,351.41,351.45,826
10+2026-10-30 04:10:00,351.23,351.23,351.16,351.16,94
11+2026-10-30 04:11:00,351.255,351.27,351.18,351.255,945
12+2026-10-30 04:12:00,351.18,351.18,350.81,350.81,3561
13+2026-10-30 04:13:00,350.87,350.87,350.8,350.8,567
14+2026-10-30 04:14:00,350.5,350.8,350.5,350.6,1336
15+2026-10-30 04:15:00,350.67,350.7,350.62,350.62,401
16+2026-10-30 04:16:00,350.62,350.7,349.61,350.12,5170
17+2026-10-30 04:17:00,349.83,350.16,349.74,349.77,655
18+2026-10-30 04:18:00,349.81,349.99,349.53,349.53,2244
19+2026-10-30 04:19:00,349.67,349.67,349,349,7254
20+2026-10-30 04:20:00,349.18,349.23,349.01,349.2,1803
21+2026-10-30 04:21:00,349.16,349.41,349.16,349.4,345
22+2026-10-30 04:22:00,349.4,349.47,349.18,349.2,827
23+2026-10-30 04:23:00,349.29,349.38,349.09,349.16,1910
24+2026-10-30 04:24:00,349.17,349.58,349.09,349.16,2467
25+2026-10-30 04:25:00,349.16,349.42,349.16,349.37,3201
26+2026-10-30 04:26:00,349.33,349.59,349.25,349.51,6714
27+2026-10-30 04:27:00,349.4,349.44,349.4,349.4,231
28+2026-10-30 04:28:00,349.37,349.47,349.37,349.41,591
29+2026-10-30 04:29:00,349.43,349.47,349.43,349.47,120
30+2026-10-30 04:30:00,349.6,349.69,349.25,349.27,2650
31+2026-10-30 04:31:00,349.39,349.41,349.35,349.41,230
32+2026-10-30 04:32:00,349.7,349.7,349.4,349.56,830
33+2026-10-30 04:33:00,349.62,349.8,349.39,349.39,5224
34+2026-10-30 04:34:00,349.34,349.6,349.34,349.55,672
35+2026-10-30 04:35:00,349.5,349.64,349.25,349.5,6166
36+2026-10-30 04:36:00,349.52,349.58,349.52,349.55,388
37+2026-10-30 04:37:00,349.55,349.61,349.52,349.61,624
38+2026-10-30 04:38:00,349.6,349.61,349.6,349.61,267
39+2026-10-30 04:39:00,349.59,349.59,349.5,349.52,536
40+2026-10-30 04:40:00,349.53,349.56,349.3,349.42,3056
41+2026-10-30 04:41:00,349.42,349.5,349.4049,349.44,1023
42+2026-10-30 04:42:00,349.54,349.54,349.54,349.54,60
43+2026-10-30 04:43:00,349.54,349.61,349.53,349.61,1920
44+2026-10-30 04:44:00,349.58,349.66,349.58,349.66,399
45+2026-10-30 04:45:00,349.71,349.71,349.54,349.54,634
46+2026-10-30 04:46:00,349.54,349.97,349.54,349.78,2581
47+2026-10-30 04:47:00,349.99,349.99,349.75,349.75,886
48+2026-10-30 04:48:00,349.82,349.91,349.82,349.91,408
49+2026-10-30 04:49:00,349.99,350.19,349.98,350.08,1816
50+2026-10-30 04:50:00,350.1,350.12,349.64,350.1118,6478
51+2026-10-30 04:51:00,350.12,350.18,350.1,350.18,264
52+2026-10-30 04:52:00,350.14,350.14,349.77,350.03,1364
53+2026-10-30 04:53:00,349.99,350.2,349.76,349.95,616
54+2026-10-30 04:55:00,349.92,349.92,349.92,349.92,150
55+2026-10-30 04:56:00,349.79,349.87,349.7,349.8,562
56+2026-10-30 04:58:00,349.89,350,349.89,349.9,148
57+2026-10-30 04:59:00,349.981,349.981,349.981,349.981,58
58+2026-10-30 05:01:00,349.9,350,349.9,350,285
59+2026-10-30 05:02:00,350.14,350.14,350.14,350.14,200
60+2026-10-30 05:03:00,350.1928,350.1928,350.1928,350.1928,1000
61+2026-10-30 05:04:00,350.01,350.1,350.01,350.1,2377
62+2026-10-30 05:05:00,350.03,350.12,349.8,349.8,6082
63+2026-10-30 05:06:00,349.81,349.81,349.81,349.81,80
64+2026-10-30 05:09:00,349.91,349.98,349.89,349.98,855
65+2026-10-30 05:10:00,349.87,349.87,349.63,349.7,1181
66+2026-10-30 05:11:00,349.77,349.77,349.77,349.77,120
67+2026-10-30 05:12:00,349.9097,349.9097,349.9097,349.9097,50
68+2026-10-30 05:13:00,349.83,349.83,349.8,349.8,289
69+2026-10-30 05:14:00,349.83,349.87,349.8172,349.87,619
70+2026-10-30 05:15:00,349.9,349.9,349.9,349.9,100
71+2026-10-30 05:16:00,349.83,349.83,349.81,349.81,1995
72+2026-10-30 05:17:00,349.83,349.86,349.8,349.8203,1098
73+2026-10-30 05:18:00,349.88,349.95,349.87,349.95,627
74+2026-10-30 05:19:00,349.91,349.98,349.86,349.86,1670
75+2026-10-30 05:20:00,349.82,349.82,349.23,349.27,9026
76+2026-10-30 05:21:00,349.27,349.57,349.23,349.23,854
77+2026-10-30 05:22:00,349.23,349.29,348.92,349.02,7485
78+2026-10-30 05:23:00,349.05,349.08,348.12,348.12,10784
79+2026-10-30 05:24:00,348.15,348.25,346.51,346.67,23069
80+2026-10-30 05:25:00,346.75,347.59,346.63,346.81,3578
81+2026-10-30 05:26:00,346.81,347.07,346.47,346.6,5612
82+2026-10-30 05:27:00,346.52,347.04,346.5,346.84,4872
83+2026-10-30 05:28:00,346.65,346.87,346.59,346.87,1235
84+2026-10-30 05:29:00,346.82,347.035,346.65,346.74,2808
85+2026-10-30 05:30:00,346.74,347.06,346.62,346.72,3181
86+2026-10-30 05:31:00,346.62,347.1,346.62,346.7997,2507
87+2026-10-30 05:32:00,346.72,347.19,345.5,345.61,16768
88+2026-10-30 05:33:00,346.25,346.44,345.33,346.44,17283
89+2026-10-30 05:34:00,346.35,346.36,345.76,346.25,2402
90+2026-10-30 05:35:00,345.96,346.4,345.96,346.28,2268
91+2026-10-30 05:36:00,346,346.38,345.77,346,4916
92+2026-10-30 05:37:00,346,346.135,345.3,345.3,19935
93+2026-10-30 05:38:00,345.84,345.92,345.03,345.73,8245
94+2026-10-30 05:39:00,345.71,346.28,345.71,346,691
95+2026-10-30 05:40:00,346.35,346.69,346.01,346.45,10543
96+2026-10-30 05:41:00,346.37,346.62,346.03,346.25,4780
97+2026-10-30 05:42:00,346.16,346.5,346.15,346.18,8874
98+2026-10-30 05:43:00,346.07,346.38,345.5,345.69,6844
99+2026-10-30 05:44:00,345.685,345.99,345.685,345.95,1443
100+2026-10-30 05:45:00,345.9062,346,345.5,345.5,5568
101+2026-10-30 05:46:00,345.87,345.9,345.53,345.53,3571
102+2026-10-30 05:47:00,345.54,345.8,345.17,345.26,9663
103+2026-10-30 05:48:00,345.74,346.44,345.32,346.105,10855
104+2026-10-30 05:49:00,346.39,346.58,346.08,346.49,3461
105+2026-10-30 05:50:00,346.43,346.58,345.59,345.73,8786
106+2026-10-30 05:51:00,345.73,345.73,345.19,345.3234,4674
107+2026-10-30 05:52:00,345.32,345.32,344.03,345.3,19600
108+2026-10-30 05:53:00,345.19,345.5,344.8,345.07,8031
109+2026-10-30 05:54:00,345.01,345.33,345,345,456
110+2026-10-30 05:55:00,345.41,345.44,345.09,345.18,443
111+2026-10-30 05:56:00,345.18,345.5,345.03,345.05,1200
112+2026-10-30 05:57:00,345.275,345.5,345,345.25,11692
113+2026-10-30 05:58:00,345.11,345.28,345,345.15,4272
114+2026-10-30 05:59:00,345.23,345.42,345.01,345.14,7076
115+2026-10-30 06:00:00,345.14,345.35,345,345.04,1754
116+2026-10-30 06:01:00,345,345.3,344.83,345.3,3256
117+2026-10-30 06:02:00,345.15,345.29,345.01,345.06,2070
118+2026-10-30 06:03:00,345.11,345.29,345.0183,345.29,1588
119+2026-10-30 06:04:00,345.1,345.3,345.01,345.08,1679
120+2026-10-30 06:05:00,345.17,345.21,345.01,345.11,2622
121+2026-10-30 06:06:00,345.12,345.18,345.05,345.16,1368
122+2026-10-30 06:07:00,345.24,345.24,344.95,344.95,4248
123+2026-10-30 06:08:00,345.08,345.08,345,345,169
124+2026-10-30 06:09:00,345.14,345.14,345.14,345.14,205
125+2026-10-30 06:10:00,345.2,345.2,345,345.015,2810
126+2026-10-30 06:11:00,345,345.29,345,345.29,2428
127+2026-10-30 06:12:00,345.15,345.35,345.15,345.17,1181
128+2026-10-30 06:13:00,345.09,345.15,344.83,344.83,3024
129+2026-10-30 06:14:00,345.01,345.19,344.86,344.86,1018
130+2026-10-30 06:15:00,344.97,345.13,344.97,345.11,1075
131+2026-10-30 06:16:00,345.1,345.1,345,345,1293
132+2026-10-30 06:17:00,345,345.2,345,345.2,804
133+2026-10-30 06:18:00,345.16,345.2,344.9,344.92,2878
134+2026-10-30 06:19:00,344.9953,345.01,344.99,345,3449
135+2026-10-30 06:20:00,345,345.2,345,345.2,590
136+2026-10-30 06:21:00,345.16,345.3,345.13,345.15,1550
137+2026-10-30 06:22:00,345.15,345.24,345.11,345.11,3380
138+2026-10-30 06:23:00,345.13,345.14,345.06,345.09,1475
139+2026-10-30 06:24:00,345.04,345.04,344.7349,344.9,2992
140+2026-10-30 06:25:00,344.95,344.9998,344.87,344.87,1478
141+2026-10-30 06:26:00,344.96,345,344.8716,344.9,3414
142+2026-10-30 06:27:00,344.9,345.1,344.9,345.08,1274
143+2026-10-30 06:28:00,345.23,345.23,345.13,345.15,368
144+2026-10-30 06:29:00,345.15,345.16,344.92,344.92,2224
145+2026-10-30 06:30:00,345,345.01,344.95,345,3492
146+2026-10-30 06:31:00,345.129,345.24,345,345,2762
147+2026-10-30 06:32:00,345.1678,345.1678,345.02,345.02,154
148+2026-10-30 06:33:00,345.09,345.1,344.995,345.1,1024
149+2026-10-30 06:34:00,345.24,345.29,345.24,345.29,416
150+2026-10-30 06:35:00,345.22,345.29,345.18,345.23,587
151+2026-10-30 06:36:00,345.28,345.3,345.17,345.27,4352
152+2026-10-30 06:37:00,345.27,345.3,345.27,345.3,409
153+2026-10-30 06:38:00,345.3,345.59,345.28,345.3212,1981
154+2026-10-30 06:39:00,345.5,345.5,345.38,345.5,1470
155+2026-10-30 06:40:00,345.5167,345.59,345.485,345.59,560
156+2026-10-30 06:41:00,345.59,345.59,345.28,345.28,3110
157+2026-10-30 06:42:00,345.3617,345.59,345.11,345.32,8229
158+2026-10-30 06:43:00,345.2,345.37,345.19,345.35,441
159+2026-10-30 06:44:00,345.3498,345.3498,345.21,345.32,454
160+2026-10-30 06:45:00,345.22,345.24,345.11,345.23,1553
161+2026-10-30 06:46:00,345.29,345.44,345.29,345.44,331
162+2026-10-30 06:47:00,345.4,345.5034,345.16,345.19,1516
163+2026-10-30 06:48:00,345.2,345.4,345.2,345.4,375
164+2026-10-30 06:49:00,345.48,345.48,345.48,345.48,127
165+2026-10-30 06:50:00,345.4,345.4,345.16,345.25,1981
166+2026-10-30 06:51:00,345.29,345.3056,345,345,2777
167+2026-10-30 06:52:00,345.17,345.385,345.09,345.09,1789
168+2026-10-30 06:53:00,345.26,345.26,345.15,345.23,5350
169+2026-10-30 06:54:00,345.1782,345.26,345.17,345.26,296
170+2026-10-30 06:55:00,345.24,345.24,345.24,345.24,44
171+2026-10-30 06:56:00,345.24,345.325,345.17,345.21,2170
172+2026-10-30 06:57:00,345.21,345.35,345.2,345.27,1949
173+2026-10-30 06:58:00,345.25,345.29,345.2,345.22,1671
174+2026-10-30 06:59:00,345.25,345.35,345.24,345.31,1886
175+2026-10-30 07:00:00,345.32,345.95,344.3,344.3,104549
176+2026-10-30 07:01:00,344.45,344.83,344.03,344.43,24414
177+2026-10-30 07:02:00,344.05,344.78,343.41,343.62,33717
178+2026-10-30 07:03:00,343.59,343.8548,342.89,343.2,37734
179+2026-10-30 07:04:00,343.41,344,343.2,343.98,15300
180+2026-10-30 07:05:00,343.68,344.51,343.68,344.42,4958
181+2026-10-30 07:06:00,344.51,344.8,344.18,344.6833,8521
182+2026-10-30 07:07:00,344.68,344.68,344.02,344.02,9011
183+2026-10-30 07:08:00,344.44,344.4983,344.16,344.38,5077
184+2026-10-30 07:09:00,344.38,344.59,344.38,344.38,4075
185+2026-10-30 07:10:00,344.59,344.59,344.38,344.59,5823
186+2026-10-30 07:11:00,344.54,344.65,344.54,344.63,1509
187+2026-10-30 07:12:00,344.53,344.53,344.01,344.01,8786
188+2026-10-30 07:13:00,344.03,344.35,344.02,344.35,1267
189+2026-10-30 07:14:00,344.32,344.4468,344.12,344.25,1657
190+2026-10-30 07:15:00,344.3,344.56,344.2171,344.4,2270
191+2026-10-30 07:16:00,344.32,344.32,344.21,344.22,705
192+2026-10-30 07:17:00,344.31,344.4,344.07,344.1,5144
193+2026-10-30 07:18:00,344.1,344.2456,344.01,344.2456,2131
194+2026-10-30 07:19:00,344.31,344.3991,344.19,344.229,840
195+2026-10-30 07:20:00,344.34,344.4,344.2674,344.38,1270
196+2026-10-30 07:21:00,344.3,344.36,344.05,344.17,7499
197+2026-10-30 07:22:00,344.05,344.19,344.05,344.19,769
198+2026-10-30 07:23:00,344.2375,344.43,344.17,344.4284,822
199+2026-10-30 07:24:00,344.35,344.45,344.3,344.45,1211
200+2026-10-30 07:25:00,344.22,344.34,344.18,344.18,2200
201+2026-10-30 07:26:00,344.35,344.4,344.2,344.2,1181
202+2026-10-30 07:27:00,344.26,344.32,344.2,344.28,666
203+2026-10-30 07:28:00,344.3,344.46,344.21,344.21,3333
204+2026-10-30 07:29:00,344.3199,344.3929,344.17,344.3704,2653
205+2026-10-30 07:30:00,344.37,344.4,344.2148,344.3194,1392
206+2026-10-30 07:31:00,344.22,344.24,343.8,343.81,6882
207+2026-10-30 07:32:00,343.8,343.9369,343.63,343.6544,8104
208+2026-10-30 07:33:00,343.75,343.9447,343.6567,343.9447,1794
209+2026-10-30 07:34:00,343.92,344.1,343.85,344.02,11501
210+2026-10-30 07:35:00,344,344,343.85,343.85,4729
211+2026-10-30 07:36:00,343.85,343.85,343.51,343.65,10296
212+2026-10-30 07:37:00,343.5609,343.8,343.36,343.42,10879
213+2026-10-30 07:38:00,343.5,343.57,343.36,343.36,6666
214+2026-10-30 07:39:00,343.41,343.6533,343.36,343.505,3172
215+2026-10-30 07:40:00,343.36,343.59,343.36,343.38,7365
216+2026-10-30 07:41:00,343.36,343.57,343.36,343.46,2487
217+2026-10-30 07:42:00,343.57,343.57,342.5,342.84,26564
218+2026-10-30 07:43:00,342.82,343,342.39,342.39,12456
219+2026-10-30 07:44:00,342.39,342.6419,341.63,342,41526
220+2026-10-30 07:45:00,342.22,342.28,341.9,341.94,12190
221+2026-10-30 07:46:00,341.9909,342.07,341.68,342.07,5870
222+2026-10-30 07:47:00,342.07,342.18,341.85,342.12,3672
223+2026-10-30 07:48:00,342.16,342.46,342,342.23,3831
224+2026-10-30 07:49:00,342.28,342.4,341.85,342.29,9062
225+2026-10-30 07:50:00,342.1823,342.5,341.85,341.85,9475
226+2026-10-30 07:51:00,341.92,342.5,341.8794,342.08,5452
227+2026-10-30 07:52:00,342.28,342.28,342.09,342.22,2313
228+2026-10-30 07:53:00,342.15,342.49,342.15,342.3,6873
229+2026-10-30 07:54:00,342.32,342.4,342.05,342.39,18266
230+2026-10-30 07:55:00,342.47,342.7,342.25,342.3,10173
231+2026-10-30 07:56:00,342.51,343,342.455,342.73,12799
232+2026-10-30 07:57:00,342.73,342.83,342.63,342.7475,4367
233+2026-10-30 07:58:00,342.83,342.9,342.38,342.73,15689
234+2026-10-30 07:59:00,342.53,348.038,342.2,342.4647,8024
235+2026-10-30 08:00:00,342.3,342.49,342.24,342.41,7137
236+2026-10-30 08:01:00,342.46,342.75,341.85,341.98,22668
237+2026-10-30 08:02:00,342.1628,342.73,342.06,342.55,6622
238+2026-10-30 08:03:00,342.32,342.35,341.8183,341.8183,9937
239+2026-10-30 08:04:00,342.04,342.29,341.63,341.875,8891
240+2026-10-30 08:05:00,342,342.3,341.77,341.85,15839
241+2026-10-30 08:06:00,342.2,342.2,341.75,341.93,5018
242+2026-10-30 08:07:00,342.07,342.2,342,342.05,448
243+2026-10-30 08:08:00,342.05,342.17,341.76,341.9789,7746
244+2026-10-30 08:09:00,342.12,342.2797,342,342.03,7936
245+2026-10-30 08:10:00,341.93,341.9865,341.34,341.45,19861
246+2026-10-30 08:11:00,341.6,341.94,341.42,341.94,3249
247+2026-10-30 08:12:00,341.925,342.2,341.79,342.08,7138
248+2026-10-30 08:13:00,341.79,342.24,341.79,342.15,2565
249+2026-10-30 08:14:00,342.22,342.4,342.1,342.19,2177
250+2026-10-30 08:15:00,342.25,342.65,342,342.4,7461
251+2026-10-30 08:16:00,342.155,342.41,342.1,342.41,13747
252+2026-10-30 08:17:00,342.28,342.58,342.28,342.43,11915
253+2026-10-30 08:18:00,342.43,342.53,341.8,341.93,18688
254+2026-10-30 08:19:00,342,342.12,341.86,342.0417,4969
255+2026-10-30 08:20:00,342.14,342.49,342.04,342.065,2921
256+2026-10-30 08:21:00,342.11,342.18,341.93,341.93,11032
257+2026-10-30 08:22:00,341.99,342.71,341.99,342.66,9609
258+2026-10-30 08:23:00,342.69,342.81,342.49,342.8,12169
259+2026-10-30 08:24:00,342.8,343.2,342.76,343,28369
260+2026-10-30 08:25:00,343.1,343.48,343,343.3041,5617
261+2026-10-30 08:26:00,343.3989,343.64,343.3989,343.49,5408
262+2026-10-30 08:27:00,343.49,344,343.49,343.94,10352
263+2026-10-30 08:28:00,343.89,344.25,343.8,344.25,14343
264+2026-10-30 08:29:00,344.25,344.4,344.1,344.17,12306
265+2026-10-30 08:30:00,344.27,344.4,344,344.15,3354
266+2026-10-30 08:31:00,344.01,344.65,344,344.29,7757
267+2026-10-30 08:32:00,344.58,344.61,344.42,344.48,3422
268+2026-10-30 08:33:00,344.6,344.83,344.5,344.73,6533
269+2026-10-30 08:34:00,344.72,345.1,344.7,345,17165
270+2026-10-30 08:35:00,345.1,345.26,344.58,344.58,19792
271+2026-10-30 08:36:00,344.66,345.19,344.66,344.98,15821
272+2026-10-30 08:37:00,345,345.25,344.99,345.1708,4668
273+2026-10-30 08:38:00,345.2,345.25,345.1,345.12,6870
274+2026-10-30 08:39:00,345.21,345.38,345.1,345.34,9470
275+2026-10-30 08:40:00,345.2279,345.4,345.11,345.11,6901
276+2026-10-30 08:41:00,345.11,345.2446,345.1,345.11,7432
277+2026-10-30 08:42:00,345.37,345.37,345.1326,345.255,317
278+2026-10-30 08:43:00,345.3799,345.3799,345.1103,345.1103,2700
279+2026-10-30 08:44:00,345.2,345.41,345.1,345.26,9491
280+2026-10-30 08:45:00,345.45,345.45,345.12,345.13,2000
281+2026-10-30 08:46:00,345.12,345.28,345.1,345.1,3030
282+2026-10-30 08:47:00,345.17,345.25,345.1,345.25,3654
283+2026-10-30 08:48:00,345.15,345.8,345.15,345.47,11769
284+2026-10-30 08:49:00,345.7163,345.8,345.4118,345.4118,3288
285+2026-10-30 08:50:00,345.3909,345.71,345.3909,345.4262,3865
286+2026-10-30 08:51:00,345.51,345.85,345.51,345.85,5906
287+2026-10-30 08:52:00,345.7,345.74,345.65,345.74,144
288+2026-10-30 08:53:00,345.6,345.61,345.5,345.5,2754
289+2026-10-30 08:54:00,345.6,345.6,345.41,345.41,3297
290+2026-10-30 08:55:00,345.31,345.4,345.25,345.4,1961
291+2026-10-30 08:56:00,345.48,345.5654,345.3919,345.5654,1516
292+2026-10-30 08:57:00,345.34,345.41,345.34,345.41,1266
293+2026-10-30 08:58:00,345.33,345.42,345.297,345.42,5009
294+2026-10-30 08:59:00,345.38,345.42,345.28,345.28,564
295+2026-10-30 09:00:00,345.35,345.48,345.3032,345.34,598
296+2026-10-30 09:01:00,345.28,345.51,345.28,345.5,3768
297+2026-10-30 09:02:00,345.4829,345.6,345.4529,345.59,3792
298+2026-10-30 09:03:00,345.6,345.74,345,345,17991
299+2026-10-30 09:04:00,345.02,345.27,344.69,344.93,3078
300+2026-10-30 09:05:00,344.93,345,344.5,344.5,3480
301+2026-10-30 09:06:00,344.52,344.7291,344.45,344.6,7453
302+2026-10-30 09:07:00,344.65,344.67,344.5,344.6,4991
303+2026-10-30 09:08:00,344.58,344.6997,344.41,344.41,1987
304+2026-10-30 09:09:00,344.6,344.6,344.6,344.6,98
305+2026-10-30 09:10:00,344.6,344.8218,344.48,344.48,1423
306+2026-10-30 09:11:00,344.6866,344.6866,344.45,344.5,632
307+2026-10-30 09:12:00,344.6,344.6,344.45,344.6,7484
308+2026-10-30 09:13:00,344.6,344.89,344.5395,344.89,1319
309+2026-10-30 09:14:00,344.9,345.02,344.78,344.87,1623
310+2026-10-30 09:15:00,344.99,345.07,344.96,345.065,961
311+2026-10-30 09:16:00,344.9642,345.25,344.9642,345.25,8979
312+2026-10-30 09:17:00,345.25,345.4,345.17,345.37,2601
313+2026-10-30 09:18:00,345.3,345.49,345.3,345.35,1851
314+2026-10-30 09:19:00,345.42,345.5,345.4,345.5,5830
315+2026-10-30 09:20:00,345.5,345.9197,345.5,345.8,3504
316+2026-10-30 09:21:00,345.82,345.92,345.606,345.6068,1950
317+2026-10-30 09:22:00,345.6409,345.82,345.6409,345.7357,3146
318+2026-10-30 09:23:00,345.82,345.9,345.69,345.69,10838
319+2026-10-30 09:24:00,345.665,345.89,345.4,345.4,2235
320+2026-10-30 09:25:00,344.64,345.3,344.26,344.88,13273
321+2026-10-30 09:26:00,344.925,345.05,344.8,345.02,11133
322+2026-10-30 09:27:00,345.07,345.8,345.07,345.45,11809
323+2026-10-30 09:28:00,345.45,346.075,345.45,346,10500
324+2026-10-30 09:29:00,346.01,346.87,346.01,346.77,20731
325+2026-10-30 09:30:00,346.63,348.51,346.62,348.26,1119437
326+2026-10-30 09:31:00,348.22,348.7696,347.01,347.49,332655
327+2026-10-30 09:32:00,347.47,347.54,346.35,346.775,178187
328+2026-10-30 09:33:00,346.78,347.12,345.8,346.03,193970
329+2026-10-30 09:34:00,346.03,346.14,345.02,345.805,293295
330+2026-10-30 09:35:00,345.78,346.29,345.18,345.33,201111
331+2026-10-30 09:36:00,345.355,345.78,345.14,345.405,219182
332+2026-10-30 09:37:00,345.39,346.58,345.36,346.515,188970
333+2026-10-30 09:38:00,346.51,346.74,345.54,345.84,209034
334+2026-10-30 09:39:00,345.84,345.84,344.42,345.02,206897
335+2026-10-30 09:40:00,345.02,345.73,344.97,345.39,143503
336+2026-10-30 09:41:00,345.46,345.75,344.7,344.82,110118
337+2026-10-30 09:42:00,344.732,345.13,344.68,345.105,88045
338+2026-10-30 09:43:00,345.08,345.13,344.58,344.64,94550
339+2026-10-30 09:44:00,344.665,344.7378,344.21,344.23,150840
340+2026-10-30 09:45:00,344.23,344.9899,344.13,344.42,199452
341+2026-10-30 09:46:00,344.405,345.11,344.31,345.07,105369
342+2026-10-30 09:47:00,345.1,345.61,344.95,345.33,149911
343+2026-10-30 09:48:00,345.345,345.75,345.2137,345.395,83057
344+2026-10-30 09:49:00,345.39,345.55,345.13,345.35,68085
345+2026-10-30 09:50:00,345.33,345.39,344.78,345.125,103496
346+2026-10-30 09:51:00,345.125,345.56,344.95,345.27,70692
347+2026-10-30 09:52:00,345.38,345.755,345.27,345.66,103485
348+2026-10-30 09:53:00,345.65,345.79,345.44,345.58,93435
349+2026-10-30 09:54:00,345.56,345.6441,345.16,345.33,86014
350+2026-10-30 09:55:00,345.3,345.59,345.08,345.49,56822
351+2026-10-30 09:56:00,345.49,345.5092,345.06,345.17,68035
352+2026-10-30 09:57:00,345.17,345.34,344.86,344.93,66902
353+2026-10-30 09:58:00,344.915,345.05,344.74,344.97,68003
354+2026-10-30 09:59:00,344.94,345.3942,344.9,345.33,100916
355+2026-10-30 10:00:00,345.29,345.31,344.97,345.2675,58195
356+2026-10-30 10:01:00,345.265,345.38,345.07,345.15,123448
357+2026-10-30 10:02:00,345.13,345.42,345.01,345.08,67254
358+2026-10-30 10:03:00,345.1,345.39,344.97,345.37,63729
359+2026-10-30 10:04:00,345.3999,345.4,344.95,344.995,54828
360+2026-10-30 10:05:00,344.995,345.15,344.9,345.12,42917
361+2026-10-30 10:06:00,345.1391,345.22,344.89,345.13,55264
362+2026-10-30 10:07:00,345.15,345.38,344.97,345.32,52336
363+2026-10-30 10:08:00,345.345,345.5175,345.22,345.26,45178
364+2026-10-30 10:09:00,345.305,345.305,344.9501,345.11,58863
365+2026-10-30 10:10:00,345.105,345.33,345.02,345.14,54658
366+2026-10-30 10:11:00,345.14,345.62,345.0401,345.32,93120
367+2026-10-30 10:12:00,345.31,345.44,344.99,345.0406,51920
368+2026-10-30 10:13:00,345.065,345.2701,344.984,345.23,75768
369+2026-10-30 10:14:00,345.2,345.66,345.12,345.545,110930
370+2026-10-30 10:15:00,345.545,345.69,345.31,345.495,71939
371+2026-10-30 10:16:00,345.47,345.9,345.46,345.795,76175
372+2026-10-30 10:17:00,345.82,346.2751,345.77,346.175,155482
373+2026-10-30 10:18:00,346.16,346.2,345.83,345.9,91184
374+2026-10-30 10:19:00,345.93,345.99,345.53,345.57,53989
375+2026-10-30 10:20:00,345.57,345.722,345.35,345.43,57874
376+2026-10-30 10:21:00,345.435,345.76,345.35,345.635,73565
377+2026-10-30 10:22:00,345.6101,346.145,345.6101,346,152813
378+2026-10-30 10:23:00,345.97,346.04,345.75,345.93,82302
379+2026-10-30 10:24:00,345.91,346,345.7,345.76,57865
380+2026-10-30 10:25:00,345.76,346,345.68,345.89,82249
381+2026-10-30 10:26:00,345.91,346.05,345.53,345.6,69549
382+2026-10-30 10:27:00,345.55,346.0099,345.52,345.904,90878
383+2026-10-30 10:28:00,345.91,346.09,345.83,346.03,78350
384+2026-10-30 10:29:00,346.03,346.48,346.01,346.33,76214
385+2026-10-30 10:30:00,346.3,346.7,346.19,346.53,120926
386+2026-10-30 10:31:00,346.53,346.79,346.53,346.74,104868
387+2026-10-30 10:32:00,346.755,346.755,346.36,346.62,85281
388+2026-10-30 10:33:00,346.59,346.82,346.57,346.68,60140
389+2026-10-30 10:34:00,346.69,346.91,346.58,346.74,112459
390+2026-10-30 10:35:00,346.73,347.15,346.69,346.745,141967
391+2026-10-30 10:36:00,346.74,346.7466,346.49,346.665,41951
392+2026-10-30 10:37:00,346.65,346.81,346.55,346.74,45014
393+2026-10-30 10:38:00,346.73,347.515,346.73,347.5,79736
394+2026-10-30 10:39:00,347.515,347.78,347.3185,347.7,101190
395+2026-10-30 10:40:00,347.68,348.1,347.62,348.015,90676
396+2026-10-30 10:41:00,348.02,348.27,347.8,348.085,110567
397+2026-10-30 10:42:00,348.06,348.53,348.05,348.46,131786
398+2026-10-30 10:43:00,348.45,348.74,348.39,348.64,104227
399+2026-10-30 10:44:00,348.64,348.87,348.44,348.83,176949
400+2026-10-30 10:45:00,348.81,349,348.67,348.975,151393
401+2026-10-30 10:46:00,348.95,348.96,348.51,348.79,103811
402+2026-10-30 10:47:00,348.81,348.9,348.69,348.715,62961
403+2026-10-30 10:48:00,348.715,348.88,348.6,348.88,69054
404+2026-10-30 10:49:00,348.882,349.14,348.7,349.03,165982
405+2026-10-30 10:50:00,349.06,349.06,348.81,348.945,84493
406+2026-10-30 10:51:00,348.94,349.0719,348.88,349.06,64569
407+2026-10-30 10:52:00,349.04,349.19,348.96,349.19,112475
408+2026-10-30 10:53:00,349.19,349.19,348.74,348.74,87560
409+2026-10-30 10:54:00,348.74,349,348.68,348.94,69964
410+2026-10-30 10:55:00,348.92,348.92,348.5501,348.72,61714
411+2026-10-30 10:56:00,348.7,348.8999,348.53,348.845,52696
412+2026-10-30 10:57:00,348.84,348.8599,348.55,348.565,40629
413+2026-10-30 10:58:00,348.53,348.56,348.17,348.195,69275
414+2026-10-30 10:59:00,348.195,348.57,348.16,348.48,65334
415+2026-10-30 11:00:00,348.495,348.62,348.36,348.4755,40526
416+2026-10-30 11:01:00,348.5,348.6,348.34,348.52,71961
417+2026-10-30 11:02:00,348.52,348.7,348.4,348.45,39721
418+2026-10-30 11:03:00,348.46,348.54,348.3141,348.455,29921
419+2026-10-30 11:04:00,348.44,348.57,348.32,348.365,43566
420+2026-10-30 11:05:00,348.3428,348.3428,347.89,347.93,61713
421+2026-10-30 11:06:00,347.89,348.1601,347.86,348.06,51319
422+2026-10-30 11:07:00,348.02,348.08,347.67,347.73,71941
423+2026-10-30 11:08:00,347.72,348.05,347.59,347.91,43131
424+2026-10-30 11:09:00,347.91,348.12,347.85,348.01,33673
425+2026-10-30 11:10:00,348.025,348.07,347.73,347.84,36494
426+2026-10-30 11:11:00,347.84,348.17,347.8204,347.995,48612
427+2026-10-30 11:12:00,348.03,348.11,347.92,347.96,49539
428+2026-10-30 11:13:00,347.92,347.97,347.7101,347.8233,56882
429+2026-10-30 11:14:00,347.838,348.08,347.79,347.87,49949
430+2026-10-30 11:15:00,347.885,347.895,347.722,347.765,26935
431+2026-10-30 11:16:00,347.76,347.76,347.41,347.43,52206
432+2026-10-30 11:17:00,347.41,347.82,347.41,347.66,79686
433+2026-10-30 11:18:00,347.7099,347.7826,347.45,347.47,46006
434+2026-10-30 11:19:00,347.47,347.92,347.44,347.8401,45719
435+2026-10-30 11:20:00,347.87,347.96,347.78,347.86,38967
436+2026-10-30 11:21:00,347.88,348.25,347.88,348.145,69925
437+2026-10-30 11:22:00,348.15,348.405,348.15,348.27,77094
438+2026-10-30 11:23:00,348.29,348.68,348.25,348.44,81872
439+2026-10-30 11:24:00,348.43,348.575,348.29,348.435,72765
440+2026-10-30 11:25:00,348.46,348.51,348.16,348.265,62080
441+2026-10-30 11:26:00,348.265,348.34,347.96,348,30852
442+2026-10-30 11:27:00,347.995,348.14,347.94,348.13,27688
443+2026-10-30 11:28:00,348.16,348.1831,347.95,347.95,66435
444+2026-10-30 11:29:00,347.95,347.99,347.69,347.69,48882
445+2026-10-30 11:30:00,347.69,347.88,347.5501,347.589,54085
446+2026-10-30 11:31:00,347.585,347.65,347.35,347.37,59026
447+2026-10-30 11:32:00,347.39,347.4995,347.28,347.45,36369
448+2026-10-30 11:33:00,347.46,347.74,347.46,347.7,46972
449+2026-10-30 11:34:00,347.7399,347.77,347.44,347.55,39831
450+2026-10-30 11:35:00,347.56,347.58,347.33,347.338,38696
451+2026-10-30 11:36:00,347.38,347.38,347.1,347.245,37276
452+2026-10-30 11:37:00,347.23,347.37,347.1901,347.235,41698
453+2026-10-30 11:38:00,347.25,347.33,347.12,347.21,58283
454+2026-10-30 11:39:00,347.21,347.28,347.13,347.265,24382
455+2026-10-30 11:40:00,347.2899,347.33,347.13,347.18,30043
456+2026-10-30 11:41:00,347.18,347.19,346.87,346.87,60473
457+2026-10-30 11:42:00,346.9085,347.49,346.73,347.46,109910
458+2026-10-30 11:43:00,347.46,347.65,347.37,347.5,88879
459+2026-10-30 11:44:00,347.5,347.56,347.12,347.1201,50417
460+2026-10-30 11:45:00,347.1552,347.29,347.05,347.125,73051
461+2026-10-30 11:46:00,347.1001,347.24,346.8537,347.0623,98773
462+2026-10-30 11:47:00,347.08,347.31,346.97,347.23,124076
463+2026-10-30 11:48:00,347.21,347.36,347.16,347.31,42500
464+2026-10-30 11:49:00,347.27,347.4,347.24,347.26,37422
465+2026-10-30 11:50:00,347.24,347.25,347.06,347.17,53849
466+2026-10-30 11:51:00,347.155,347.24,346.954,347.02,31931
467+2026-10-30 11:52:00,347.01,347.33,346.99,347.25,85831
468+2026-10-30 11:53:00,347.25,347.485,347.2009,347.39,129504
469+2026-10-30 11:54:00,347.3701,347.505,347.21,347.3606,78406
470+2026-10-30 11:55:00,347.38,347.51,347.33,347.43,49311
471+2026-10-30 11:56:00,347.45,347.51,347.24,347.24,50625
472+2026-10-30 11:57:00,347.245,347.27,347.125,347.225,31885
473+2026-10-30 11:58:00,347.21,347.225,347.06,347.15,44415
474+2026-10-30 11:59:00,347.135,347.18,346.9,346.925,49327
475+2026-10-30 12:00:00,346.94,346.95,346.35,346.35,79714
476+2026-10-30 12:01:00,346.355,346.38,346.04,346.06,79500
477+2026-10-30 12:02:00,346.045,346.41,346.0298,346.38,56664
478+2026-10-30 12:03:00,346.37,346.64,346.24,346.24,40271
479+2026-10-30 12:04:00,346.255,346.255,346.01,346.01,43570
480+2026-10-30 12:05:00,346.04,346.13,345.6711,345.87,67165
481+2026-10-30 12:06:00,345.89,346.21,345.72,346.13,53409
482+2026-10-30 12:07:00,346.1112,346.27,345.99,346.2,52123
483+2026-10-30 12:08:00,346.24,346.46,346.24,346.37,53504
484+2026-10-30 12:09:00,346.37,346.39,346.24,346.28,24167
485+2026-10-30 12:10:00,346.285,346.64,346.285,346.58,60442
486+2026-10-30 12:11:00,346.615,346.79,346.54,346.67,119971
487+2026-10-30 12:12:00,346.66,347.16,346.6,347.05,189718
488+2026-10-30 12:13:00,347.05,347.09,346.73,346.84,86871
489+2026-10-30 12:14:00,346.8401,346.85,346.65,346.8322,116606
490+2026-10-30 12:15:00,346.79,347.06,346.785,346.945,119968
491+2026-10-30 12:16:00,346.97,347.19,346.93,347.14,53182
492+2026-10-30 12:17:00,347.15,347.4,347,347.385,72916
493+2026-10-30 12:18:00,347.385,347.53,347.27,347.305,105473
494+2026-10-30 12:19:00,347.2832,347.545,347.27,347.525,61226
495+2026-10-30 12:20:00,347.525,347.6,347.46,347.56,78476
496+2026-10-30 12:21:00,347.54,347.59,347.29,347.37,57823
497+2026-10-30 12:22:00,347.39,347.42,347.26,347.3125,51626
498+2026-10-30 12:23:00,347.31,347.46,347.3,347.41,75925
499+2026-10-30 12:24:00,347.4,347.42,347.25,347.3,64960
500+2026-10-30 12:25:00,347.3,347.46,347.27,347.44,53230
501+2026-10-30 12:26:00,347.44,347.58,347.4,347.49,50556
502+2026-10-30 12:27:00,347.49,347.66,347.4704,347.61,47461
503+2026-10-30 12:28:00,347.59,347.72,347.55,347.6,54498
504+2026-10-30 12:29:00,347.585,347.7,347.5,347.5,45403
505+2026-10-30 12:30:00,347.495,347.53,347.1,347.295,50461
506+2026-10-30 12:31:00,347.28,347.37,347.25,347.34,24193
507+2026-10-30 12:32:00,347.36,347.38,346.9784,347.02,39575
508+2026-10-30 12:33:00,347,347.175,347,347.13,26807
509+2026-10-30 12:34:00,347.14,347.16,346.95,346.985,63772
510+2026-10-30 12:35:00,346.975,347.22,346.9457,347.08,56204
511+2026-10-30 12:36:00,347.06,347.44,347.0358,347.32,78520
512+2026-10-30 12:37:00,347.35,347.56,347.29,347.38,125647
513+2026-10-30 12:38:00,347.38,347.52,347.32,347.42,58439
514+2026-10-30 12:39:00,347.45,347.62,347.44,347.55,45870
515+2026-10-30 12:40:00,347.54,347.82,347.43,347.74,70528
516+2026-10-30 12:41:00,347.755,347.93,347.7,347.86,63027
517+2026-10-30 12:42:00,347.87,347.97,347.66,347.7287,69901
518+2026-10-30 12:43:00,347.74,347.74,347.49,347.55,41958
519+2026-10-30 12:44:00,347.52,347.65,347.42,347.43,40273
520+2026-10-30 12:45:00,347.435,347.52,347.29,347.3969,56012
521+2026-10-30 12:46:00,347.385,347.45,347.16,347.2,94530
522+2026-10-30 12:47:00,347.2,347.37,347.17,347.27,53649
523+2026-10-30 12:48:00,347.275,347.71,347.275,347.59,51527
524+2026-10-30 12:49:00,347.57,347.645,347.46,347.595,79094
525+2026-10-30 12:50:00,347.595,347.595,347.28,347.33,69418
526+2026-10-30 12:51:00,347.32,347.41,347.22,347.3975,69816
527+2026-10-30 12:52:00,347.39,347.58,347.3001,347.51,49029
528+2026-10-30 12:53:00,347.49,347.58,347.41,347.425,65838
529+2026-10-30 12:54:00,347.41,347.47,347.18,347.235,41441
530+2026-10-30 12:55:00,347.21,347.28,346.83,347.02,76677
531+2026-10-30 12:56:00,347.025,347.15,346.84,346.85,36809
532+2026-10-30 12:57:00,346.875,347.14,346.8501,346.98,45359
533+2026-10-30 12:58:00,346.96,347.15,346.83,347.035,116541
534+2026-10-30 12:59:00,347.04,347.14,346.93,346.955,43370
535+2026-10-30 13:00:00,346.98,347.01,346.77,346.97,53599
536+2026-10-30 13:01:00,346.99,347.04,346.79,347,41704
537+2026-10-30 13:02:00,347.005,347.1,346.95,347.03,35360
538+2026-10-30 13:03:00,347,347.2,346.9,347.07,64437
539+2026-10-30 13:04:00,347.06,347.17,346.98,347.145,39305
540+2026-10-30 13:05:00,347.16,347.2,346.99,347.05,47649
541+2026-10-30 13:06:00,347.02,347.19,346.89,346.92,85570
542+2026-10-30 13:07:00,346.94,346.98,346.7,346.75,47888
543+2026-10-30 13:08:00,346.745,346.82,346.62,346.68,44061
544+2026-10-30 13:09:00,346.69,346.82,346.6401,346.695,49296
545+2026-10-30 13:10:00,346.71,346.91,346.65,346.79,46548
546+2026-10-30 13:11:00,346.76,346.78,346.6,346.64,80931
547+2026-10-30 13:12:00,346.63,346.875,346.6,346.875,48045
548+2026-10-30 13:13:00,346.86,346.96,346.7,346.7701,73700
549+2026-10-30 13:14:00,346.81,347.01,346.78,347.01,45458
550+2026-10-30 13:15:00,347.02,347.15,346.97,347.08,63034
551+2026-10-30 13:16:00,347.06,347.21,347,347.0999,58539
552+2026-10-30 13:17:00,347.1,347.12,346.99,347.05,32775
553+2026-10-30 13:18:00,347.06,347.12,346.93,347.01,50341
554+2026-10-30 13:19:00,347.03,347.04,346.89,346.9401,61698
555+2026-10-30 13:20:00,346.96,347.01,346.76,346.805,90115
556+2026-10-30 13:21:00,346.805,346.91,346.67,346.85,98179
557+2026-10-30 13:22:00,346.85,347.13,346.79,347.0901,63679
558+2026-10-30 13:23:00,347.1163,347.2199,347.03,347.08,76763
559+2026-10-30 13:24:00,347.065,347.2199,346.95,346.9601,91691
560+2026-10-30 13:25:00,346.94,347.06,346.6,346.67,144966
561+2026-10-30 13:26:00,346.675,346.76,346.372,346.39,106997
562+2026-10-30 13:27:00,346.3793,346.5798,346.245,346.54,54608
563+2026-10-30 13:28:00,346.54,346.69,346.34,346.56,99526
564+2026-10-30 13:29:00,346.56,346.66,346.47,346.55,47653
565+2026-10-30 13:30:00,346.56,346.6899,346.45,346.64,46586
566+2026-10-30 13:31:00,346.635,346.645,346.28,346.38,102303
567+2026-10-30 13:32:00,346.33,346.47,346.24,346.37,56006
568+2026-10-30 13:33:00,346.38,346.5399,346.35,346.43,33760
569+2026-10-30 13:34:00,346.43,346.65,346.43,346.606,54625
570+2026-10-30 13:35:00,346.6,346.65,346.44,346.49,44340
571+2026-10-30 13:36:00,346.48,346.62,346.3,346.5,72065
572+2026-10-30 13:37:00,346.515,346.66,346.31,346.386,50702
573+2026-10-30 13:38:00,346.355,346.43,346.25,346.275,55367
574+2026-10-30 13:39:00,346.275,346.275,346.1,346.13,53646
575+2026-10-30 13:40:00,346.135,346.24,346.01,346.08,60345
576+2026-10-30 13:41:00,346.04,346.2,345.88,345.925,76841
577+2026-10-30 13:42:00,345.94,346.06,345.8801,345.955,47008
578+2026-10-30 13:43:00,345.96,346.04,345.76,345.795,60034
579+2026-10-30 13:44:00,345.81,346.24,345.69,346.0436,80136
580+2026-10-30 13:45:00,346.01,346.18,345.8039,345.86,63275
581+2026-10-30 13:46:00,345.884,345.93,345.59,345.77,77547
582+2026-10-30 13:47:00,345.77,345.78,345.39,345.59,126788
583+2026-10-30 13:48:00,345.6048,345.64,345.31,345.35,56676
584+2026-10-30 13:49:00,345.37,345.56,345.32,345.525,48264
585+2026-10-30 13:50:00,345.54,345.65,345.22,345.23,60259
586+2026-10-30 13:51:00,345.22,345.43,345.19,345.37,60723
587+2026-10-30 13:52:00,345.345,345.365,345.12,345.1966,65488
588+2026-10-30 13:53:00,345.2,345.25,344.96,344.99,77953
589+2026-10-30 13:54:00,344.98,345.04,344.8,344.865,85602
590+2026-10-30 13:55:00,344.865,345.05,344.82,344.93,76660
591+2026-10-30 13:56:00,344.9155,344.9689,344.31,344.55,136337
592+2026-10-30 13:57:00,344.56,344.58,344.38,344.5532,87131
593+2026-10-30 13:58:00,344.56,344.93,344.4,344.71,129795
594+2026-10-30 13:59:00,344.725,345.13,344.585,344.585,93365
595+2026-10-30 14:00:00,344.59,344.705,344.5,344.695,92613
596+2026-10-30 14:01:00,344.695,344.7,344.41,344.58,88961
597+2026-10-30 14:02:00,344.545,344.58,344.07,344.15,131077
598+2026-10-30 14:03:00,344.135,344.34,343.97,344.28,140715
599+2026-10-30 14:04:00,344.26,344.55,344.17,344.5,80683
600+2026-10-30 14:05:00,344.51,344.55,344.21,344.235,71764
601+2026-10-30 14:06:00,344.24,344.43,343.76,343.81,79093
602+2026-10-30 14:07:00,343.77,343.83,343.5,343.67,129026
603+2026-10-30 14:08:00,343.67,343.756,343.37,343.41,145989
604+2026-10-30 14:09:00,343.3932,343.43,343.24,343.38,158548
605+2026-10-30 14:10:00,343.3614,343.4949,343.03,343.05,111151
606+2026-10-30 14:11:00,343.05,343.55,342.95,343.505,183765
607+2026-10-30 14:12:00,343.505,343.65,343.25,343.42,143969
608+2026-10-30 14:13:00,343.42,343.64,343.24,343.3,109760
609+2026-10-30 14:14:00,343.29,343.69,343.25,343.675,191431
610+2026-10-30 14:15:00,343.67,343.85,343.39,343.5,60064
611+2026-10-30 14:16:00,343.52,343.77,343.4802,343.665,153079
612+2026-10-30 14:17:00,343.6574,343.85,343.45,343.47,46871
613+2026-10-30 14:18:00,343.4401,343.49,343.19,343.35,71915
614+2026-10-30 14:19:00,343.3158,343.49,343.225,343.3999,63682
615+2026-10-30 14:20:00,343.39,343.41,342.97,343.02,72181
616+2026-10-30 14:21:00,343.02,343.13,342.9,343.0701,53337
617+2026-10-30 14:22:00,343.1,343.4682,343.08,343.365,57371
618+2026-10-30 14:23:00,343.365,343.85,343.34,343.7423,176315
619+2026-10-30 14:24:00,343.775,344.14,334.35,343.885,165230
620+2026-10-30 14:25:00,343.86,343.86,343.64,343.7,51567
621+2026-10-30 14:26:00,343.72,343.73,343.4261,343.46,52013
622+2026-10-30 14:27:00,343.46,343.54,343.11,343.145,47773
623+2026-10-30 14:28:00,343.1203,343.26,342.99,343.11,82208
624+2026-10-30 14:29:00,343.11,343.28,343.0401,343.1342,40101
625+2026-10-30 14:30:00,343.14,343.26,342.945,343.125,59358
626+2026-10-30 14:31:00,343.1,343.45,342.95,343.095,188109
627+2026-10-30 14:32:00,343.085,343.32,343.085,343.14,38003
628+2026-10-30 14:33:00,343.13,343.23,342.99,343.13,39660
629+2026-10-30 14:34:00,343.11,343.16,342.85,342.915,63813
630+2026-10-30 14:35:00,342.93,343.11,342.83,343.02,72677
631+2026-10-30 14:36:00,342.9916,343.27,342.9916,343.19,87074
632+2026-10-30 14:37:00,343.22,343.39,343.16,343.28,62265
633+2026-10-30 14:38:00,343.28,343.34,343.16,343.33,50547
634+2026-10-30 14:39:00,343.32,343.67,343.23,343.61,101275
635+2026-10-30 14:40:00,343.615,343.84,343.55,343.59,102816
636+2026-10-30 14:41:00,343.595,343.67,343.3,343.46,70071
637+2026-10-30 14:42:00,343.5,343.56,343.1,343.1913,33250
638+2026-10-30 14:43:00,343.19,343.52,343.17,343.375,37926
639+2026-10-30 14:44:00,343.36,343.43,343.22,343.32,44803
640+2026-10-30 14:45:00,343.32,343.36,342.99,343.005,84413
641+2026-10-30 14:46:00,342.99,343.14,342.91,342.93,46496
642+2026-10-30 14:47:00,342.935,343.16,342.93,343.05,34258
643+2026-10-30 14:48:00,343.02,343.13,342.97,343.115,47263
644+2026-10-30 14:49:00,343.115,343.14,342.91,342.93,41290
645+2026-10-30 14:50:00,342.91,342.93,342.81,342.845,74799
646+2026-10-30 14:51:00,342.84,342.9,342.7,342.76,86467
647+2026-10-30 14:52:00,342.7533,342.83,342.53,342.55,61837
648+2026-10-30 14:53:00,342.58,342.7,342.4,342.57,49244
649+2026-10-30 14:54:00,342.58,342.6,342.35,342.49,73954
650+2026-10-30 14:55:00,342.51,342.77,342.435,342.71,72141
651+2026-10-30 14:56:00,342.73,343.17,342.685,343.147,83869
652+2026-10-30 14:57:00,343.13,343.37,343.05,343.365,106859
653+2026-10-30 14:58:00,343.365,343.44,343.2485,343.315,53034
654+2026-10-30 14:59:00,343.315,343.47,343.24,343.275,40223
655+2026-10-30 15:00:00,343.29,343.55,343.225,343.225,56149
656+2026-10-30 15:01:00,343.2,343.31,343.06,343.21,58153
657+2026-10-30 15:02:00,343.23,343.28,342.97,343.02,53309
658+2026-10-30 15:03:00,343.02,343.25,343.01,343.1,23387
659+2026-10-30 15:04:00,343.1,343.13,342.78,342.86,49540
660+2026-10-30 15:05:00,342.86,342.97,342.66,342.7,43351
661+2026-10-30 15:06:00,342.7,342.98,342.61,342.895,58609
662+2026-10-30 15:07:00,342.8701,343.13,342.8701,343.055,73114
663+2026-10-30 15:08:00,343.055,343.46,342.9701,343.37,76618
664+2026-10-30 15:09:00,343.37,343.39,343.17,343.288,73857
665+2026-10-30 15:10:00,343.27,343.45,343.18,343.2725,57325
666+2026-10-30 15:11:00,343.27,343.27,343.09,343.17,46008
667+2026-10-30 15:12:00,343.17,343.39,343.08,343.26,124738
668+2026-10-30 15:13:00,343.26,343.41,343.21,343.32,57288
669+2026-10-30 15:14:00,343.31,343.47,343.3,343.34,46747
670+2026-10-30 15:15:00,343.35,343.66,343.14,343.6,104278
671+2026-10-30 15:16:00,343.56,343.605,343.18,343.18,63034
672+2026-10-30 15:17:00,343.2,343.25,343,343.06,65410
673+2026-10-30 15:18:00,343.06,343.06,342.78,342.795,63507
674+2026-10-30 15:19:00,342.795,343.15,342.79,342.99,65680
675+2026-10-30 15:20:00,342.995,343.21,342.96,343.1,45053
676+2026-10-30 15:21:00,343.095,343.22,343.02,343.05,62561
677+2026-10-30 15:22:00,343.04,343.35,343.01,343.32,85071
678+2026-10-30 15:23:00,343.315,343.38,343.11,343.18,46466
679+2026-10-30 15:24:00,343.18,343.39,343.18,343.19,51665
680+2026-10-30 15:25:00,343.21,343.43,343.11,343.37,71571
681+2026-10-30 15:26:00,343.39,343.67,343.37,343.62,163488
682+2026-10-30 15:27:00,343.6,343.73,343.54,343.71,98316
683+2026-10-30 15:28:00,343.715,343.86,343.52,343.56,107169
684+2026-10-30 15:29:00,343.575,343.59,343.36,343.52,51043
685+2026-10-30 15:30:00,343.52,343.65,343.25,343.4,128378
686+2026-10-30 15:31:00,343.39,343.45,343.14,343.37,86842
687+2026-10-30 15:32:00,343.3775,343.61,343.28,343.585,67520
688+2026-10-30 15:33:00,343.585,343.86,343.5308,343.69,96572
689+2026-10-30 15:34:00,343.7,344.13,343.69,343.99,140262
690+2026-10-30 15:35:00,344,344.38,343.84,344.21,200823
691+2026-10-30 15:36:00,344.22,344.48,344.08,344.18,150544
692+2026-10-30 15:37:00,344.19,344.36,344.0901,344.19,54890
693+2026-10-30 15:38:00,344.18,344.1917,343.74,343.83,72542
694+2026-10-30 15:39:00,343.835,344.1774,343.7858,344.115,56277
695+2026-10-30 15:40:00,344.1299,344.31,344.06,344.29,74753
696+2026-10-30 15:41:00,344.305,344.32,343.7,343.87,87245
697+2026-10-30 15:42:00,343.87,344.19,343.8448,344.08,73841
698+2026-10-30 15:43:00,344.09,344.12,343.98,344.08,49257
699+2026-10-30 15:44:00,344.1,344.2,344.01,344.16,45423
700+2026-10-30 15:45:00,344.16,344.38,343.98,344,100400
701+2026-10-30 15:46:00,344,344.01,343.83,343.865,51688
702+2026-10-30 15:47:00,343.86,344.06,343.82,343.89,89642
703+2026-10-30 15:48:00,343.905,344.15,343.905,344.06,59783
704+2026-10-30 15:49:00,344.06,344.62,344.06,344.48,121716
705+2026-10-30 15:50:00,344.51,344.755,344.16,344.705,192607
706+2026-10-30 15:51:00,344.71,344.86,344.51,344.76,218387
707+2026-10-30 15:52:00,344.745,345.06,344.59,344.95,222326
708+2026-10-30 15:53:00,344.94,346,344.89,345.558,256113
709+2026-10-30 15:54:00,345.59,345.75,344.53,344.62,439675
710+2026-10-30 15:55:00,344.605,345.11,344.15,344.785,350521
711+2026-10-30 15:56:00,344.78,344.785,344.15,344.21,242944
712+2026-10-30 15:57:00,344.23,344.9,344.16,344.8899,295256
713+2026-10-30 15:58:00,344.87,344.91,344.6,344.685,307978
714+2026-10-30 15:59:00,344.7,345.07,344.52,344.8,797147
715+2026-10-30 16:00:00,344.8,345.01,344.63,344.79,12902672
716+2026-10-30 16:01:00,344.68,345.1,344.63,344.65,9780
717+2026-10-30 16:02:00,344.65,344.68,344.48,344.48,9872
718+2026-10-30 16:03:00,344.36,344.36,344.17,344.3,5108
719+2026-10-30 16:04:00,344.35,344.55,344.3,344.5,1281
720+2026-10-30 16:05:00,344.3,344.42,344.17,344.39,1653
721+2026-10-30 16:06:00,344.4,344.44,344.4,344.44,10022
722+2026-10-30 16:07:00,344.4,344.43,344.2696,344.2696,2294
723+2026-10-30 16:08:00,344.37,344.44,344.31,344.44,558
724+2026-10-30 16:09:00,344.44,344.5,344.44,344.5,1659
725+2026-10-30 16:10:00,344.46,344.5,344.44,344.46,1540
726+2026-10-30 16:11:00,344.5,344.5,344.47,344.47,434
727+2026-10-30 16:12:00,344.47,344.5,344.47,344.5,1701
728+2026-10-30 16:13:00,344.51,344.63,344.4126,344.63,655
729+2026-10-30 16:14:00,344.62,344.62,344.6,344.6,140
730+2026-10-30 16:15:00,344.55,344.55,344.41,344.54,1740
731+2026-10-30 16:16:00,344.5,344.5,344.5,344.5,56
732+2026-10-30 16:17:00,344.5,344.53,344.44,344.53,10608
733+2026-10-30 16:18:00,344.51,344.62,344.45,344.59,15345
734+2026-10-30 16:19:00,344.6,344.6,344.57,344.57,145
735+2026-10-30 16:20:00,344.4501,344.57,344.4501,344.54,1028
736+2026-10-30 16:21:00,344.54,344.54,344.37,344.37,5064
737+2026-10-30 16:22:00,344.3979,344.45,344.3979,344.43,343
738+2026-10-30 16:23:00,344.41,344.41,344.4,344.4,1970
739+2026-10-30 16:24:00,344.42,344.42,344.33,344.33,1579
740+2026-10-30 16:26:00,344.38,344.38,344.38,344.38,116
741+2026-10-30 16:27:00,344.425,344.43,344.37,344.38,1198
742+2026-10-30 16:28:00,344.37,344.45,344.37,344.45,100
743+2026-10-30 16:29:00,344.47,344.55,344.47,344.55,808
744+2026-10-30 16:30:00,344.5694,344.5694,344.5694,344.5694,500
745+2026-10-30 16:32:00,344.583,344.64,344.583,344.64,1747
746+2026-10-30 16:33:00,344.61,344.65,344.6,344.6,3426
747+2026-10-30 16:35:00,344.62,344.62,344.6,344.6,827
748+2026-10-30 16:36:00,344.57,344.57,344.55,344.5586,820
749+2026-10-30 16:38:00,344.55,344.61,344.55,344.61,1009
750+2026-10-30 16:39:00,344.63,344.9,344.63,344.9,1841
751+2026-10-30 16:40:00,344.8433,344.91,344.8433,344.9,217
752+2026-10-30 16:41:00,344.9,344.92,344.9,344.9,1355
753+2026-10-30 16:42:00,344.85,344.9,344.84,344.9,1520
754+2026-10-30 16:43:00,344.8176,344.9,344.8176,344.9,522
755+2026-10-30 16:44:00,344.63,344.89,344.63,344.85,16908
756+2026-10-30 16:45:00,344.89,344.9,344.89,344.9,280
757+2026-10-30 16:46:00,344.93,344.93,344.88,344.88,304
758+2026-10-30 16:47:00,344.85,344.85,344.61,344.61,1866
759+2026-10-30 16:48:00,344.63,344.63,344.63,344.63,1410
760+2026-10-30 16:49:00,344.63,344.63,344.63,344.63,1616
761+2026-10-30 16:50:00,344.8368,344.8368,344.8368,344.8368,250
762+2026-10-30 16:51:00,344.75,344.75,344.75,344.75,80
763+2026-10-30 16:52:00,344.7527,344.8,344.7527,344.8,670
764+2026-10-30 16:54:00,344.7669,344.8293,344.7669,344.8293,699
765+2026-10-30 16:55:00,344.75,344.75,344.75,344.75,542
766+2026-10-30 16:56:00,344.6356,344.7813,344.6356,344.67,807
767+2026-10-30 16:57:00,344.7027,344.81,344.66,344.66,240
768+2026-10-30 16:59:00,344.7738,344.7738,344.7026,344.7026,480
769+2026-10-30 17:01:00,344.8,344.8,344.6817,344.6817,723
770+2026-10-30 17:02:00,344.7652,344.7652,344.66,344.66,3003
771+2026-10-30 17:03:00,344.679,344.7783,344.679,344.7783,140
772+2026-10-30 17:04:00,344.7678,344.7678,344.7678,344.7678,150
773+2026-10-30 17:05:00,344.6731,344.6731,344.6731,344.6731,50
774+2026-10-30 17:07:00,344.67,344.67,344.6,344.66,2338
775+2026-10-30 17:08:00,344.7433,344.78,344.7433,344.78,222
776+2026-10-30 17:09:00,344.74,344.74,344.73,344.73,205
777+2026-10-30 17:11:00,344.7,344.7,344.7,344.7,319
778+2026-10-30 17:12:00,344.7,344.74,344.7,344.74,200
779+2026-10-30 17:14:00,344.75,344.75,344.75,344.75,296
780+2026-10-30 17:16:00,344.7134,344.7186,344.71,344.7186,240
781+2026-10-30 17:17:00,344.71,344.75,344.71,344.74,700
782+2026-10-30 17:18:00,344.75,344.7712,344.75,344.7712,481
783+2026-10-30 17:19:00,344.77,344.77,344.77,344.77,50
784+2026-10-30 17:20:00,344.79,344.9,344.79,344.87,1767
785+2026-10-30 17:21:00,344.92,344.97,344.92,344.97,789
786+2026-10-30 17:22:00,344.97,344.97,344.88,344.94,2464
787+2026-10-30 17:23:00,344.9057,344.9057,344.89,344.89,157
788+2026-10-30 17:26:00,344.88,344.94,344.88,344.94,1020
789+2026-10-30 17:27:00,344.94,344.94,344.94,344.94,1071
790+2026-10-30 17:28:00,344.94,344.94,344.93,344.93,485
791+2026-10-30 17:29:00,344.8,344.8,344.8,344.8,500
792+2026-10-30 17:33:00,344.8972,344.8972,344.89,344.89,141
793+2026-10-30 17:34:00,344.85,344.85,344.85,344.85,123
794+2026-10-30 17:35:00,344.95,344.95,344.95,344.95,50
795+2026-10-30 17:37:00,344.82,344.8401,344.82,344.8401,678
796+2026-10-30 17:38:00,344.8401,344.85,344.8401,344.85,178
797+2026-10-30 17:40:00,344.83,344.83,344.83,344.83,149
798+2026-10-30 17:42:00,344.9,344.9,344.9,344.9,50
799+2026-10-30 17:44:00,344.87,344.9479,344.87,344.9479,281
800+2026-10-30 17:45:00,344.85,344.85,344.82,344.82,396
801+2026-10-30 17:47:00,344.77,344.7755,344.77,344.7755,150
802+2026-10-30 17:48:00,344.77,344.77,344.63,344.71,16978
803+2026-10-30 17:49:00,344.7,344.7,344.55,344.69,12790
804+2026-10-30 17:50:00,344.69,344.8,344.69,344.8,190
805+2026-10-30 17:52:00,344.6601,344.6601,344.63,344.63,16953
806+2026-10-30 17:53:00,344.8,344.8,344.8,344.8,200
807+2026-10-30 17:55:00,344.7155,344.7155,344.7155,344.7155,50
808+2026-10-30 17:57:00,344.73,344.75,344.73,344.75,579
809+2026-10-30 17:58:00,344.7938,344.8,344.73,344.8,2563
810+2026-10-30 17:59:00,344.7444,344.7444,344.7304,344.7304,340
811+2026-10-30 18:00:00,344.74,344.74,344.74,344.74,288
812+2026-10-30 18:01:00,344.7356,344.7356,344.7356,344.7356,49
813+2026-10-30 18:02:00,344.7372,344.7372,344.7372,344.7372,100
814+2026-10-30 18:03:00,344.7853,344.7853,344.7562,344.7562,139
815+2026-10-30 18:04:00,344.7527,344.77,344.75,344.75,1109
816+2026-10-30 18:05:00,344.7579,344.7824,344.7579,344.7824,250
817+2026-10-30 18:06:00,344.75,344.751,344.75,344.751,680
818+2026-10-30 18:07:00,344.75,344.75,344.75,344.75,95
819+2026-10-30 18:08:00,344.78,344.79,344.7446,344.76,305
820+2026-10-30 18:09:00,344.7887,344.8,344.7887,344.79,785
821+2026-10-30 18:10:00,344.79,344.81,344.79,344.81,342
822+2026-10-30 18:11:00,344.86,344.86,344.82,344.83,639
823+2026-10-30 18:12:00,344.82,344.83,344.82,344.82,330
824+2026-10-30 18:13:00,344.78,344.83,344.768,344.8072,2716
825+2026-10-30 18:15:00,344.81,344.9,344.7489,344.9,1614
826+2026-10-30 18:17:00,344.9,344.9,344.8009,344.8009,310
827+2026-10-30 18:18:00,344.91,344.91,344.7977,344.85,366
828+2026-10-30 18:20:00,344.817,344.8581,344.817,344.8581,1080
829+2026-10-30 18:21:00,344.8583,344.8583,344.85,344.85,321
830+2026-10-30 18:24:00,344.91,345,344.8639,345,993
831+2026-10-30 18:25:00,345.07,345.07,344.94,344.94,3846
832+2026-10-30 18:27:00,345.07,345.07,345.06,345.06,115
833+2026-10-30 18:28:00,345.07,345.07,344.98,344.98,150
834+2026-10-30 18:30:00,345,345,345,345,129
835+2026-10-30 18:33:00,344.9,344.9,344.9,344.9,41
836+2026-10-30 18:34:00,344.94,344.94,344.94,344.94,62
837+2026-10-30 18:35:00,344.9,344.9,344.9,344.9,100
838+2026-10-30 18:36:00,344.9624,344.9624,344.9624,344.9624,200
839+2026-10-30 18:37:00,344.9018,344.97,344.9,344.97,1471
840+2026-10-30 18:38:00,344.97,344.97,344.97,344.97,40
841+2026-10-30 18:39:00,345,345,345,345,50
842+2026-10-30 18:40:00,345,345,345,345,140
843+2026-10-30 18:41:00,344.9,344.9,344.9,344.9,240
844+2026-10-30 18:43:00,344.95,344.95,344.95,344.95,144
845+2026-10-30 18:44:00,344.9047,344.9047,344.9047,344.9047,60
846+2026-10-30 18:46:00,344.9,344.9,344.9,344.9,124
847+2026-10-30 18:47:00,344.7937,344.9,344.7937,344.9,2911
848+2026-10-30 18:48:00,344.8545,344.8545,344.8545,344.8545,50
849+2026-10-30 18:49:00,344.89,344.89,344.89,344.89,40
850+2026-10-30 18:50:00,344.9225,344.95,344.9225,344.95,1596
851+2026-10-30 18:51:00,344.95,344.95,344.92,344.92,172
852+2026-10-30 18:52:00,344.9,344.9,344.9,344.9,500
853+2026-10-30 18:53:00,344.85,344.85,344.85,344.85,357
854+2026-10-30 18:54:00,344.8,344.8,344.76,344.76,1080
855+2026-10-30 18:55:00,344.773,344.94,344.773,344.94,125
856+2026-10-30 18:56:00,344.94,344.94,344.94,344.94,44
857+2026-10-30 18:58:00,344.7604,344.8,344.7604,344.8,2054
858+2026-10-30 18:59:00,344.7775,344.7775,344.7775,344.7775,50
859+2026-10-30 19:00:00,344.9121,344.9121,344.9121,344.9121,134
860+2026-10-30 19:02:00,344.94,344.94,344.94,344.94,179
861+2026-10-30 19:05:00,344.85,344.85,344.85,344.85,100
862+2026-10-30 19:07:00,344.9339,344.9339,344.9339,344.9339,208
863+2026-10-30 19:09:00,344.93,344.93,344.93,344.93,50
864+2026-10-30 19:11:00,344.8988,344.8988,344.8988,344.8988,272
865+2026-10-30 19:12:00,344.7601,344.7601,344.7601,344.7601,184
866+2026-10-30 19:13:00,344.94,344.94,344.7841,344.7841,710
867+2026-10-30 19:14:00,344.8,344.94,344.8,344.94,99
868+2026-10-30 19:16:00,344.87,344.87,344.87,344.87,70
869+2026-10-30 19:17:00,344.9,344.9,344.8999,344.8999,308
870+2026-10-30 19:18:00,345,345,344.9895,344.9895,186
871+2026-10-30 19:19:00,344.8303,344.8303,344.8303,344.8303,60
872+2026-10-30 19:20:00,344.8815,344.8815,344.8815,344.8815,100
873+2026-10-30 19:21:00,344.9,344.9,344.9,344.9,86
874+2026-10-30 19:22:00,344.88,344.9106,344.83,344.9106,191
875+2026-10-30 19:23:00,344.9,344.9,344.9,344.9,100
876+2026-10-30 19:25:00,344.94,344.94,344.89,344.9,185
877+2026-10-30 19:28:00,344.94,344.94,344.9385,344.9385,100
878+2026-10-30 19:29:00,344.94,344.94,344.912,344.93,322
879+2026-10-30 19:30:00,344.925,344.925,344.92,344.92,80
880+2026-10-30 19:31:00,344.94,344.94,344.94,344.94,87
881+2026-10-30 19:33:00,344.8,344.8,344.8,344.8,40
882+2026-10-30 19:35:00,344.93,344.93,344.9221,344.9221,148
883+2026-10-30 19:36:00,344.866,344.866,344.866,344.866,43
884+2026-10-30 19:39:00,344.93,344.93,344.88,344.9,436
885+2026-10-30 19:40:00,344.9,344.9,344.86,344.88,350
886+2026-10-30 19:43:00,344.9846,344.9846,344.91,344.91,119
887+2026-10-30 19:44:00,344.8927,345,344.8927,345,100
888+2026-10-30 19:46:00,345,345,344.995,344.995,230
889+2026-10-30 19:47:00,344.9519,344.97,344.95,344.97,919
890+2026-10-30 19:48:00,345,345,344.999,344.999,768
891+2026-10-30 19:49:00,345.06,345.08,345.06,345.0745,1648
892+2026-10-30 19:51:00,345.08,345.08,345,345,384
893+2026-10-30 19:52:00,345.07,345.07,345.04,345.04,411
894+2026-10-30 19:53:00,345.08,345.18,345.0092,345.18,1142
895+2026-10-30 19:54:00,345.18,345.18,345.18,345.18,45
896+2026-10-30 19:55:00,345.1335,345.1335,345.0283,345.0283,156
897+2026-10-30 19:56:00,345.0524,345.18,345,345.18,1647
898+2026-10-30 19:57:00,345.05,345.1335,345.0232,345.12,550
899+2026-10-30 19:58:00,345.09,345.13,345.09,345.1,544
900+2026-10-30 19:59:00,345.0933,345.1,345.0933,345.1,852
\ No newline at end of file
added tests/fixtures/firstrate/A_day_1min_UNADJUSTED.txt +390 −0
@@ -0,0 +1,390 @@
1+2026-10-30 07:04:00,180.9,180.9,180.9,180.9,100
2+2026-10-30 09:30:00,180.98,180.98,179.93,180.8,19729
3+2026-10-30 09:31:00,180.2775,180.365,179.61,180.25,2032
4+2026-10-30 09:33:00,179.12,179.13,178.48,178.93,3218
5+2026-10-30 09:34:00,179.035,179.035,177.46,177.69,8961
6+2026-10-30 09:35:00,177.83,178.76,177.46,178.735,37242
7+2026-10-30 09:36:00,178.8,179.045,178.675,178.87,2954
8+2026-10-30 09:37:00,178.885,178.885,178.885,178.885,100
9+2026-10-30 09:38:00,178.91,179.26,178.28,179.1,12502
10+2026-10-30 09:39:00,179.19,179.32,178.875,179.32,8777
11+2026-10-30 09:40:00,179,179.115,178.74,178.74,4228
12+2026-10-30 09:41:00,178.74,178.81,178.61,178.62,1600
13+2026-10-30 09:42:00,178.23,178.6,178.06,178.46,6317
14+2026-10-30 09:43:00,177.91,177.97,177.91,177.93,3338
15+2026-10-30 09:44:00,177.92,177.9325,177.7882,177.93,3659
16+2026-10-30 09:45:00,178.14,178.2865,177.5,177.5,3704
17+2026-10-30 09:46:00,177.16,177.38,177.055,177.355,1475
18+2026-10-30 09:47:00,177.41,177.71,177.41,177.4325,1200
19+2026-10-30 09:48:00,177.4525,177.52,177.11,177.52,2372
20+2026-10-30 09:49:00,177.61,177.61,177.215,177.43,3278
21+2026-10-30 09:50:00,177.36,177.7376,177.36,177.505,4750
22+2026-10-30 09:51:00,177.17,177.365,177.02,177.365,800
23+2026-10-30 09:52:00,177.08,177.08,176.7105,176.81,1846
24+2026-10-30 09:53:00,176.745,176.745,176.2853,176.2853,2412
25+2026-10-30 09:54:00,176.57,176.57,176.185,176.5,1839
26+2026-10-30 09:55:00,176.28,176.35,176.07,176.14,1218
27+2026-10-30 09:56:00,176.42,176.755,176.33,176.755,1100
28+2026-10-30 09:57:00,176.925,177.29,176.925,177.29,2049
29+2026-10-30 09:58:00,177.12,177.19,177.12,177.19,771
30+2026-10-30 09:59:00,177.21,177.585,177.21,177.585,5537
31+2026-10-30 10:00:00,177.29,177.305,176.95,177.055,1838
32+2026-10-30 10:01:00,177.095,177.1,176.43,176.49,3099
33+2026-10-30 10:02:00,176.53,176.65,176.41,176.65,2615
34+2026-10-30 10:03:00,176.665,176.67,176.34,176.34,950
35+2026-10-30 10:04:00,176.37,176.545,176.36,176.4,4456
36+2026-10-30 10:05:00,176.485,176.54,176.485,176.53,1244
37+2026-10-30 10:06:00,176.52,176.58,176.52,176.58,402
38+2026-10-30 10:07:00,176.89,176.93,176.625,176.67,1077
39+2026-10-30 10:08:00,176.29,176.29,176.29,176.29,100
40+2026-10-30 10:09:00,176.45,176.45,175.995,176.19,2586
41+2026-10-30 10:10:00,175.955,176.01,175.71,175.71,1750
42+2026-10-30 10:11:00,175.71,175.71,175.135,175.51,4554
43+2026-10-30 10:12:00,175.265,175.52,174.58,174.6975,73028
44+2026-10-30 10:13:00,174.9,175.06,174.87,175.04,700
45+2026-10-30 10:14:00,175.05,175.31,174.9003,175.31,3006
46+2026-10-30 10:15:00,175.4489,175.48,175.31,175.4,52440
47+2026-10-30 10:16:00,175.33,175.49,175.18,175.49,13652
48+2026-10-30 10:17:00,175.68,176.21,175.64,175.86,10012
49+2026-10-30 10:18:00,176.02,176.25,176.02,176.1,4590
50+2026-10-30 10:19:00,176.07,176.34,176.07,176.285,1583
51+2026-10-30 10:20:00,176.275,176.67,176.275,176.565,1735
52+2026-10-30 10:21:00,176.565,176.94,176.56,176.8,6417
53+2026-10-30 10:22:00,176.79,176.8,175.95,175.95,3924
54+2026-10-30 10:23:00,175.83,175.95,175.605,175.79,88691
55+2026-10-30 10:24:00,175.605,176.01,175.605,175.8625,914
56+2026-10-30 10:25:00,175.875,175.875,175.87,175.875,346
57+2026-10-30 10:26:00,175.875,176.02,175.87,175.87,1900
58+2026-10-30 10:27:00,175.87,175.9,175.35,175.585,14850
59+2026-10-30 10:28:00,175.6275,175.78,175.49,175.615,4202
60+2026-10-30 10:29:00,175.615,175.62,175.47,175.615,2629
61+2026-10-30 10:30:00,175.61,176.27,175.35,175.35,31329
62+2026-10-30 10:31:00,175.485,175.485,175.075,175.08,9019
63+2026-10-30 10:32:00,175.03,175.2,175.03,175.08,1467
64+2026-10-30 10:33:00,175.0825,175.155,175.08,175.085,2073
65+2026-10-30 10:34:00,175.085,175.53,175.085,175.53,1906
66+2026-10-30 10:35:00,175.53,175.73,175.4,175.4,5803
67+2026-10-30 10:36:00,175.6,176.11,175.6,176.11,3768
68+2026-10-30 10:37:00,176.06,176.08,175.68,175.795,3263
69+2026-10-30 10:38:00,175.69,176,175.69,175.69,2307
70+2026-10-30 10:39:00,175.69,175.695,175.635,175.635,866
71+2026-10-30 10:40:00,175.635,175.635,175.635,175.635,100
72+2026-10-30 10:41:00,175.72,175.72,175.56,175.56,700
73+2026-10-30 10:42:00,175.675,175.705,175.4501,175.58,5174
74+2026-10-30 10:43:00,175.63,175.85,175.63,175.85,2087
75+2026-10-30 10:44:00,175.75,176.16,175.75,176.16,2097
76+2026-10-30 10:45:00,176.16,176.26,176.16,176.26,286
77+2026-10-30 10:46:00,176.1,176.245,176.1,176.245,400
78+2026-10-30 10:47:00,176.23,176.23,176.08,176.08,1068
79+2026-10-30 10:48:00,176.095,176.095,176.095,176.095,1080
80+2026-10-30 10:49:00,176.045,176.045,176.045,176.045,300
81+2026-10-30 10:50:00,176.045,176.045,175.87,175.87,3500
82+2026-10-30 10:51:00,175.8,175.84,175.44,175.595,6856
83+2026-10-30 10:52:00,175.74,175.75,175.49,175.49,7811
84+2026-10-30 10:53:00,175.62,175.625,175.44,175.44,900
85+2026-10-30 10:54:00,175.39,175.54,175.39,175.54,700
86+2026-10-30 10:55:00,175.6175,175.65,175.39,175.39,900
87+2026-10-30 10:56:00,175.37,175.39,175.37,175.39,596
88+2026-10-30 10:57:00,175.37,175.37,175.18,175.35,3866
89+2026-10-30 10:58:00,175.32,175.51,175.32,175.51,1833
90+2026-10-30 10:59:00,175.39,175.395,175.275,175.275,410
91+2026-10-30 11:00:00,175.475,175.64,175.44,175.44,1100
92+2026-10-30 11:01:00,175.365,175.365,175.11,175.11,4320
93+2026-10-30 11:02:00,175.2,175.22,175.06,175.07,2090
94+2026-10-30 11:03:00,174.9789,175.035,174.965,174.965,662
95+2026-10-30 11:04:00,174.965,174.965,174.945,174.945,500
96+2026-10-30 11:05:00,174.96,175.14,174.96,175.14,1819
97+2026-10-30 11:06:00,175.19,175.43,175.19,175.26,1936
98+2026-10-30 11:07:00,175.26,175.28,174.8,174.83,19083
99+2026-10-30 11:08:00,174.835,174.835,174.645,174.65,700
100+2026-10-30 11:09:00,174.64,174.64,174.46,174.46,1239
101+2026-10-30 11:10:00,174.47,174.75,174.47,174.65,1028
102+2026-10-30 11:11:00,174.59,174.59,174.59,174.59,127
103+2026-10-30 11:12:00,174.59,174.59,174.305,174.54,2129
104+2026-10-30 11:13:00,174.54,174.595,174.54,174.59,900
105+2026-10-30 11:14:00,174.59,174.59,174.59,174.59,131
106+2026-10-30 11:15:00,174.68,175.0299,174.68,174.71,15828
107+2026-10-30 11:16:00,174.93,175.09,174.815,175.09,26884
108+2026-10-30 11:17:00,175.03,175.0325,174.56,174.56,3505
109+2026-10-30 11:18:00,174.58,174.77,174.36,174.6575,901
110+2026-10-30 11:19:00,174.61,174.78,174.61,174.68,900
111+2026-10-30 11:20:00,174.695,174.695,174.6825,174.6825,200
112+2026-10-30 11:21:00,174.685,174.685,174.39,174.58,800
113+2026-10-30 11:22:00,174.43,174.54,174.43,174.52,545
114+2026-10-30 11:23:00,174.37,174.55,174.37,174.4,1700
115+2026-10-30 11:24:00,174.2,174.3,174.1305,174.3,597
116+2026-10-30 11:25:00,174.26,174.26,174.1875,174.25,763
117+2026-10-30 11:26:00,174.185,174.185,173.85,173.975,6749
118+2026-10-30 11:27:00,173.975,174.11,173.9,173.98,909
119+2026-10-30 11:28:00,174.035,174.1567,173.61,173.61,2424
120+2026-10-30 11:29:00,173.61,174.08,173.38,174.08,14588
121+2026-10-30 11:30:00,174.09,174.09,174.02,174.0475,433
122+2026-10-30 11:31:00,174.155,174.155,174,174.05,1600
123+2026-10-30 11:32:00,174.055,174.055,173.81,173.97,1025
124+2026-10-30 11:33:00,173.905,174.02,173.905,173.98,1300
125+2026-10-30 11:34:00,174.12,174.26,174.12,174.16,410
126+2026-10-30 11:35:00,174.29,174.31,174.29,174.31,806
127+2026-10-30 11:36:00,174.205,174.38,174.09,174.18,1673
128+2026-10-30 11:37:00,174.145,174.26,174.145,174.26,700
129+2026-10-30 11:38:00,174.15,174.15,174.15,174.15,700
130+2026-10-30 11:39:00,174.08,174.08,174.08,174.08,100
131+2026-10-30 11:40:00,174.15,174.16,173.94,174.05,5740
132+2026-10-30 11:41:00,173.94,173.94,173.67,173.775,1661
133+2026-10-30 11:42:00,173.775,173.8,173.66,173.735,1675
134+2026-10-30 11:43:00,173.86,174,173.76,173.945,2317
135+2026-10-30 11:44:00,173.945,173.945,173.885,173.885,471
136+2026-10-30 11:45:00,173.88,173.9821,173.6975,173.79,2487
137+2026-10-30 11:46:00,173.885,173.885,173.85,173.85,344
138+2026-10-30 11:47:00,174.06,174.07,174.02,174.02,300
139+2026-10-30 11:48:00,174.01,174.0862,173.99,173.99,2339
140+2026-10-30 11:49:00,174.16,174.52,174.16,174.38,1417
141+2026-10-30 11:50:00,174.385,174.39,174.16,174.31,3390
142+2026-10-30 11:51:00,174.31,174.45,174.31,174.44,909
143+2026-10-30 11:52:00,174.445,174.445,174.3401,174.3401,515
144+2026-10-30 11:53:00,174.43,174.45,174.35,174.415,1358
145+2026-10-30 11:54:00,174.415,174.45,174.4,174.4,2019
146+2026-10-30 11:55:00,174.51,174.56,174.485,174.545,1334
147+2026-10-30 11:56:00,174.555,174.555,174.37,174.38,1300
148+2026-10-30 11:57:00,174.415,174.415,174.33,174.37,3092
149+2026-10-30 11:58:00,174.285,174.285,174.285,174.285,100
150+2026-10-30 11:59:00,174.45,174.45,174.365,174.365,793
151+2026-10-30 12:00:00,174.28,174.28,174.28,174.28,200
152+2026-10-30 12:01:00,174.23,174.23,174.21,174.21,400
153+2026-10-30 12:02:00,174.23,174.23,174.225,174.225,371
154+2026-10-30 12:03:00,174.14,174.14,173.865,173.865,4482
155+2026-10-30 12:04:00,173.895,174.0253,173.83,173.85,1419
156+2026-10-30 12:05:00,173.85,173.925,173.785,173.925,1925
157+2026-10-30 12:06:00,173.925,173.925,173.88,173.88,400
158+2026-10-30 12:07:00,173.95,173.96,173.915,173.92,1773
159+2026-10-30 12:08:00,173.915,173.915,173.91,173.91,600
160+2026-10-30 12:09:00,173.905,173.99,173.88,173.88,600
161+2026-10-30 12:10:00,173.75,173.835,173.57,173.585,2480
162+2026-10-30 12:11:00,173.585,173.735,173.585,173.73,1500
163+2026-10-30 12:12:00,173.71,173.81,173.71,173.81,762
164+2026-10-30 12:13:00,173.93,174.05,173.93,174.005,800
165+2026-10-30 12:14:00,174.01,174.01,174.01,174.01,100
166+2026-10-30 12:15:00,174.03,174.27,174.03,174.27,2128
167+2026-10-30 12:16:00,174.29,174.55,174.29,174.55,6230
168+2026-10-30 12:17:00,174.71,174.71,174.71,174.71,108
169+2026-10-30 12:18:00,174.71,174.71,174.565,174.565,300
170+2026-10-30 12:19:00,174.74,175,174.74,174.93,2504
171+2026-10-30 12:20:00,174.86,174.95,174.84,174.95,512
172+2026-10-30 12:21:00,174.88,174.88,174.73,174.765,3578
173+2026-10-30 12:22:00,174.87,174.97,174.87,174.97,1466
174+2026-10-30 12:23:00,174.98,174.99,174.845,174.845,1128
175+2026-10-30 12:24:00,174.74,174.87,174.7,174.72,13203
176+2026-10-30 12:25:00,174.68,174.68,174.59,174.6,1623
177+2026-10-30 12:26:00,174.635,174.68,174.53,174.67,1796
178+2026-10-30 12:27:00,174.59,174.59,174.59,174.59,100
179+2026-10-30 12:28:00,174.68,174.73,174.68,174.72,1700
180+2026-10-30 12:29:00,174.635,174.725,174.635,174.725,400
181+2026-10-30 12:30:00,174.73,174.73,174.6175,174.7,1527
182+2026-10-30 12:31:00,174.64,174.64,174.64,174.64,100
183+2026-10-30 12:32:00,174.77,174.89,174.76,174.89,1637
184+2026-10-30 12:33:00,174.88,174.88,174.735,174.735,4061
185+2026-10-30 12:34:00,174.72,174.81,174.59,174.81,1999
186+2026-10-30 12:35:00,174.75,174.8,174.65,174.65,1186
187+2026-10-30 12:36:00,174.8,174.8,174.5,174.6,8168
188+2026-10-30 12:37:00,174.545,174.545,174.46,174.49,12823
189+2026-10-30 12:38:00,174.49,174.56,174.49,174.56,874
190+2026-10-30 12:39:00,174.5,174.58,174.28,174.34,6944
191+2026-10-30 12:40:00,174.34,174.34,174.34,174.34,100
192+2026-10-30 12:41:00,174.345,174.345,174.16,174.16,1053
193+2026-10-30 12:42:00,174.35,174.35,174.345,174.345,352
194+2026-10-30 12:43:00,174.35,174.511,174.35,174.511,773
195+2026-10-30 12:44:00,174.3,174.3,174.3,174.3,400
196+2026-10-30 12:45:00,174.3,174.3,174.17,174.175,2182
197+2026-10-30 12:46:00,174.175,174.27,174.175,174.175,2552
198+2026-10-30 12:47:00,174.18,174.18,174.17,174.17,627
199+2026-10-30 12:48:00,174.15,174.39,174.137,174.36,8890
200+2026-10-30 12:49:00,174.42,174.45,174.4025,174.415,4607
201+2026-10-30 12:50:00,174.415,174.54,174.4,174.54,5600
202+2026-10-30 12:51:00,174.34,174.37,174.29,174.295,1613
203+2026-10-30 12:52:00,174.295,174.45,174.17,174.45,1205
204+2026-10-30 12:53:00,174.59,174.59,174.415,174.415,1613
205+2026-10-30 12:54:00,174.42,174.44,174.42,174.44,1172
206+2026-10-30 12:55:00,174.445,174.445,174.315,174.32,2270
207+2026-10-30 12:56:00,174.32,174.32,174.31,174.31,400
208+2026-10-30 12:57:00,174.315,174.41,174.315,174.41,300
209+2026-10-30 12:58:00,174.4,174.47,174.305,174.47,1721
210+2026-10-30 12:59:00,174.53,174.54,174.53,174.54,200
211+2026-10-30 13:00:00,174.43,174.68,174.43,174.68,1700
212+2026-10-30 13:01:00,174.79,174.79,174.68,174.68,451
213+2026-10-30 13:02:00,174.74,174.74,174.62,174.62,310
214+2026-10-30 13:03:00,174.61,174.68,174.48,174.58,5581
215+2026-10-30 13:04:00,174.64,174.64,174.53,174.53,600
216+2026-10-30 13:06:00,174.41,174.41,174.41,174.41,100
217+2026-10-30 13:07:00,174.55,174.55,174.33,174.33,4551
218+2026-10-30 13:08:00,174.41,174.48,174.4,174.48,1592
219+2026-10-30 13:09:00,174.5147,174.5147,174.49,174.4961,533
220+2026-10-30 13:10:00,174.44,174.61,174.44,174.57,1809
221+2026-10-30 13:11:00,174.67,174.67,174.57,174.6225,713
222+2026-10-30 13:12:00,174.61,174.66,174.49,174.505,10995
223+2026-10-30 13:13:00,174.485,174.485,174.24,174.24,1420
224+2026-10-30 13:15:00,174.36,174.62,174.2968,174.62,3572
225+2026-10-30 13:16:00,174.695,174.695,174.381,174.575,536
226+2026-10-30 13:17:00,174.7199,174.7199,174.7199,174.7199,124
227+2026-10-30 13:18:00,174.7,174.7,174.7,174.7,100
228+2026-10-30 13:19:00,174.575,174.79,174.575,174.79,1611
229+2026-10-30 13:20:00,174.79,174.81,174.64,174.81,5019
230+2026-10-30 13:21:00,174.8,174.8,174.8,174.8,100
231+2026-10-30 13:22:00,174.795,174.95,174.795,174.95,1154
232+2026-10-30 13:23:00,174.95,175.13,174.95,175.13,1134
233+2026-10-30 13:24:00,175.22,175.22,175.22,175.22,187
234+2026-10-30 13:25:00,175.08,175.08,175.08,175.08,100
235+2026-10-30 13:26:00,175.08,175.08,174.98,175.08,522
236+2026-10-30 13:27:00,175.01,175.085,175.01,175.08,818
237+2026-10-30 13:28:00,175.13,175.13,174.9701,175.11,845
238+2026-10-30 13:29:00,175.1,175.11,175.04,175.11,1152
239+2026-10-30 13:30:00,175.11,175.11,174.86,174.9275,8281
240+2026-10-30 13:31:00,174.87,174.94,174.85,174.935,648
241+2026-10-30 13:32:00,174.86,174.94,174.79,174.79,2045
242+2026-10-30 13:33:00,174.79,174.795,174.79,174.795,521
243+2026-10-30 13:34:00,174.72,174.92,174.67,174.81,3560
244+2026-10-30 13:35:00,174.91,174.93,174.91,174.93,1005
245+2026-10-30 13:36:00,174.8,174.88,174.75,174.825,2217
246+2026-10-30 13:37:00,174.8918,174.8918,174.79,174.795,1080
247+2026-10-30 13:38:00,174.78,174.945,174.78,174.945,3000
248+2026-10-30 13:39:00,174.94,174.96,174.92,174.96,359
249+2026-10-30 13:40:00,174.86,174.89,174.84,174.89,2735
250+2026-10-30 13:41:00,174.89,174.89,174.815,174.815,986
251+2026-10-30 13:42:00,174.77,174.86,174.77,174.825,1350
252+2026-10-30 13:43:00,174.83,174.975,174.82,174.975,2478
253+2026-10-30 13:44:00,174.935,174.99,174.92,174.99,800
254+2026-10-30 13:45:00,174.995,174.995,174.86,174.915,3398
255+2026-10-30 13:46:00,174.915,174.915,174.915,174.915,100
256+2026-10-30 13:47:00,174.92,174.92,174.92,174.92,200
257+2026-10-30 13:48:00,175.01,175.01,175.01,175.01,613
258+2026-10-30 13:49:00,175,175,174.83,174.95,783
259+2026-10-30 13:50:00,174.89,174.89,174.77,174.83,4132
260+2026-10-30 13:51:00,174.77,174.77,174.64,174.69,2007
261+2026-10-30 13:52:00,174.69,174.69,174.625,174.625,351
262+2026-10-30 13:53:00,174.74,174.77,174.685,174.685,1128
263+2026-10-30 13:54:00,174.85,174.85,174.85,174.85,300
264+2026-10-30 13:55:00,174.78,174.91,174.78,174.91,900
265+2026-10-30 13:56:00,174.845,174.845,174.67,174.7,4743
266+2026-10-30 13:57:00,174.7084,174.87,174.695,174.87,3754
267+2026-10-30 13:58:00,174.955,174.955,174.84,174.86,1813
268+2026-10-30 13:59:00,174.81,174.81,174.81,174.81,100
269+2026-10-30 14:00:00,174.78,174.82,174.74,174.82,1590
270+2026-10-30 14:01:00,174.795,174.795,174.7,174.79,2561
271+2026-10-30 14:02:00,174.81,174.905,174.81,174.905,600
272+2026-10-30 14:03:00,175.02,175.17,175.02,175.17,744
273+2026-10-30 14:04:00,175.075,175.14,175.022,175.022,1612
274+2026-10-30 14:05:00,175.22,175.22,175.1701,175.1701,300
275+2026-10-30 14:06:00,175.18,175.185,175.051,175.051,5407
276+2026-10-30 14:07:00,175.16,175.19,175.15,175.17,808
277+2026-10-30 14:08:00,175.11,175.165,174.91,174.91,1934
278+2026-10-30 14:09:00,174.965,174.965,174.895,174.895,993
279+2026-10-30 14:10:00,174.895,174.895,174.77,174.815,1838
280+2026-10-30 14:11:00,174.82,174.88,174.8,174.8,1523
281+2026-10-30 14:12:00,174.74,174.85,174.68,174.68,4500
282+2026-10-30 14:13:00,174.65,174.69,174.65,174.685,620
283+2026-10-30 14:14:00,174.68,174.68,174.565,174.565,2848
284+2026-10-30 14:15:00,174.56,174.61,174.54,174.61,3740
285+2026-10-30 14:16:00,174.54,174.64,174.48,174.64,1913
286+2026-10-30 14:17:00,174.64,174.65,174.46,174.48,1100
287+2026-10-30 14:18:00,174.485,174.52,174.41,174.41,2788
288+2026-10-30 14:19:00,174.41,174.47,174.37,174.46,2220
289+2026-10-30 14:20:00,174.3229,174.4,173.81,174.005,175962
290+2026-10-30 14:21:00,174.015,174.0525,174.015,174.0275,1200
291+2026-10-30 14:22:00,174.22,174.22,174.02,174.02,2629
292+2026-10-30 14:23:00,173.985,173.99,173.86,173.86,2902
293+2026-10-30 14:24:00,173.85,173.855,173.61,173.75,5056
294+2026-10-30 14:25:00,173.74,173.74,173.57,173.57,300
295+2026-10-30 14:26:00,173.745,173.745,173.705,173.71,1414
296+2026-10-30 14:27:00,173.7175,173.7175,173.59,173.7,1230
297+2026-10-30 14:28:00,173.675,173.85,173.655,173.85,3320
298+2026-10-30 14:29:00,173.89,174.015,173.81,173.84,15295
299+2026-10-30 14:30:00,173.855,173.98,173.7401,173.98,2300
300+2026-10-30 14:31:00,173.92,174.14,173.92,174.13,2809
301+2026-10-30 14:32:00,174.16,174.31,174.16,174.31,1709
302+2026-10-30 14:33:00,174.21,174.21,174.18,174.18,600
303+2026-10-30 14:34:00,174.205,174.25,174.19,174.25,1100
304+2026-10-30 14:35:00,174.26,174.26,174.115,174.21,3920
305+2026-10-30 14:36:00,174.35,174.415,174.26,174.4125,3250
306+2026-10-30 14:37:00,174.44,174.53,174.44,174.53,200
307+2026-10-30 14:38:00,174.445,174.455,174.26,174.45,2466
308+2026-10-30 14:39:00,174.59,174.59,174.59,174.59,100
309+2026-10-30 14:40:00,174.555,174.575,174.555,174.575,1950
310+2026-10-30 14:41:00,174.495,174.68,174.495,174.55,2828
311+2026-10-30 14:42:00,174.58,174.67,174.565,174.64,521
312+2026-10-30 14:43:00,174.57,174.58,174.46,174.54,1278
313+2026-10-30 14:44:00,174.49,174.55,174.45,174.47,1566
314+2026-10-30 14:45:00,174.65,174.81,174.65,174.73,3344
315+2026-10-30 14:46:00,174.73,174.76,174.62,174.655,1800
316+2026-10-30 14:47:00,174.74,174.83,174.735,174.83,1016
317+2026-10-30 14:48:00,174.8175,174.87,174.71,174.87,8964
318+2026-10-30 14:49:00,174.97,175,174.92,174.925,3600
319+2026-10-30 14:50:00,174.78,174.89,174.76,174.805,2300
320+2026-10-30 14:51:00,174.81,174.81,174.59,174.59,1766
321+2026-10-30 14:52:00,174.61,174.66,174.59,174.6,700
322+2026-10-30 14:53:00,174.605,174.69,174.605,174.69,1011
323+2026-10-30 14:54:00,174.69,174.69,174.49,174.49,960
324+2026-10-30 14:55:00,174.5025,174.66,174.5025,174.66,800
325+2026-10-30 14:56:00,174.84,174.86,174.84,174.86,200
326+2026-10-30 14:57:00,174.99,175.06,174.935,175.06,500
327+2026-10-30 14:58:00,175.1281,175.1281,174.79,174.79,1658
328+2026-10-30 14:59:00,174.885,174.89,174.88,174.89,400
329+2026-10-30 15:00:00,174.79,174.85,174.73,174.85,1441
330+2026-10-30 15:01:00,174.865,174.89,174.835,174.89,803
331+2026-10-30 15:02:00,174.87,174.94,174.83,174.86,2056
332+2026-10-30 15:03:00,174.82,174.92,174.8,174.92,1800
333+2026-10-30 15:04:00,174.835,174.92,174.835,174.84,400
334+2026-10-30 15:05:00,174.92,174.95,174.92,174.92,916
335+2026-10-30 15:06:00,174.96,174.965,174.96,174.965,200
336+2026-10-30 15:07:00,174.98,174.98,174.88,174.88,500
337+2026-10-30 15:08:00,174.88,175.09,174.88,175.05,1810
338+2026-10-30 15:09:00,175.045,175.05,174.98,174.98,327
339+2026-10-30 15:10:00,174.95,174.955,174.89,174.955,1015
340+2026-10-30 15:11:00,174.84,174.975,174.82,174.975,900
341+2026-10-30 15:12:00,175.09,175.122,175.08,175.08,1223
342+2026-10-30 15:13:00,175.08,175.14,175.08,175.1,500
343+2026-10-30 15:14:00,175.105,175.105,175.02,175.1,400
344+2026-10-30 15:15:00,175.075,175.08,174.92,175.03,4845
345+2026-10-30 15:16:00,175.08,175.08,174.9503,174.9503,1136
346+2026-10-30 15:17:00,174.96,174.985,174.935,174.935,1108
347+2026-10-30 15:18:00,174.89,174.91,174.69,174.69,1280
348+2026-10-30 15:19:00,174.75,174.8,174.69,174.69,1464
349+2026-10-30 15:20:00,174.75,175.12,174.75,175.12,1007
350+2026-10-30 15:21:00,175.18,175.285,175.16,175.16,2797
351+2026-10-30 15:22:00,175.12,175.12,175.12,175.12,100
352+2026-10-30 15:23:00,175.24,175.32,175.18,175.32,1001
353+2026-10-30 15:24:00,175.32,175.43,175.32,175.39,1361
354+2026-10-30 15:25:00,175.39,175.39,175.24,175.32,1875
355+2026-10-30 15:26:00,175.25,175.43,175.23,175.43,3482
356+2026-10-30 15:27:00,175.43,175.435,175.32,175.32,1000
357+2026-10-30 15:28:00,175.355,175.36,175.22,175.22,1052
358+2026-10-30 15:29:00,175.21,175.21,175.08,175.125,1297
359+2026-10-30 15:30:00,175.12,175.19,175.06,175.12,3771
360+2026-10-30 15:31:00,175.2,175.26,175.145,175.145,3237
361+2026-10-30 15:32:00,175.14,175.25,175.14,175.25,1855
362+2026-10-30 15:33:00,175.195,175.39,175.165,175.34,1710
363+2026-10-30 15:34:00,175.34,175.34,175.25,175.34,1171
364+2026-10-30 15:35:00,175.265,175.41,175.17,175.41,2233
365+2026-10-30 15:36:00,175.35,175.41,175.33,175.34,1336
366+2026-10-30 15:37:00,175.36,175.58,175.305,175.305,5207
367+2026-10-30 15:38:00,175.335,175.3699,175.29,175.31,1064
368+2026-10-30 15:39:00,175.25,175.37,175.21,175.37,1258
369+2026-10-30 15:40:00,175.32,175.4,175.24,175.28,4220
370+2026-10-30 15:41:00,175.265,175.33,175.17,175.33,2335
371+2026-10-30 15:42:00,175.39,175.52,175.39,175.445,1574
372+2026-10-30 15:43:00,175.43,175.43,175.31,175.375,1883
373+2026-10-30 15:44:00,175.375,175.375,175.37,175.37,820
374+2026-10-30 15:45:00,175.29,175.67,175.28,175.67,5643
375+2026-10-30 15:46:00,175.73,175.87,175.73,175.8275,3777
376+2026-10-30 15:47:00,175.86,175.86,175.57,175.57,4073
377+2026-10-30 15:48:00,175.54,175.61,175.5,175.575,2946
378+2026-10-30 15:49:00,175.62,175.67,175.49,175.67,7348
379+2026-10-30 15:50:00,175.72,175.86,175.51,175.51,5535
380+2026-10-30 15:51:00,175.65,175.72,175.6,175.61,6260
381+2026-10-30 15:52:00,175.61,175.61,175.26,175.26,6502
382+2026-10-30 15:53:00,175.31,175.33,174.92,175.19,23047
383+2026-10-30 15:54:00,175.23,175.24,174.98,175.24,30976
384+2026-10-30 15:55:00,175.21,175.335,175.1873,175.24,17554
385+2026-10-30 15:56:00,175.33,175.3995,175,175.13,48804
386+2026-10-30 15:57:00,175.13,175.13,174.89,175.065,12135
387+2026-10-30 15:58:00,175.065,175.12,174.885,174.91,28164
388+2026-10-30 15:59:00,174.91,175.03,174.8,174.81,32975
389+2026-10-30 16:00:00,174.87,174.87,174.87,174.87,390230
390+2026-10-30 16:34:00,171.2677,171.2677,171.2677,171.2677,170
\ No newline at end of file
added tests/fixtures/fundamentals/earnings_AAPL.json +74 −0
@@ -0,0 +1,74 @@
1+{
2+ "status": "success",
3+ "data": {
4+ "ticker": "AAPL",
5+ "sort": "desc",
6+ "count": 10,
7+ "earnings_release_data": [
8+ {
9+ "date": "2026-11-02",
10+ "eps_estimate": 1.98,
11+ "eps_reported": null,
12+ "release_time": "after_close"
13+ },
14+ {
15+ "date": "2026-10-29",
16+ "eps_estimate": 1.98,
17+ "eps_reported": null,
18+ "release_time": "after_close"
19+ },
20+ {
21+ "date": "2026-07-30",
22+ "eps_estimate": 1.89,
23+ "eps_reported": 2.02,
24+ "release_time": "after_close"
25+ },
26+ {
27+ "date": "2026-05-07",
28+ "eps_estimate": 1.88,
29+ "eps_reported": null,
30+ "release_time": "before_open"
31+ },
32+ {
33+ "date": "2026-04-30",
34+ "eps_estimate": 1.95,
35+ "eps_reported": 2.01,
36+ "release_time": "after_close"
37+ },
38+ {
39+ "date": "2026-01-29",
40+ "eps_estimate": 2.65,
41+ "eps_reported": 2.84,
42+ "release_time": "before_open"
43+ },
44+ {
45+ "date": "2025-10-30",
46+ "eps_estimate": 1.74,
47+ "eps_reported": 1.85,
48+ "release_time": "before_open"
49+ },
50+ {
51+ "date": "2025-07-31",
52+ "eps_estimate": 1.43,
53+ "eps_reported": 1.57,
54+ "release_time": "after_close"
55+ },
56+ {
57+ "date": "2025-05-01",
58+ "eps_estimate": 1.62,
59+ "eps_reported": 1.65,
60+ "release_time": "after_close"
61+ },
62+ {
63+ "date": "2025-01-30",
64+ "eps_estimate": 2.34,
65+ "eps_reported": 2.4,
66+ "release_time": "after_close"
67+ }
68+ ]
69+ },
70+ "meta": {
71+ "request_timestamp": "2026-11-01T22:29:13.620796",
72+ "processing_time_ms": 38
73+ }
74+}
\ No newline at end of file
added tests/fixtures/fundamentals/financials_all_AAPL_quarterly.json +927 −0
@@ -0,0 +1,927 @@
1+{
2+ "status": "success",
3+ "data": {
4+ "ticker": "AAPL",
5+ "period_filter": "quarterly",
6+ "sort": "desc",
7+ "count": 12,
8+ "financial_data": [
9+ {
10+ "period_end": "2026-06-27",
11+ "period_type": "q",
12+ "filing_date": "2026-07-31",
13+ "actual_year": 2026,
14+ "actual_period": "q2",
15+ "fiscal_year": 2026,
16+ "fiscal_period": "q3",
17+ "tax_liability": 0,
18+ "tax_expense": 6478000000,
19+ "tax_asset": 0,
20+ "shares_av_diluted": 14714676000,
21+ "shares_av": 14656110000,
22+ "shares": 14594180000,
23+ "sgna": 7346000000,
24+ "share_comp": 3401000000,
25+ "research_dev": 11729000000,
26+ "rev": 109417000000,
27+ "ret_earnings": 11326000000,
28+ "receivables": 58907000000,
29+ "pref_divs": 0,
30+ "ppe": 51431000000,
31+ "payables": 64525000000,
32+ "op_inc": 35695000000,
33+ "op_ex": 19075000000,
34+ "net_inc_non_control": 0,
35+ "net_loss_discontinued": 0,
36+ "net_inc_com": 29789000000,
37+ "net_inc": 29789000000,
38+ "fx_impact_cash": 0,
39+ "cash_op": 34369000000,
40+ "cash_inv_acq": -5106000000,
41+ "cash_inv": -7757000000,
42+ "cash_fin": -32640000000,
43+ "cash_div": -4035000000,
44+ "cash_debt_sec": -232000000,
45+ "cash_equity": -25105000000,
46+ "cash_business_acq": 0,
47+ "cash_change": -6028000000,
48+ "liab_nc": 126420000000,
49+ "liab_c": 149326000000,
50+ "liab_tot": 275746000000,
51+ "inv_nc": 84118000000,
52+ "inv_c": 22855000000,
53+ "investments": 106973000000,
54+ "inventory": 11092000000,
55+ "interest_exp": 0,
56+ "intangibles": 20342000000,
57+ "gp": 54770000000,
58+ "equity": 107520000000,
59+ "eps_d": 2.02,
60+ "eps": 2.03,
61+ "ebt": 36267000000,
62+ "ebitda": 39587000000,
63+ "ebit": 36267000000,
64+ "dps": 0.27,
65+ "depr": 3320000000,
66+ "def_rev": 9538000000,
67+ "debt_nc": 71340000000,
68+ "debt_c": 13004000000,
69+ "debt": 84344000000,
70+ "cogs": 54647000000,
71+ "cos_inc": 29789000000,
72+ "cash": 39544000000,
73+ "capex": -2455000000,
74+ "assets_nc": 233448000000,
75+ "assets_c": 149818000000,
76+ "assets_tot": 383266000000,
77+ "acc_oci": -4508000000,
78+ "roa": 0.08,
79+ "roe": 0.28,
80+ "roic": 0.16,
81+ "gm": 0.5,
82+ "om": 0.33,
83+ "intr_cover": null
84+ },
85+ {
86+ "period_end": "2026-03-28",
87+ "period_type": "q",
88+ "filing_date": "2026-05-01",
89+ "actual_year": 2026,
90+ "actual_period": "q1",
91+ "fiscal_year": 2026,
92+ "fiscal_period": "q2",
93+ "tax_liability": 0,
94+ "tax_expense": 6255000000,
95+ "tax_asset": 0,
96+ "shares_av_diluted": 14725873000,
97+ "shares_av": 14673278000,
98+ "shares": 14687356000,
99+ "sgna": 7477000000,
100+ "share_comp": 3528000000,
101+ "research_dev": 11419000000,
102+ "rev": 111184000000,
103+ "ret_earnings": 12359000000,
104+ "receivables": 53511000000,
105+ "pref_divs": 0,
106+ "ppe": 50116000000,
107+ "payables": 57349000000,
108+ "op_inc": 35885000000,
109+ "op_ex": 18896000000,
110+ "net_inc_non_control": 0,
111+ "net_loss_discontinued": 0,
112+ "net_inc_com": 29578000000,
113+ "net_inc": 29578000000,
114+ "fx_impact_cash": 0,
115+ "cash_op": 28702000000,
116+ "cash_inv_acq": -2767000000,
117+ "cash_inv": -6168000000,
118+ "cash_fin": -22279000000,
119+ "cash_div": -3822000000,
120+ "cash_debt_sec": -5751000000,
121+ "cash_equity": -12288000000,
122+ "cash_business_acq": 0,
123+ "cash_change": 255000000,
124+ "liab_nc": 129950000000,
125+ "liab_c": 134641000000,
126+ "liab_tot": 264591000000,
127+ "inv_nc": 78088000000,
128+ "inv_c": 22935000000,
129+ "investments": 101023000000,
130+ "inventory": 6747000000,
131+ "interest_exp": 0,
132+ "intangibles": 21334000000,
133+ "gp": 54781000000,
134+ "equity": 106491000000,
135+ "eps_d": 2.01,
136+ "eps": 2.02,
137+ "ebt": 35833000000,
138+ "ebitda": 39272000000,
139+ "ebit": 35833000000,
140+ "dps": 0.26,
141+ "depr": 3439000000,
142+ "def_rev": 9331000000,
143+ "debt_nc": 74404000000,
144+ "debt_c": 10307000000,
145+ "debt": 84711000000,
146+ "cogs": 56403000000,
147+ "cos_inc": 29578000000,
148+ "cash": 45572000000,
149+ "capex": -1971000000,
150+ "assets_nc": 226968000000,
151+ "assets_c": 144114000000,
152+ "assets_tot": 371082000000,
153+ "acc_oci": -5375000000,
154+ "roa": 0.08,
155+ "roe": 0.28,
156+ "roic": 0.15,
157+ "gm": 0.49,
158+ "om": 0.32,
159+ "intr_cover": null
160+ },
161+ {
162+ "period_end": "2025-12-27",
163+ "period_type": "q",
164+ "filing_date": "2026-01-30",
165+ "actual_year": 2025,
166+ "actual_period": "q4",
167+ "fiscal_year": 2026,
168+ "fiscal_period": "q1",
169+ "tax_liability": 0,
170+ "tax_expense": 8905000000,
171+ "tax_asset": 0,
172+ "shares_av_diluted": 14810356000,
173+ "shares_av": 14748158000,
174+ "shares": 14681140000,
175+ "sgna": 7492000000,
176+ "share_comp": 3594000000,
177+ "research_dev": 10887000000,
178+ "rev": 143756000000,
179+ "ret_earnings": -2177000000,
180+ "receivables": 70320000000,
181+ "pref_divs": 0,
182+ "ppe": 50159000000,
183+ "payables": 70587000000,
184+ "op_inc": 50852000000,
185+ "op_ex": 18379000000,
186+ "net_inc_non_control": 0,
187+ "net_loss_discontinued": 0,
188+ "net_inc_com": 42097000000,
189+ "net_inc": 42097000000,
190+ "fx_impact_cash": 0,
191+ "cash_op": 53925000000,
192+ "cash_inv_acq": -2359000000,
193+ "cash_inv": -4886000000,
194+ "cash_fin": -39656000000,
195+ "cash_div": -3921000000,
196+ "cash_debt_sec": -8074000000,
197+ "cash_equity": -24701000000,
198+ "cash_business_acq": 0,
199+ "cash_change": 9383000000,
200+ "liab_nc": 128740000000,
201+ "liab_c": 162367000000,
202+ "liab_tot": 291107000000,
203+ "inv_nc": 77888000000,
204+ "inv_c": 21590000000,
205+ "investments": 99478000000,
206+ "inventory": 5875000000,
207+ "interest_exp": 0,
208+ "intangibles": 0,
209+ "gp": 69231000000,
210+ "equity": 88190000000,
211+ "eps_d": 2.84,
212+ "eps": 2.85,
213+ "ebt": 51002000000,
214+ "ebitda": 54216000000,
215+ "ebit": 51002000000,
216+ "dps": 0.26,
217+ "depr": 3214000000,
218+ "def_rev": 9413000000,
219+ "debt_nc": 76685000000,
220+ "debt_c": 13824000000,
221+ "debt": 90509000000,
222+ "cogs": 74525000000,
223+ "cos_inc": 42097000000,
224+ "cash": 45317000000,
225+ "capex": -2373000000,
226+ "assets_nc": 221193000000,
227+ "assets_c": 158104000000,
228+ "assets_tot": 379297000000,
229+ "acc_oci": -4854000000,
230+ "roa": 0.11,
231+ "roe": 0.48,
232+ "roic": 0.24,
233+ "gm": 0.48,
234+ "om": 0.35,
235+ "intr_cover": null
236+ },
237+ {
238+ "period_end": "2025-09-27",
239+ "period_type": "q",
240+ "filing_date": "2025-10-31",
241+ "actual_year": 2025,
242+ "actual_period": "q3",
243+ "fiscal_year": 2025,
244+ "fiscal_period": "q4",
245+ "tax_liability": 0,
246+ "tax_expense": 5338000000,
247+ "tax_asset": 0,
248+ "shares_av_diluted": 14863610000,
249+ "shares_av": 14815306000,
250+ "shares": 14776353000,
251+ "sgna": 7048000000,
252+ "share_comp": 3183000000,
253+ "research_dev": 8866000000,
254+ "rev": 102466000000,
255+ "ret_earnings": -14264000000,
256+ "receivables": 72957000000,
257+ "pref_divs": 0,
258+ "ppe": 49834000000,
259+ "payables": 69860000000,
260+ "op_inc": 32427000000,
261+ "op_ex": 15914000000,
262+ "net_inc_non_control": 0,
263+ "net_loss_discontinued": 0,
264+ "net_inc_com": 27466000000,
265+ "net_inc": 27466000000,
266+ "fx_impact_cash": 0,
267+ "cash_op": 29728000000,
268+ "cash_inv_acq": 1160000000,
269+ "cash_inv": -2587000000,
270+ "cash_fin": -27476000000,
271+ "cash_div": -3862000000,
272+ "cash_debt_sec": -3217000000,
273+ "cash_equity": -20132000000,
274+ "cash_business_acq": 0,
275+ "cash_change": -335000000,
276+ "liab_nc": 119877000000,
277+ "liab_c": 165631000000,
278+ "liab_tot": 285508000000,
279+ "inv_nc": 77723000000,
280+ "inv_c": 18763000000,
281+ "investments": 96486000000,
282+ "inventory": 5718000000,
283+ "interest_exp": 0,
284+ "intangibles": 0,
285+ "gp": 48341000000,
286+ "equity": 73733000000,
287+ "eps_d": 1.84,
288+ "eps": 1.85,
289+ "ebt": 32804000000,
290+ "ebitda": 35931000000,
291+ "ebit": 32804000000,
292+ "dps": 0.26,
293+ "depr": 3127000000,
294+ "def_rev": 9055000000,
295+ "debt_nc": 78328000000,
296+ "debt_c": 20329000000,
297+ "debt": 98657000000,
298+ "cogs": 54125000000,
299+ "cos_inc": 27466000000,
300+ "cash": 35934000000,
301+ "capex": -3242000000,
302+ "assets_nc": 211284000000,
303+ "assets_c": 147957000000,
304+ "assets_tot": 359241000000,
305+ "acc_oci": -5571000000,
306+ "roa": 0.08,
307+ "roe": 0.37,
308+ "roic": 0.16,
309+ "gm": 0.47,
310+ "om": 0.32,
311+ "intr_cover": null
312+ },
313+ {
314+ "period_end": "2025-06-28",
315+ "period_type": "q",
316+ "filing_date": "2025-08-01",
317+ "actual_year": 2025,
318+ "actual_period": "q2",
319+ "fiscal_year": 2025,
320+ "fiscal_period": "q3",
321+ "tax_liability": 0,
322+ "tax_expense": 4597000000,
323+ "tax_asset": 0,
324+ "shares_av_diluted": 14948179000,
325+ "shares_av": 14902886000,
326+ "shares": 14840390000,
327+ "sgna": 6650000000,
328+ "share_comp": 3168000000,
329+ "research_dev": 8866000000,
330+ "rev": 94036000000,
331+ "ret_earnings": -17607000000,
332+ "receivables": 46835000000,
333+ "pref_divs": 0,
334+ "ppe": 48508000000,
335+ "payables": 50374000000,
336+ "op_inc": 28202000000,
337+ "op_ex": 15516000000,
338+ "net_inc_non_control": 0,
339+ "net_loss_discontinued": 0,
340+ "net_inc_com": 23434000000,
341+ "net_inc": 23434000000,
342+ "fx_impact_cash": 0,
343+ "cash_op": 27867000000,
344+ "cash_inv_acq": 8875000000,
345+ "cash_inv": 5073000000,
346+ "cash_fin": -24833000000,
347+ "cash_div": -3945000000,
348+ "cash_debt_sec": 2711000000,
349+ "cash_equity": -21075000000,
350+ "cash_business_acq": 0,
351+ "cash_change": 8107000000,
352+ "liab_nc": 124545000000,
353+ "liab_c": 141120000000,
354+ "liab_tot": 265665000000,
355+ "inv_nc": 77614000000,
356+ "inv_c": 19103000000,
357+ "investments": 96717000000,
358+ "inventory": 5925000000,
359+ "interest_exp": 0,
360+ "intangibles": 0,
361+ "gp": 43718000000,
362+ "equity": 65830000000,
363+ "eps_d": 1.57,
364+ "eps": 1.57,
365+ "ebt": 28031000000,
366+ "ebitda": 30861000000,
367+ "ebit": 28031000000,
368+ "dps": 0.26,
369+ "depr": 2830000000,
370+ "def_rev": 8979000000,
371+ "debt_nc": 82430000000,
372+ "debt_c": 19268000000,
373+ "debt": 101698000000,
374+ "cogs": 50318000000,
375+ "cos_inc": 23434000000,
376+ "cash": 36269000000,
377+ "capex": -3462000000,
378+ "assets_nc": 209004000000,
379+ "assets_c": 122491000000,
380+ "assets_tot": 331495000000,
381+ "acc_oci": -6369000000,
382+ "roa": 0.07,
383+ "roe": 0.36,
384+ "roic": 0.14,
385+ "gm": 0.46,
386+ "om": 0.3,
387+ "intr_cover": null
388+ },
389+ {
390+ "period_end": "2025-03-29",
391+ "period_type": "q",
392+ "filing_date": "2025-05-02",
393+ "actual_year": 2025,
394+ "actual_period": "q1",
395+ "fiscal_year": 2025,
396+ "fiscal_period": "q2",
397+ "tax_liability": 0,
398+ "tax_expense": 4530000000,
399+ "tax_asset": 0,
400+ "shares_av_diluted": 15056133000,
401+ "shares_av": 14994082000,
402+ "shares": 14935826000,
403+ "sgna": 6728000000,
404+ "share_comp": 3226000000,
405+ "research_dev": 8550000000,
406+ "rev": 95359000000,
407+ "ret_earnings": -15552000000,
408+ "receivables": 49798000000,
409+ "pref_divs": 0,
410+ "ppe": 46876000000,
411+ "payables": 54126000000,
412+ "op_inc": 29589000000,
413+ "op_ex": 15278000000,
414+ "net_inc_non_control": 0,
415+ "net_loss_discontinued": 0,
416+ "net_inc_com": 24780000000,
417+ "net_inc": 24780000000,
418+ "fx_impact_cash": 0,
419+ "cash_op": 23952000000,
420+ "cash_inv_acq": 6020000000,
421+ "cash_inv": 2917000000,
422+ "cash_fin": -29006000000,
423+ "cash_div": -3758000000,
424+ "cash_debt_sec": 976000000,
425+ "cash_equity": -25898000000,
426+ "cash_business_acq": 0,
427+ "cash_change": -2137000000,
428+ "liab_nc": 119866000000,
429+ "liab_c": 144571000000,
430+ "liab_tot": 264437000000,
431+ "inv_nc": 84424000000,
432+ "inv_c": 20336000000,
433+ "investments": 104760000000,
434+ "inventory": 6269000000,
435+ "interest_exp": 0,
436+ "intangibles": 0,
437+ "gp": 44867000000,
438+ "equity": 66796000000,
439+ "eps_d": 1.65,
440+ "eps": 1.65,
441+ "ebt": 29310000000,
442+ "ebitda": 31971000000,
443+ "ebit": 29310000000,
444+ "dps": 0.25,
445+ "depr": 2661000000,
446+ "def_rev": 8976000000,
447+ "debt_nc": 78566000000,
448+ "debt_c": 19620000000,
449+ "debt": 98186000000,
450+ "cogs": 50492000000,
451+ "cos_inc": 24780000000,
452+ "cash": 28162000000,
453+ "capex": -3071000000,
454+ "assets_nc": 212559000000,
455+ "assets_c": 118674000000,
456+ "assets_tot": 331233000000,
457+ "acc_oci": -6363000000,
458+ "roa": 0.07,
459+ "roe": 0.37,
460+ "roic": 0.15,
461+ "gm": 0.47,
462+ "om": 0.31,
463+ "intr_cover": null
464+ },
465+ {
466+ "period_end": "2024-12-28",
467+ "period_type": "q",
468+ "filing_date": "2025-01-31",
469+ "actual_year": 2024,
470+ "actual_period": "q4",
471+ "fiscal_year": 2025,
472+ "fiscal_period": "q1",
473+ "tax_liability": 0,
474+ "tax_expense": 6254000000,
475+ "tax_asset": 0,
476+ "shares_av_diluted": 15150865000,
477+ "shares_av": 15081724000,
478+ "shares": 15022073000,
479+ "sgna": 7175000000,
480+ "share_comp": 3286000000,
481+ "research_dev": 8268000000,
482+ "rev": 124300000000,
483+ "ret_earnings": -11221000000,
484+ "receivables": 59306000000,
485+ "pref_divs": 0,
486+ "ppe": 46069000000,
487+ "payables": 61910000000,
488+ "op_inc": 42832000000,
489+ "op_ex": 15443000000,
490+ "net_inc_non_control": 0,
491+ "net_loss_discontinued": 0,
492+ "net_inc_com": 36330000000,
493+ "net_inc": 36330000000,
494+ "fx_impact_cash": 0,
495+ "cash_op": 29935000000,
496+ "cash_inv_acq": 13335000000,
497+ "cash_inv": 9792000000,
498+ "cash_fin": -39371000000,
499+ "cash_div": -3856000000,
500+ "cash_debt_sec": -8953000000,
501+ "cash_equity": -23606000000,
502+ "cash_business_acq": 0,
503+ "cash_change": 356000000,
504+ "liab_nc": 132962000000,
505+ "liab_c": 144365000000,
506+ "liab_tot": 277327000000,
507+ "inv_nc": 87593000000,
508+ "inv_c": 23476000000,
509+ "investments": 111069000000,
510+ "inventory": 6911000000,
511+ "interest_exp": 0,
512+ "intangibles": 0,
513+ "gp": 58275000000,
514+ "equity": 66758000000,
515+ "eps_d": 2.4,
516+ "eps": 2.41,
517+ "ebt": 42584000000,
518+ "ebitda": 45664000000,
519+ "ebit": 42584000000,
520+ "dps": 0.25,
521+ "depr": 3080000000,
522+ "def_rev": 8461000000,
523+ "debt_nc": 83956000000,
524+ "debt_c": 12843000000,
525+ "debt": 96799000000,
526+ "cogs": 66025000000,
527+ "cos_inc": 36330000000,
528+ "cash": 30299000000,
529+ "capex": -2940000000,
530+ "assets_nc": 210845000000,
531+ "assets_c": 133240000000,
532+ "assets_tot": 344085000000,
533+ "acc_oci": -6789000000,
534+ "roa": 0.11,
535+ "roe": 0.54,
536+ "roic": 0.22,
537+ "gm": 0.47,
538+ "om": 0.34,
539+ "intr_cover": null
540+ },
541+ {
542+ "period_end": "2024-09-28",
543+ "period_type": "q",
544+ "filing_date": "2024-11-01",
545+ "actual_year": 2024,
546+ "actual_period": "q3",
547+ "fiscal_year": 2024,
548+ "fiscal_period": "q4",
549+ "tax_liability": 0,
550+ "tax_expense": 14874000000,
551+ "tax_asset": 0,
552+ "shares_av_diluted": 15242855000,
553+ "shares_av": 15171991000,
554+ "shares": 15115823000,
555+ "sgna": 6523000000,
556+ "share_comp": 2858000000,
557+ "research_dev": 7765000000,
558+ "rev": 94930000000,
559+ "ret_earnings": -19154000000,
560+ "receivables": 66243000000,
561+ "pref_divs": 0,
562+ "ppe": 45680000000,
563+ "payables": 68960000000,
564+ "op_inc": 29591000000,
565+ "op_ex": 14288000000,
566+ "net_inc_non_control": 0,
567+ "net_loss_discontinued": 0,
568+ "net_inc_com": 14736000000,
569+ "net_inc": 14736000000,
570+ "fx_impact_cash": 0,
571+ "cash_op": 26811000000,
572+ "cash_inv_acq": 4544000000,
573+ "cash_inv": 1445000000,
574+ "cash_fin": -24948000000,
575+ "cash_div": -3804000000,
576+ "cash_debt_sec": 4387000000,
577+ "cash_equity": -25083000000,
578+ "cash_business_acq": 0,
579+ "cash_change": 3308000000,
580+ "liab_nc": 131638000000,
581+ "liab_c": 176392000000,
582+ "liab_tot": 308030000000,
583+ "inv_nc": 91479000000,
584+ "inv_c": 35228000000,
585+ "investments": 126707000000,
586+ "inventory": 7286000000,
587+ "interest_exp": 0,
588+ "intangibles": 0,
589+ "gp": 43879000000,
590+ "equity": 56950000000,
591+ "eps_d": 0.97,
592+ "eps": 0.98,
593+ "ebt": 29610000000,
594+ "ebitda": 32521000000,
595+ "ebit": 29610000000,
596+ "dps": 0.25,
597+ "depr": 2911000000,
598+ "def_rev": 8249000000,
599+ "debt_nc": 85750000000,
600+ "debt_c": 20879000000,
601+ "debt": 106629000000,
602+ "cogs": 51051000000,
603+ "cos_inc": 14736000000,
604+ "cash": 29943000000,
605+ "capex": -2908000000,
606+ "assets_nc": 211993000000,
607+ "assets_c": 152987000000,
608+ "assets_tot": 364980000000,
609+ "acc_oci": -7172000000,
610+ "roa": 0.04,
611+ "roe": 0.26,
612+ "roic": 0.09,
613+ "gm": 0.46,
614+ "om": 0.31,
615+ "intr_cover": null
616+ },
617+ {
618+ "period_end": "2024-06-29",
619+ "period_type": "q",
620+ "filing_date": "2024-08-02",
621+ "actual_year": 2024,
622+ "actual_period": "q2",
623+ "fiscal_year": 2024,
624+ "fiscal_period": "q3",
625+ "tax_liability": 0,
626+ "tax_expense": 4046000000,
627+ "tax_asset": 0,
628+ "shares_av_diluted": 15348175000,
629+ "shares_av": 15287521000,
630+ "shares": 15204137000,
631+ "sgna": 6320000000,
632+ "share_comp": 2869000000,
633+ "research_dev": 8006000000,
634+ "rev": 85777000000,
635+ "ret_earnings": -4726000000,
636+ "receivables": 43172000000,
637+ "pref_divs": 0,
638+ "ppe": 44502000000,
639+ "payables": 47574000000,
640+ "op_inc": 25352000000,
641+ "op_ex": 14326000000,
642+ "net_inc_non_control": 0,
643+ "net_loss_discontinued": 0,
644+ "net_inc_com": 21448000000,
645+ "net_inc": 21448000000,
646+ "fx_impact_cash": 0,
647+ "cash_op": 28858000000,
648+ "cash_inv_acq": 2412000000,
649+ "cash_inv": -127000000,
650+ "cash_fin": -36017000000,
651+ "cash_div": -3895000000,
652+ "cash_debt_sec": -3253000000,
653+ "cash_equity": -26522000000,
654+ "cash_business_acq": 0,
655+ "cash_change": -7286000000,
656+ "liab_nc": 133280000000,
657+ "liab_c": 131624000000,
658+ "liab_tot": 264904000000,
659+ "inv_nc": 91240000000,
660+ "inv_c": 36236000000,
661+ "investments": 127476000000,
662+ "inventory": 6165000000,
663+ "interest_exp": 0,
664+ "intangibles": 0,
665+ "gp": 39678000000,
666+ "equity": 66708000000,
667+ "eps_d": 1.4,
668+ "eps": 1.4,
669+ "ebt": 25494000000,
670+ "ebitda": 28344000000,
671+ "ebit": 25494000000,
672+ "dps": 0.25,
673+ "depr": 2850000000,
674+ "def_rev": 8053000000,
675+ "debt_nc": 86196000000,
676+ "debt_c": 15108000000,
677+ "debt": 101304000000,
678+ "cogs": 46099000000,
679+ "cos_inc": 21448000000,
680+ "cash": 25565000000,
681+ "capex": -2151000000,
682+ "assets_nc": 206177000000,
683+ "assets_c": 125435000000,
684+ "assets_tot": 331612000000,
685+ "acc_oci": -8416000000,
686+ "roa": 0.06,
687+ "roe": 0.32,
688+ "roic": 0.13,
689+ "gm": 0.46,
690+ "om": 0.3,
691+ "intr_cover": null
692+ },
693+ {
694+ "period_end": "2024-03-30",
695+ "period_type": "q",
696+ "filing_date": "2024-05-03",
697+ "actual_year": 2024,
698+ "actual_period": "q1",
699+ "fiscal_year": 2024,
700+ "fiscal_period": "q2",
701+ "tax_liability": 0,
702+ "tax_expense": 4422000000,
703+ "tax_asset": 0,
704+ "shares_av_diluted": 15464709000,
705+ "shares_av": 15405856000,
706+ "shares": 15334082000,
707+ "sgna": 6468000000,
708+ "share_comp": 2964000000,
709+ "research_dev": 7903000000,
710+ "rev": 90753000000,
711+ "ret_earnings": 4339000000,
712+ "receivables": 41150000000,
713+ "pref_divs": 0,
714+ "ppe": 43546000000,
715+ "payables": 45753000000,
716+ "op_inc": 27900000000,
717+ "op_ex": 14371000000,
718+ "net_inc_non_control": 0,
719+ "net_loss_discontinued": 0,
720+ "net_inc_com": 23636000000,
721+ "net_inc": 23636000000,
722+ "fx_impact_cash": 0,
723+ "cash_op": 22690000000,
724+ "cash_inv_acq": 2131000000,
725+ "cash_inv": -310000000,
726+ "cash_fin": -30433000000,
727+ "cash_div": -3710000000,
728+ "cash_debt_sec": -3148000000,
729+ "cash_equity": -23205000000,
730+ "cash_business_acq": 0,
731+ "cash_change": -8053000000,
732+ "liab_nc": 139395000000,
733+ "liab_c": 123822000000,
734+ "liab_tot": 263217000000,
735+ "inv_nc": 95187000000,
736+ "inv_c": 34455000000,
737+ "investments": 129642000000,
738+ "inventory": 6232000000,
739+ "interest_exp": 0,
740+ "intangibles": 0,
741+ "gp": 42271000000,
742+ "equity": 74194000000,
743+ "eps_d": 1.53,
744+ "eps": 1.53,
745+ "ebt": 28058000000,
746+ "ebitda": 30894000000,
747+ "ebit": 28058000000,
748+ "dps": 0.24,
749+ "depr": 2836000000,
750+ "def_rev": 8012000000,
751+ "debt_nc": 91831000000,
752+ "debt_c": 12759000000,
753+ "debt": 104590000000,
754+ "cogs": 48482000000,
755+ "cos_inc": 23636000000,
756+ "cash": 32695000000,
757+ "capex": -1996000000,
758+ "assets_nc": 208995000000,
759+ "assets_c": 128416000000,
760+ "assets_tot": 337411000000,
761+ "acc_oci": -8960000000,
762+ "roa": 0.07,
763+ "roe": 0.32,
764+ "roic": 0.13,
765+ "gm": 0.47,
766+ "om": 0.31,
767+ "intr_cover": null
768+ },
769+ {
770+ "period_end": "2023-12-30",
771+ "period_type": "q",
772+ "filing_date": "2024-02-02",
773+ "actual_year": 2023,
774+ "actual_period": "q4",
775+ "fiscal_year": 2024,
776+ "fiscal_period": "q1",
777+ "tax_liability": 0,
778+ "tax_expense": 6407000000,
779+ "tax_asset": 0,
780+ "shares_av_diluted": 15576641000,
781+ "shares_av": 15509763000,
782+ "shares": 15441881000,
783+ "sgna": 6786000000,
784+ "share_comp": 2997000000,
785+ "research_dev": 7696000000,
786+ "rev": 119575000000,
787+ "ret_earnings": 8242000000,
788+ "receivables": 50102000000,
789+ "pref_divs": 0,
790+ "ppe": 43666000000,
791+ "payables": 58146000000,
792+ "op_inc": 40373000000,
793+ "op_ex": 14482000000,
794+ "net_inc_non_control": 0,
795+ "net_loss_discontinued": 0,
796+ "net_inc_com": 33916000000,
797+ "net_inc": 33916000000,
798+ "fx_impact_cash": 0,
799+ "cash_op": 39895000000,
800+ "cash_inv_acq": 4603000000,
801+ "cash_inv": 1927000000,
802+ "cash_fin": -30585000000,
803+ "cash_div": -3825000000,
804+ "cash_debt_sec": -3984000000,
805+ "cash_equity": -20139000000,
806+ "cash_business_acq": 0,
807+ "cash_change": 11237000000,
808+ "liab_nc": 145441000000,
809+ "liab_c": 133973000000,
810+ "liab_tot": 279414000000,
811+ "inv_nc": 99475000000,
812+ "inv_c": 32340000000,
813+ "investments": 131815000000,
814+ "inventory": 6511000000,
815+ "interest_exp": 0,
816+ "intangibles": 0,
817+ "gp": 54855000000,
818+ "equity": 74100000000,
819+ "eps_d": 2.18,
820+ "eps": 2.19,
821+ "ebt": 40323000000,
822+ "ebitda": 43171000000,
823+ "ebit": 40323000000,
824+ "dps": 0.24,
825+ "depr": 2848000000,
826+ "def_rev": 8264000000,
827+ "debt_nc": 95088000000,
828+ "debt_c": 12952000000,
829+ "debt": 108040000000,
830+ "cogs": 64720000000,
831+ "cos_inc": 33916000000,
832+ "cash": 40760000000,
833+ "capex": -2392000000,
834+ "assets_nc": 209822000000,
835+ "assets_c": 143692000000,
836+ "assets_tot": 353514000000,
837+ "acc_oci": -9378000000,
838+ "roa": 0.1,
839+ "roe": 0.46,
840+ "roic": 0.19,
841+ "gm": 0.46,
842+ "om": 0.34,
843+ "intr_cover": null
844+ },
845+ {
846+ "period_end": "2023-09-30",
847+ "period_type": "q",
848+ "filing_date": "2023-11-03",
849+ "actual_year": 2023,
850+ "actual_period": "q3",
851+ "fiscal_year": 2023,
852+ "fiscal_period": "q4",
853+ "tax_liability": 0,
854+ "tax_expense": 4042000000,
855+ "tax_asset": 0,
856+ "shares_av_diluted": 15672399000,
857+ "shares_av": 15599433000,
858+ "shares": 15552752000,
859+ "sgna": 6151000000,
860+ "share_comp": 2625000000,
861+ "research_dev": 7307000000,
862+ "rev": 89498000000,
863+ "ret_earnings": -214000000,
864+ "receivables": 60985000000,
865+ "pref_divs": 0,
866+ "ppe": 43715000000,
867+ "payables": 62611000000,
868+ "op_inc": 26969000000,
869+ "op_ex": 13458000000,
870+ "net_inc_non_control": 0,
871+ "net_loss_discontinued": 0,
872+ "net_inc_com": 22956000000,
873+ "net_inc": 22956000000,
874+ "fx_impact_cash": 0,
875+ "cash_op": 21598000000,
876+ "cash_inv_acq": 5141000000,
877+ "cash_inv": 2394000000,
878+ "cash_fin": -23153000000,
879+ "cash_div": -3758000000,
880+ "cash_debt_sec": 1993000000,
881+ "cash_equity": -21003000000,
882+ "cash_business_acq": 0,
883+ "cash_change": 839000000,
884+ "liab_nc": 145129000000,
885+ "liab_c": 145308000000,
886+ "liab_tot": 290437000000,
887+ "inv_nc": 100544000000,
888+ "inv_c": 31590000000,
889+ "investments": 132134000000,
890+ "inventory": 6331000000,
891+ "interest_exp": 1002000000,
892+ "intangibles": 0,
893+ "gp": 40427000000,
894+ "equity": 62146000000,
895+ "eps_d": 1.46,
896+ "eps": 1.47,
897+ "ebt": 26998000000,
898+ "ebitda": 30653000000,
899+ "ebit": 28000000000,
900+ "dps": 0.24,
901+ "depr": 2653000000,
902+ "def_rev": 8061000000,
903+ "debt_nc": 95281000000,
904+ "debt_c": 15807000000,
905+ "debt": 111088000000,
906+ "cogs": 49071000000,
907+ "cos_inc": 22956000000,
908+ "cash": 29965000000,
909+ "capex": -2163000000,
910+ "assets_nc": 209017000000,
911+ "assets_c": 143566000000,
912+ "assets_tot": 352583000000,
913+ "acc_oci": -11452000000,
914+ "roa": 0.07,
915+ "roe": 0.37,
916+ "roic": 0.14,
917+ "gm": 0.45,
918+ "om": 0.3,
919+ "intr_cover": 27.94
920+ }
921+ ]
922+ },
923+ "meta": {
924+ "request_timestamp": "2026-11-01T22:29:13.163211",
925+ "processing_time_ms": 19
926+ }
927+}
\ No newline at end of file
added tests/test_fundamentals_pit.py +56 −0
@@ -0,0 +1,56 @@
1+from __future__ import annotations
2+
3+import json
4+from datetime import UTC, date, datetime
5+from pathlib import Path
6+
7+from findata_fundamentals import SecEdgarConnector, available_at_policy, facts_from_companyfacts, facts_from_financials
8+
9+FIX = Path(__file__).parent / "fixtures" / "fundamentals"
10+NOW = datetime(2026, 11, 1, 12, 0, tzinfo=UTC)
11+
12+
13+def test_available_at_policy_is_next_day_midnight_et():
14+ # filing 2026-07-31 → available 2026-08-01 00:00 EDT = 2026-08-01 04:00 UTC
15+ assert available_at_policy(date(2026, 7, 31), date(2026, 6, 27)) == datetime(2026, 8, 1, 4, 0, tzinfo=UTC)
16+ # winter: 2026-01-30 → 2026-01-31 00:00 EST = 05:00 UTC
17+ assert available_at_policy(date(2026, 1, 30), date(2025, 12, 27)) == datetime(2026, 1, 31, 5, 0, tzinfo=UTC)
18+ # missing filing date → period_end + 90 d + 1
19+ assert available_at_policy(None, date(2026, 6, 30)).date() == date(2026, 9, 29)
20+
21+
22+def test_facts_from_financials_long_format():
23+ payload = json.loads((FIX / "financials_all_AAPL_quarterly.json").read_text())
24+ facts = list(facts_from_financials(payload, NOW))
25+ assert len(facts) > 12 * 50
26+ latest = [f for f in facts if f["fiscal_period_end"] == date(2026, 6, 27)]
27+ rev = next(f for f in latest if f["metric"] == "rev")
28+ assert rev["value"] == 109417000000.0 and rev["unit"] == "USD" and rev["fiscal_period_type"] == "q"
29+ assert rev["filing_date"] == date(2026, 7, 31) and rev["available_at"] == datetime(2026, 8, 1, 4, 0, tzinfo=UTC)
30+ assert rev["entity_id"] == "frd:AAPL" and rev["source_id"] == "firstrate_fundamentals" and rev["pit_fallback"] is False
31+ assert all(f["available_at"] > datetime.combine(f["fiscal_period_end"], datetime.min.time(), tzinfo=UTC) for f in facts)
32+
33+
34+def test_sec_edgar_pending_without_user_agent():
35+ c = SecEdgarConnector(user_agent="")
36+ assert c.status()["status"] == "pending_configuration"
37+ try:
38+ c.companyfacts(320193)
39+ raise AssertionError("must refuse without user agent")
40+ except RuntimeError as e:
41+ assert "pending configuration" in str(e)
42+
43+
44+def test_companyfacts_parsing_keeps_each_filing_as_revision():
45+ payload = {"cik": 320193, "entityName": "Apple Inc.", "facts": {"us-gaap": {"Revenues": {"units": {"USD": [
46+ {"start": "2024-09-29", "end": "2024-12-28", "val": 124300000000, "accn": "0000320193-25-000008", "fy": 2025, "fp": "Q1", "form": "10-Q", "filed": "2025-01-31", "frame": "CY2024Q4"},
47+ {"start": "2024-09-29", "end": "2024-12-28", "val": 124300000001, "accn": "0000320193-25-000999", "fy": 2025, "fp": "Q2", "form": "10-Q", "filed": "2025-05-02"},
48+ {"start": "2023-10-01", "end": "2024-09-28", "val": 391035000000, "accn": "0000320193-24-000123", "fy": 2024, "fp": "FY", "form": "10-K", "filed": "2024-11-01"},
49+ ]}}}}}
50+ facts = list(facts_from_companyfacts(payload, NOW))
51+ assert len(facts) == 3
52+ q = [f for f in facts if f["fiscal_period_end"] == date(2024, 12, 28)]
53+ assert sorted(f["filing_date"] for f in q) == [date(2025, 1, 31), date(2025, 5, 2)]
54+ assert q[0]["available_at"] == datetime(2025, 2, 1, 5, 0, tzinfo=UTC)
55+ fy = next(f for f in facts if f["fiscal_period_type"] == "a")
56+ assert fy["metric"] == "rev" and fy["entity_id"] == "sec:CIK0000320193"
added tests/test_ingest_lake.py +157 −0
@@ -0,0 +1,157 @@
1+"""Integration: lake publication, durable queue, incremental sync idempotency, resume, derive, compaction."""
2+from __future__ import annotations
3+
4+import json
5+from datetime import UTC, date, datetime
6+
7+import pyarrow as pa
8+import pytest
9+
10+from findata_core.db import connection, fetch_one, transaction
11+from findata_ingest import queue
12+from findata_ingest.lake import live_files
13+from findata_ingest.reader import apply_adjustment, read_bars, run_sql
14+from findata_ingest.tasks import backfill, compaction, derive, incremental, metadata
15+from tests.conftest import build_update_zip
16+
17+DS = "market_bars.stock.1m.raw"
18+
19+
20+def _job(kind, dataset_id, params, run=True):
21+ jid = queue.enqueue(kind, dataset_id, params, created_by="test")
22+ job = queue.claim_next([kind])
23+ assert job and str(job["id"]) == jid
24+ return job
25+
26+
27+def _rows(ds=DS):
28+ with connection() as conn:
29+ return conn.execute("SELECT coalesce(sum(row_count),0) n, count(*) f FROM lake_files WHERE dataset_id=%s AND version_removed IS NULL", (ds,)).fetchone()
30+
31+
32+def test_queue_retry_backoff_and_dead_letter():
33+ jid = queue.enqueue("quality", None, {"x": 1}, max_attempts=2, dedupe_key="q-test")
34+ assert queue.enqueue("quality", None, {"x": 1}, dedupe_key="q-test") is None # deduped while queued
35+ job = queue.claim_next(["quality"])
36+ assert job["attempts"] == 1
37+ queue.finish(job, False, error=f"boom userid=SECRETVALUE")
38+ r = fetch_one("SELECT status, last_error, next_run_at > now() later FROM ingest_jobs WHERE id=%s", (jid,))
39+ assert r["status"] == "queued" and r["later"] and "SECRETVALUE" not in r["last_error"]
40+ with transaction() as conn:
41+ conn.execute("UPDATE ingest_jobs SET next_run_at=now() WHERE id=%s", (jid,))
42+ job = queue.claim_next(["quality"])
43+ queue.finish(job, False, error="boom again")
44+ assert fetch_one("SELECT status FROM ingest_jobs WHERE id=%s", (jid,))["status"] == "dead"
45+
46+
47+def test_metadata_then_incremental_is_idempotent(vendor, lake, log):
48+ metadata.run(_job("metadata", None, {"types": ["stock", "etf"]}), lake, log)
49+ with connection() as conn:
50+ n = conn.execute("SELECT count(*) FROM instruments").fetchone()["count"]
51+ delisted = conn.execute("SELECT status FROM instruments WHERE symbol='TWTR-DELISTED'").fetchone()["status"]
52+ splits = conn.execute("SELECT count(*) FROM corporate_actions WHERE action_type='split'").fetchone()["count"]
53+ assert n == 5 and delisted == "delisted" and splits == 4
54+
55+ # first incremental: 3 symbols, one session
56+ r1 = incremental.run(_job("incremental", DS, {"type": "stock", "period": "day"}), lake, log)
57+ assert r1["rows_published"] == 900 + 390 + 532 or r1["rows_published"] > 1500
58+ assert r1["version"] == 1 and r1["files_added"] == 1 and r1["symbols"] == 3
59+ v1 = _rows()
60+ cov = fetch_one("SELECT count(*) n FROM coverage WHERE dataset_id=%s", (DS,))["n"]
61+ assert cov == 3
62+ raw = fetch_one("SELECT status, path FROM raw_archives WHERE dataset_id=%s", (DS,))
63+ assert raw["status"] == "published" and (lake.root / raw["path"]).exists() # immutable raw kept
64+
65+ # re-run: vendor unchanged → skipped, nothing duplicated
66+ r2 = incremental.run(_job("incremental", DS, {"type": "stock", "period": "day"}), lake, log)
67+ assert r2.get("skipped") and _rows() == v1
68+
69+ # forced re-fetch of the identical archive → recognised by sha256, no new version
70+ r3 = incremental.run(_job("incremental", DS, {"type": "stock", "period": "day", "force": True}), lake, log)
71+ assert r3.get("skipped") and r3["reason"] == "archive already ingested" and _rows() == v1
72+
73+ # vendor republishes the same session with a corrected bar → supersede (no duplicates, version 2)
74+ fixed = (lake.root.parent / "x").exists() # noqa: F841
75+ import pathlib
76+ txt = (pathlib.Path(__file__).parent / "fixtures" / "firstrate" / "AAPL_day_1min_UNADJUSTED.txt").read_text()
77+ txt = txt.replace("2026-10-30 09:30:00,346.63,348.51,346.62,348.26,1119437", "2026-10-30 09:30:00,346.63,348.51,346.62,348.27,1119437")
78+ vendor.archive_override = build_update_zip(mutate={"AAPL": txt})
79+ vendor.last_update = "2026-10-30"
80+ r4 = incremental.run(_job("incremental", DS, {"type": "stock", "period": "day", "force": True}), lake, log)
81+ assert not r4.get("skipped") and r4["version"] == 2 and r4["files_retired"] == 1
82+ v2 = _rows()
83+ assert v2["n"] == v1["n"] and v2["f"] == 1
84+ t, meta = read_bars(DS, ["AAPL"], date(2026, 10, 30), date(2026, 10, 30))
85+ assert t.num_rows == 900 and meta["version"] == 2
86+ row = t.filter(pa.compute.equal(t.column("timestamp_utc"), pa.scalar(datetime(2026, 10, 30, 13, 30, tzinfo=UTC), pa.timestamp("us", tz="UTC")))).to_pylist()[0]
87+ assert row["close"] == 348.27
88+ # snapshot reproducibility: version 1 still readable from retired file set
89+ t1, _ = read_bars(DS, ["AAPL"], date(2026, 10, 30), date(2026, 10, 30), version=1)
90+ assert t1.num_rows == 900
91+ vendor.archive_override = None
92+ # no duplicate (instrument, ts) anywhere in the live set
93+ dup = run_sql("SELECT count(*) FROM (SELECT instrument_id, timestamp_utc, count(*) c FROM market_bars_stock_1m_raw GROUP BY 1,2 HAVING c > 1)", 10, 10,
94+ {"market_bars_stock_1m_raw": [str(lake.abs(f["path"])) for f in _live()]})[0].to_pylist()[0]
95+ assert list(dup.values())[0] == 0
96+
97+
98+def _live():
99+ with connection() as conn:
100+ return live_files(conn, DS)
101+
102+
103+def test_derive_5m_from_published_session(vendor, lake, log):
104+ job = queue.claim_next(["derive"]) # enqueued by the incremental run
105+ assert job is not None
106+ r = derive.run(job, lake, log)
107+ assert r["rows"] > 0
108+ t, _ = read_bars("market_bars.stock.5m.raw", ["AAPL"], date(2026, 10, 30), date(2026, 10, 30))
109+ assert 150 <= t.num_rows <= 192 and t.column("interval")[0].as_py() == "5m"
110+ one, _ = read_bars(DS, ["AAPL"], date(2026, 10, 30), date(2026, 10, 30))
111+ assert abs(sum(t.column("volume").to_pylist()) - sum(one.column("volume").to_pylist())) < 1e-6
112+
113+
114+def test_adjustment_applied_at_query_time(vendor, lake, log):
115+ with transaction() as conn:
116+ conn.execute("INSERT INTO corporate_actions(instrument_id, action_type, ex_date, value, source_id) VALUES ('firstrate:stock:AAPL','split','2026-10-31',2,'firstrate') ON CONFLICT DO NOTHING")
117+ raw, _ = read_bars(DS, ["AAPL"], date(2026, 10, 30), date(2026, 10, 30))
118+ adj, notes = apply_adjustment(raw, "split")
119+ assert abs(adj.column("close")[0].as_py() * 2 - raw.column("close")[0].as_py()) < 1e-9
120+ assert abs(adj.column("volume")[0].as_py() - raw.column("volume")[0].as_py() * 2) < 1e-6
121+ assert adj.column("adjustment_type")[0].as_py() == "split" and raw.column("adjustment_type")[0].as_py() == "raw"
122+ with transaction() as conn:
123+ conn.execute("DELETE FROM corporate_actions WHERE ex_date='2026-10-31'")
124+
125+
126+def test_backfill_resumes_from_checkpoint_and_respects_delta_dates(vendor, lake, log):
127+ job = _job("backfill", DS, {"type": "stock", "ticker_range": "A", "delete_raw_after_publish": False})
128+ # simulate a crash after AAL was published: pre-seed the checkpoint
129+ queue.set_checkpoint(f"job:{job['id']}", "state", {"done_symbols": ["AAL"]})
130+ r = backfill.run(job, lake, log)
131+ # all fixture rows fall on 2026-10-30 which already lives in a delta file → excluded, nothing duplicated
132+ assert r["symbols"] == 0 and r["skipped_rows_in_delta"] > 0
133+ ck = queue.get_checkpoint(f"job:{job['id']}", "state")
134+ assert set(ck["done_symbols"]) == {"A", "AAL", "AAPL"}
135+ assert _rows()["f"] == 1
136+
137+
138+def test_compaction_merges_delta_into_base_and_checks_invariant(vendor, lake, log):
139+ job = _job("compaction", DS, {"min_age_days": 0})
140+ r = compaction.run(job, lake, log)
141+ assert r["compacted_files"] == 1 and r["base_files_written"] == 3
142+ live = _live()
143+ assert all(f["layout"] == "base" for f in live) and len(live) == 3
144+ t, _ = read_bars(DS, ["AAPL"], date(2026, 10, 30), date(2026, 10, 30))
145+ assert t.num_rows == 900
146+ # the retired delta file was moved out of the live tree
147+ assert not any(p.name.endswith(".parquet") for p in (lake.root / "curated" / DS / "delta").rglob("*.parquet"))
148+ assert any((lake.root / "_retired").rglob("*.parquet"))
149+
150+
151+def test_run_sql_sandbox_rejects_mutations():
152+ views = {"market_bars_stock_1m_raw": []}
153+ for bad in ["DROP TABLE x", "SELECT 1; SELECT 2", "COPY (SELECT 1) TO '/tmp/x'", "INSTALL httpfs", "SELECT * FROM read_csv('/etc/passwd')", "ATTACH 'x.db'"]:
154+ with pytest.raises(ValueError):
155+ run_sql(bad, 10, 5, views)
156+ t, meta = run_sql("SELECT 1 AS one", 10, 5, views)
157+ assert t.to_pylist() == [{"one": 1}]
added tests/test_parse_firstrate.py +72 −0
@@ -0,0 +1,72 @@
1+from __future__ import annotations
2+
3+from datetime import UTC, date, datetime
4+from pathlib import Path
5+
6+import pyarrow as pa
7+
8+from findata_market_firstrate.archive import parse_bar_file, ticker_from_filename
9+from findata_schema import BARS_SCHEMA, Adjustment, AssetClass, Interval
10+
11+FIX = Path(__file__).parent / "fixtures" / "firstrate"
12+NOW = datetime(2026, 11, 1, 12, 0, tzinfo=UTC)
13+
14+
15+def _parse(text: str, symbol="TEST", interval=Interval.m1):
16+ return parse_bar_file(text.encode(), symbol, AssetClass.stock, interval, Adjustment.raw, "firstrate", NOW)
17+
18+
19+def test_ticker_from_filename_variants():
20+ assert ticker_from_filename("AAPL_day_1min_UNADJUSTED.txt") == "AAPL"
21+ assert ticker_from_filename("AAPL_1min.txt") == "AAPL"
22+ assert ticker_from_filename("BRK.B_full_1min_adj_split.txt") == "BRK.B"
23+ assert ticker_from_filename("TWTR-DELISTED_1min.txt") == "TWTR-DELISTED"
24+ assert ticker_from_filename("nested.zip!/ES_contin_adj_ratio_1min.txt") == "ES"
25+
26+
27+def test_parse_real_session_bar_open_convention():
28+ t, raw = parse_bar_file((FIX / "AAPL_day_1min_UNADJUSTED.txt").read_bytes(), "AAPL", AssetClass.stock, Interval.m1, Adjustment.raw, "firstrate", NOW)
29+ assert raw == 900 and t.num_rows == 900
30+ assert t.schema.equals(BARS_SCHEMA)
31+ first = t.slice(0, 1).to_pylist()[0]
32+ # 2026-10-30 04:00 America/New_York (EDT, UTC-4) == 08:00 UTC ; timestamp marks the bar OPEN
33+ assert first["timestamp_utc"] == datetime(2026, 10, 30, 8, 0, tzinfo=UTC)
34+ assert first["session_date"] == date(2026, 10, 30)
35+ assert first["open"] == 350.76 and first["volume"] == 4802.0
36+ assert first["instrument_id"] == "firstrate:stock:AAPL" and first["adjustment_type"] == "raw"
37+ last = t.slice(899, 1).to_pylist()[0]
38+ assert last["timestamp_utc"] == datetime(2026, 10, 30, 23, 59, tzinfo=UTC) # 19:59 EDT
39+
40+
41+def test_dst_spring_forward_and_fall_back():
42+ # 2026-03-06 (EST, UTC-5): 09:30 ET = 14:30 UTC ; 2026-03-09 (EDT, UTC-4): 09:30 ET = 13:30 UTC
43+ t, _ = _parse("2026-03-06 09:30:00,1,2,0.5,1.5,10\n2026-03-09 09:30:00,1,2,0.5,1.5,10\n")
44+ ts = t.column("timestamp_utc").to_pylist()
45+ assert ts == [datetime(2026, 3, 6, 14, 30, tzinfo=UTC), datetime(2026, 3, 9, 13, 30, tzinfo=UTC)]
46+ # fall back 2026-11-01: 2026-10-30 09:30 EDT = 13:30 UTC ; 2026-11-02 09:30 EST = 14:30 UTC
47+ t, _ = _parse("2026-10-30 09:30:00,1,2,0.5,1.5,10\n2026-11-02 09:30:00,1,2,0.5,1.5,10\n")
48+ ts = t.column("timestamp_utc").to_pylist()
49+ assert ts == [datetime(2026, 10, 30, 13, 30, tzinfo=UTC), datetime(2026, 11, 2, 14, 30, tzinfo=UTC)]
50+
51+
52+def test_nonexistent_local_time_is_dropped_and_counted():
53+ # 02:30 on 2026-03-08 does not exist in America/New_York
54+ t, raw = _parse("2026-03-08 02:30:00,1,2,0.5,1.5,10\n2026-03-08 04:00:00,1,2,0.5,1.5,10\n")
55+ assert raw == 2 and t.num_rows == 1
56+
57+
58+def test_crlf_header_and_fx_two_field_layout():
59+ t, raw = _parse("DateTime,Open,High,Low,Close,Volume\r\n2026-10-30 09:30:00,1,2,0.5,1.5,10\r\n2026-10-30 09:31:00,1,2,0.5,1.5,0\r\n")
60+ assert raw == 2 and t.num_rows == 2
61+ t, raw = _parse("20261030,09:30:00,1.1,1.2,1.0,1.15,0\n")
62+ assert t.num_rows == 1 and t.column("timestamp_utc")[0].as_py() == datetime(2026, 10, 30, 13, 30, tzinfo=UTC)
63+
64+
65+def test_daily_layout_with_open_interest_column():
66+ t, raw = _parse("2026-10-30,1,2,0.5,1.5,10,1234\n", interval=Interval.d1)
67+ assert t.num_rows == 1 and t.column("session_date")[0].as_py() == date(2026, 10, 30)
68+
69+
70+def test_duplicates_are_kept_for_flagging_not_dropped():
71+ t, raw = _parse("2026-10-30 09:30:00,1,2,0.5,1.5,10\n2026-10-30 09:30:00,1,2,0.5,1.5,10\n")
72+ assert raw == 2 and t.num_rows == 2
added tests/test_redaction.py +29 −0
@@ -0,0 +1,29 @@
1+from __future__ import annotations
2+
3+import httpx
4+import pytest
5+
6+from findata_core.redact import redact
7+from findata_market_firstrate.client import FRDClient, FRDError
8+from tests.conftest import TEST_USERID
9+
10+
11+def test_redact_query_params_and_values():
12+ s = f"GET https://firstratedata.com/api/last_update?type=stock&userid={TEST_USERID} failed; api_key=abc"
13+ out = redact(s, [TEST_USERID])
14+ assert TEST_USERID not in out and "api_key=<REDACTED>" in out and "userid=<REDACTED>" in out
15+
16+
17+def test_client_never_leaks_userid_in_errors(vendor):
18+ c = FRDClient(TEST_USERID, min_interval_s=0)
19+ assert TEST_USERID not in c.sanitized_url("last_update", {"type": "stock"})
20+ with pytest.raises(FRDError) as ei:
21+ c._get_text("does_not_exist", {"type": "stock"})
22+ assert TEST_USERID not in str(ei.value)
23+
24+
25+def test_settings_repr_hides_secrets():
26+ from findata_core.config import get_settings
27+ s = get_settings()
28+ assert TEST_USERID not in repr(s) and TEST_USERID not in str(s.model_dump())
29+ assert TEST_USERID in s.secret_values()
added tests/test_validators_aggregate.py +108 −0
@@ -0,0 +1,108 @@
1+from __future__ import annotations
2+
3+from datetime import UTC, date, datetime
4+from pathlib import Path
5+
6+import pyarrow as pa
7+
8+from findata_market_firstrate.archive import parse_bar_file
9+from findata_quality import aggregate_bars, validate_bars
10+from findata_quality.calendar import xnys
11+from findata_quality.validators import session_coverage
12+from findata_schema import Adjustment, AssetClass, Interval, QualityFlag
13+
14+FIX = Path(__file__).parent / "fixtures" / "firstrate"
15+NOW = datetime(2026, 11, 1, 12, 0, tzinfo=UTC)
16+
17+
18+def _parse(text: str, symbol="TEST"):
19+ return parse_bar_file(text.encode(), symbol, AssetClass.stock, Interval.m1, Adjustment.raw, "firstrate", NOW)[0]
20+
21+
22+def _flags(t):
23+ return [set(f) for f in t.column("quality_flags").to_pylist()]
24+
25+
26+def test_clean_real_session_has_no_error_flags():
27+ t = parse_bar_file((FIX / "AAPL_day_1min_UNADJUSTED.txt").read_bytes(), "AAPL", AssetClass.stock, Interval.m1, Adjustment.raw, "firstrate", NOW)[0]
28+ out, anomalies = validate_bars(t, Interval.m1)
29+ errors = [a for a in anomalies if a["severity"] == "error"]
30+ assert errors == []
31+ assert out.num_rows == 900
32+
33+
34+def test_row_level_flags():
35+ rows = "\n".join([
36+ "2026-10-30 09:30:00,10,9,8,9.5,100", # high < open → ohlc_inconsistent
37+ "2026-10-30 09:31:00,10,11,9,10,-5", # negative volume
38+ "2026-10-30 09:32:30,10,11,9,10,5", # off interval grid
39+ "2026-10-30 09:33:00,10,11,9,10,5",
40+ "2026-10-30 09:33:00,10,11,9,10,5", # duplicate
41+ "2026-10-30 03:59:00,10,11,9,10,5", # before 04:00 ET → outside_session
42+ "2026-10-30 09:34:00,10,20,9,15,5", # +50 % vs previous close → price_jump
43+ "2026-10-30 09:35:00,0,0,0,0,5", # nonpositive price
44+ "2026-11-01 09:30:00,10,11,9,10,5", # Sunday → non_trading_day
45+ ])
46+ out, anomalies = validate_bars(_parse(rows), Interval.m1)
47+ by_ts = {r["timestamp_utc"]: set(r["quality_flags"]) for r in out.to_pylist()}
48+ f = lambda h, m, d=30: by_ts[datetime(2026, 10, d, h, m, tzinfo=UTC)] # noqa: E731
49+ assert QualityFlag.ohlc_inconsistent in f(13, 30)
50+ assert QualityFlag.negative_volume in f(13, 31)
51+ assert any(QualityFlag.off_interval in v for v in by_ts.values())
52+ assert QualityFlag.duplicate in f(13, 33)
53+ assert QualityFlag.outside_session in f(7, 59)
54+ assert QualityFlag.price_jump in f(13, 34)
55+ assert QualityFlag.nonpositive_price in f(13, 35)
56+ assert QualityFlag.non_trading_day in by_ts[datetime(2026, 11, 1, 14, 30, tzinfo=UTC)]
57+ codes = {a["code"] for a in anomalies}
58+ assert {"ohlc_inconsistent", "negative_volume", "duplicate", "non_trading_day"} <= codes
59+ # prices are never altered by validation
60+ assert out.column("open").to_pylist()[0] == 10.0
61+
62+
63+def test_session_calendar_expected_bars_and_early_close():
64+ cal = xnys()
65+ assert cal.is_session(date(2026, 10, 30)) and not cal.is_session(date(2026, 11, 1))
66+ assert not cal.is_session(date(2026, 11, 26)) # Thanksgiving
67+ assert cal.expected_regular_bars(date(2026, 10, 30)) == 391 # 09:30..16:00 inclusive
68+ assert cal.expected_regular_bars(date(2026, 11, 27)) == 211 # early close 13:00
69+ assert cal.expected_regular_bars(date(2026, 11, 1)) == 0
70+
71+
72+def test_session_coverage_rows():
73+ t = parse_bar_file((FIX / "A_day_1min_UNADJUSTED.txt").read_bytes(), "A", AssetClass.stock, Interval.m1, Adjustment.raw, "firstrate", NOW)[0]
74+ cov = session_coverage(t, Interval.m1)
75+ assert len(cov) == 1 and cov[0]["symbol"] == "A" and cov[0]["bar_count"] == 390 and cov[0]["expected_bars"] == 391
76+
77+
78+def test_aggregate_5m_matches_manual_expectation():
79+ t = parse_bar_file((FIX / "AAPL_day_1min_UNADJUSTED.txt").read_bytes(), "AAPL", AssetClass.stock, Interval.m1, Adjustment.raw, "firstrate", NOW)[0]
80+ agg = aggregate_bars(t, Interval.m5)
81+ import polars as pl
82+ one = pl.from_arrow(t).sort("timestamp_utc")
83+ five = pl.from_arrow(agg).sort("timestamp_utc")
84+ # 09:30 ET bucket = 13:30 UTC covers 13:30..13:34
85+ win = one.filter((pl.col("timestamp_utc") >= datetime(2026, 10, 30, 13, 30, tzinfo=UTC)) & (pl.col("timestamp_utc") < datetime(2026, 10, 30, 13, 35, tzinfo=UTC)))
86+ b = five.filter(pl.col("timestamp_utc") == datetime(2026, 10, 30, 13, 30, tzinfo=UTC)).row(0, named=True)
87+ assert b["open"] == win["open"][0] and b["close"] == win["close"][-1]
88+ assert b["high"] == win["high"].max() and b["low"] == win["low"].min()
89+ assert abs(b["volume"] - win["volume"].sum()) < 1e-6 and b["trade_count"] == 5
90+ assert b["interval"] == "5m" and b["session_date"] == date(2026, 10, 30)
91+ assert abs(five["volume"].sum() - one["volume"].sum()) < 1e-6 # volume conserved
92+ assert five.height <= 16 * 12 # 04:00–20:00 → ≤ 192 buckets, none manufactured
93+ assert all(ts.minute % 5 == 0 for ts in five["timestamp_utc"].to_list())
94+
95+
96+def test_aggregate_buckets_follow_wall_clock_across_dst():
97+ # 09:30 EST (14:30 UTC) and 09:30 EDT (13:30 UTC) must each start a bucket
98+ t = _parse("2026-03-06 09:31:00,1,1,1,1,1\n2026-03-06 09:34:00,2,2,2,2,1\n2026-03-09 09:31:00,3,3,3,3,1\n2026-03-09 09:34:00,4,4,4,4,1\n")
99+ agg = aggregate_bars(t, Interval.m5)
100+ ts = agg.column("timestamp_utc").to_pylist()
101+ assert ts == [datetime(2026, 3, 6, 14, 30, tzinfo=UTC), datetime(2026, 3, 9, 13, 30, tzinfo=UTC)]
102+ assert agg.column("open").to_pylist() == [1.0, 3.0] and agg.column("close").to_pylist() == [2.0, 4.0]
103+
104+
105+def test_aggregate_zero_volume_bucket_has_null_vwap():
106+ t = _parse("2026-10-30 09:30:00,1,1,1,1,0\n")
107+ agg = aggregate_bars(t, Interval.m5)
108+ assert agg.column("vwap")[0].as_py() is None
added tests/test_z_api_sdk.py +273 −0
@@ -0,0 +1,273 @@
1+"""API + SDK end-to-end against the in-process app (TestClient) with data published by the ingest task."""
2+from __future__ import annotations
3+
4+import io
5+import json
6+import time
7+from datetime import UTC, date, datetime
8+
9+import httpx
10+import pyarrow.ipc as ipc
11+import pyarrow.parquet as pq
12+import pyotp
13+import pytest
14+from fastapi.testclient import TestClient
15+
16+from findata_api import auth as A
17+from findata_core.db import connection, fetch_one, transaction
18+from findata_ingest import queue
19+from findata_ingest.tasks import incremental, metadata
20+from tests.conftest import TEST_USERID
21+
22+OWNER = "owner@example.com"
23+PW = "correct horse battery staple"
24+
25+
26+@pytest.fixture(scope="module")
27+def app_client(vendor_module, lake_module):
28+ from findata_api.main import create_app
29+ # make sure one session of bars exists (tests may run in isolation)
30+ n = fetch_one("SELECT count(*) AS n FROM lake_files WHERE dataset_id='market_bars.stock.1m.raw' AND version_removed IS NULL")["n"]
31+ if n == 0:
32+ class L:
33+ def __getattr__(self, _):
34+ return lambda *a, **k: None
35+ def bind(self, **k):
36+ return self
37+ jid = queue.enqueue("metadata", None, {"types": ["stock", "etf"]})
38+ metadata.run(queue.claim_next(["metadata"]), lake_module, L())
39+ queue.enqueue("incremental", "market_bars.stock.1m.raw", {"type": "stock", "period": "day", "force": True})
40+ incremental.run(queue.claim_next(["incremental"]), lake_module, L())
41+ if fetch_one("SELECT count(*) AS n FROM lake_files WHERE dataset_id='market_bars.stock.5m.raw' AND version_removed IS NULL")["n"] == 0:
42+ from findata_ingest.tasks import derive
43+ class L2:
44+ def __getattr__(self, _):
45+ return lambda *a, **k: None
46+ def bind(self, **k):
47+ return self
48+ j = queue.claim_next(["derive"]) or (queue.enqueue("derive", "market_bars.stock.5m.raw", {"source_dataset": "market_bars.stock.1m.raw", "target_interval": "5m", "dates": ["2026-10-30"]}) and queue.claim_next(["derive"]))
49+ derive.run(j, lake_module, L2())
50+ A.create_owner(OWNER, PW)
51+ app = create_app()
52+ with TestClient(app, base_url="http://testserver") as c:
53+ yield c
54+
55+
56+@pytest.fixture(scope="module")
57+def vendor_module():
58+ from tests.conftest import VendorMock
59+ import findata_market_firstrate.client as C
60+ import findata_fundamentals.firstrate as F
61+ vm = VendorMock()
62+ old = (C.DEFAULT_TRANSPORT, F.DEFAULT_TRANSPORT)
63+ C.DEFAULT_TRANSPORT = httpx.MockTransport(vm)
64+ F.DEFAULT_TRANSPORT = httpx.MockTransport(vm)
65+ yield vm
66+ C.DEFAULT_TRANSPORT, F.DEFAULT_TRANSPORT = old
67+
68+
69+@pytest.fixture(scope="module")
70+def lake_module():
71+ from findata_ingest.lake import Lake
72+ return Lake()
73+
74+
75+def _login(c: TestClient) -> str:
76+ r = c.post("/api/v1/auth/login", json={"email": OWNER, "password": PW})
77+ assert r.status_code == 200, r.text
78+ return r.json()["csrf_token"]
79+
80+
81+def test_unauthenticated_access_fails(app_client):
82+ c = app_client
83+ for path in ["/api/v1/catalog/datasets", "/api/v1/market/bars?symbol=AAPL&start=2026-10-30&end=2026-10-30", "/api/v1/admin/jobs", "/api/v1/query/views"]:
84+ r = c.get(path)
85+ assert r.status_code == 401, path
86+ assert r.json()["error"]["code"] == "unauthenticated" and r.headers["x-request-id"]
87+ assert c.post("/api/v1/query", json={"sql": "select 1"}).status_code == 401
88+ assert c.get("/api/v1/health").json() == {"status": c.get("/api/v1/health").json()["status"], "service": "findata-os"}
89+ r = c.get("/api/v1/market/bars", params={"symbol": "AAPL", "start": "2026-10-30", "end": "2026-10-30"}, headers={"Authorization": "Bearer fd_bad.token"})
90+ assert r.status_code == 401 and r.json()["error"]["code"] == "invalid_api_key"
91+
92+
93+def test_login_bad_password_then_good_then_csrf(app_client):
94+ c = app_client
95+ r = c.post("/api/v1/auth/login", json={"email": OWNER, "password": "nope"})
96+ assert r.status_code == 401
97+ csrf = _login(c)
98+ me = c.get("/api/v1/auth/me").json()
99+ assert me["email"] == OWNER and me["role"] == "owner" and "admin" in me["scopes"] and me["totp_enabled"] is False
100+ # state-changing call without CSRF header is refused
101+ r = c.post("/api/v1/admin/jobs", json={"kind": "quality"})
102+ assert r.status_code == 403 and r.json()["error"]["code"] == "csrf"
103+ r = c.post("/api/v1/admin/jobs", json={"kind": "quality"}, headers={"X-CSRF-Token": csrf})
104+ assert r.status_code == 201
105+ with transaction() as conn:
106+ conn.execute("UPDATE ingest_jobs SET status='cancelled' WHERE kind='quality' AND status='queued'")
107+
108+
109+def test_totp_enrolment_and_mfa_gate(app_client):
110+ c = app_client
111+ csrf = _login(c)
112+ r = c.post("/api/v1/auth/totp/setup", headers={"X-CSRF-Token": csrf})
113+ assert r.status_code == 200 and r.json()["otpauth_uri"].startswith("otpauth://totp/")
114+ secret = r.json()["secret"]
115+ r = c.post("/api/v1/auth/totp/verify", json={"code": "000000"}, headers={"X-CSRF-Token": csrf})
116+ assert r.status_code == 400
117+ r = c.post("/api/v1/auth/totp/verify", json={"code": pyotp.TOTP(secret).now()}, headers={"X-CSRF-Token": csrf})
118+ assert r.status_code == 200 and r.json()["totp_enabled"]
119+ # new login now requires MFA
120+ r = c.post("/api/v1/auth/login", json={"email": OWNER, "password": PW})
121+ assert r.json()["mfa_required"] is True
122+ csrf = r.json()["csrf_token"]
123+ assert c.get("/api/v1/catalog/datasets").status_code == 401 # mfa_required
124+ r = c.post("/api/v1/auth/mfa", json={"code": "123456"}, headers={"X-CSRF-Token": csrf})
125+ assert r.status_code == 401
126+ r = c.post("/api/v1/auth/mfa", json={"code": pyotp.TOTP(secret).now()}, headers={"X-CSRF-Token": csrf})
127+ assert r.status_code == 200
128+ assert c.get("/api/v1/catalog/datasets").status_code == 200
129+ # disable TOTP again for the remaining tests (keep the flow simple)
130+ with transaction() as conn:
131+ conn.execute("UPDATE users SET totp_enabled=false WHERE email=%s", (OWNER,))
132+
133+
134+@pytest.fixture(scope="module")
135+def api_key(app_client):
136+ csrf = _login(app_client)
137+ r = app_client.post("/api/v1/admin/keys", json={"name": "sdk-test", "scopes": ["read:market", "read:fundamentals", "query", "export"]},
138+ headers={"X-CSRF-Token": csrf})
139+ assert r.status_code == 201, r.text
140+ tok = r.json()["token"]
141+ app_client.cookies.clear()
142+ return tok
143+
144+
145+def test_api_key_scopes_and_bars(app_client, api_key):
146+ c = app_client
147+ H = {"Authorization": f"Bearer {api_key}"}
148+ assert c.get("/api/v1/admin/jobs", headers=H).status_code == 403 # no admin scope
149+ r = c.get("/api/v1/catalog/datasets", headers=H)
150+ assert r.status_code == 200
151+ ds = {d["id"]: d for d in r.json()["data"]}
152+ assert ds["market_bars.stock.1m.raw"]["status"] == "active" and ds["market_ticks"]["status"] == "unsupported"
153+ assert ds["fundamentals.sec_companyfacts"]["status"] == "pending_configuration"
154+ assert "no redistribution" in ds["market_bars.stock.1m.raw"]["license_text"].lower() or "may not be redistributed" in ds["market_bars.stock.1m.raw"]["license_text"]
155+ r = c.get("/api/v1/market/bars", params={"symbol": "aapl", "interval": "1m", "start": "2026-10-30", "end": "2026-10-30", "limit": 400}, headers=H)
156+ assert r.status_code == 200, r.text
157+ body = r.json()
158+ assert body["meta"]["rows"] == 400 and body["meta"]["rows_matching"] == 900 and body["next_cursor"]
159+ assert body["data"][0]["timestamp_utc"] == "2026-10-30T08:00:00Z" and body["meta"]["timestamp_convention"] == "bar_open"
160+ assert r.headers["X-Dataset-Version"].isdigit() and r.headers["X-Dataset-Id"] == "market_bars.stock.1m.raw"
161+ r2 = c.get("/api/v1/market/bars", params={"symbol": "AAPL", "start": "2026-10-30", "end": "2026-10-30", "limit": 400, "cursor": body["next_cursor"]}, headers=H)
162+ assert r2.json()["data"][0]["timestamp_utc"] > body["data"][-1]["timestamp_utc"]
163+ # 5-minute derived
164+ r = c.get("/api/v1/market/bars", params={"symbol": "AAPL", "interval": "5m", "start": "2026-10-30", "end": "2026-10-30"}, headers=H)
165+ assert r.status_code == 200 and 150 <= r.json()["meta"]["rows_matching"] <= 192
166+ # unbounded range refused
167+ r = c.get("/api/v1/market/bars", params={"symbol": "AAPL", "start": "2020-01-01", "end": "2026-10-30"}, headers=H)
168+ assert r.status_code == 413 and r.json()["error"]["code"] == "range_too_large"
169+ # arrow transport
170+ r = c.get("/api/v1/market/bars", params={"symbol": "AAPL", "start": "2026-10-30", "end": "2026-10-30", "format": "arrow", "limit": 10000}, headers=H)
171+ with ipc.open_stream(io.BytesIO(r.content)) as reader:
172+ t = reader.read_all()
173+ assert t.num_rows == 900
174+ # sessions / coverage / quality
175+ assert c.get("/api/v1/market/sessions", params={"symbol": "AAPL"}, headers=H).json()["data"][0]["bar_count"] == 900
176+ assert c.get("/api/v1/coverage/heatmap", params={"start": "2026-10-01", "end": "2026-10-31"}, headers=H).json()["sessions"][0]["symbols"] == 3
177+ assert "codes" in c.get("/api/v1/quality", headers=H).json()
178+ assert c.get("/api/v1/instruments", params={"query": "aap"}, headers=H).json()["data"][0]["symbol"] == "AAPL"
179+
180+
181+def test_fundamentals_as_of_hides_later_filings_and_restatements(app_client, api_key):
182+ c = app_client
183+ H = {"Authorization": f"Bearer {api_key}"}
184+ with transaction() as conn:
185+ conn.execute("DELETE FROM fundamentals_facts WHERE entity_id='frd:TESTCO'")
186+ # Q1 filed 2025-05-01 (available 2025-05-02 04:00Z), Q2 filed 2025-08-01 ; Q1 'rev' restated later (observed 2026-01-15)
187+ rows = [
188+ ("rev", 100.0, date(2025, 3, 31), date(2025, 5, 1), datetime(2025, 5, 2, 4, tzinfo=UTC), datetime(2025, 5, 2, 12, tzinfo=UTC), 1, False, datetime(2026, 1, 15, tzinfo=UTC)),
189+ ("rev", 105.0, date(2025, 3, 31), date(2025, 5, 1), datetime(2025, 5, 2, 4, tzinfo=UTC), datetime(2026, 1, 15, tzinfo=UTC), 2, True, None),
190+ ("rev", 120.0, date(2025, 6, 30), date(2025, 8, 1), datetime(2025, 8, 2, 4, tzinfo=UTC), datetime(2025, 8, 2, 12, tzinfo=UTC), 1, False, None),
191+ ]
192+ for m, v, pe, fd, av, ob, rev, rs, sup in rows:
193+ conn.execute("INSERT INTO fundamentals_facts(entity_id, metric, value, unit, fiscal_period_end, fiscal_period_type, filing_date, available_at, observed_at, source_id, revision_id, restatement_flag, superseded_at) "
194+ "VALUES ('frd:TESTCO',%s,%s,'USD',%s,'q',%s,%s,%s,'firstrate_fundamentals',%s,%s,%s)", (m, v, pe, fd, av, ob, rev, rs, sup))
195+ # as of 2025-06-01: only Q1, original value 100 (restatement observed 2026-01 must not leak)
196+ r = c.get("/api/v1/fundamentals/TESTCO", params={"as_of": "2025-06-01T00:00:00Z", "metrics": "rev"}, headers=H)
197+ assert r.status_code == 200, r.text
198+ d = r.json()["data"]
199+ assert len(d) == 1 and d[0]["value"] == 100.0 and d[0]["revision_id"] == 1 and r.json()["meta"]["facts_hidden_by_as_of"] == 1
200+ # as of 2025-08-01 (filing day itself): Q2 not yet available (policy: next day)
201+ d = c.get("/api/v1/fundamentals/TESTCO", params={"as_of": "2025-08-01T20:00:00Z", "metrics": "rev"}, headers=H).json()["data"]
202+ assert [x["fiscal_period_end"] for x in d] == ["2025-03-31"]
203+ # as of 2025-08-02 05:00Z: Q2 visible
204+ d = c.get("/api/v1/fundamentals/TESTCO", params={"as_of": "2025-08-02T05:00:00Z", "metrics": "rev"}, headers=H).json()["data"]
205+ assert sorted(x["value"] for x in d) == [100.0, 120.0]
206+ # as of now: restated Q1 value 105 (revision 2) is the current knowledge
207+ d = c.get("/api/v1/fundamentals/TESTCO", params={"metrics": "rev"}, headers=H).json()["data"]
208+ assert sorted(x["value"] for x in d) == [105.0, 120.0] and any(x["restatement_flag"] for x in d)
209+
210+
211+def test_query_sandbox_and_quotas(app_client, api_key):
212+ c = app_client
213+ H = {"Authorization": f"Bearer {api_key}"}
214+ r = c.post("/api/v1/query", json={"sql": "SELECT symbol_as_reported s, count(*) n FROM market_bars_stock_1m_raw GROUP BY 1 ORDER BY 1", "max_rows": 10}, headers=H)
215+ assert r.status_code == 200, r.text
216+ rows = {x["s"]: x["n"] for x in r.json()["rows"]}
217+ assert rows["AAPL"] == 900 and r.json()["meta"]["engine"].startswith("duckdb")
218+ for bad in ["DROP TABLE x", "SELECT 1; SELECT 2", "SELECT * FROM read_csv('/etc/passwd')", "COPY (SELECT 1) TO '/tmp/x'"]:
219+ r = c.post("/api/v1/query", json={"sql": bad}, headers=H)
220+ assert r.status_code == 400 and r.json()["error"]["code"] in ("query_rejected", "query_error"), bad
221+ with connection() as conn:
222+ u = conn.execute("SELECT * FROM usage_daily WHERE principal LIKE 'api_key:%'").fetchall()
223+ assert u and u[0]["requests"] > 0 and u[0]["rows_returned"] > 0
224+
225+
226+def test_export_roundtrip_and_sdk(app_client, api_key):
227+ c = app_client
228+ from findata_sdk import FinData
229+ import threading
230+ import uvicorn
231+ # the sync SDK needs a real HTTP server: run uvicorn on a loopback port in a thread
232+ config = uvicorn.Config(c.app, host="127.0.0.1", port=8699, log_level="warning")
233+ server = uvicorn.Server(config)
234+ th = threading.Thread(target=server.run, daemon=True)
235+ th.start()
236+ for _ in range(100):
237+ if server.started:
238+ break
239+ time.sleep(0.05)
240+ sdk = FinData("http://127.0.0.1:8699", api_key=api_key)
241+ t = sdk.bars("AAPL", "1m", "2026-10-30", "2026-10-30")
242+ assert t.num_rows == 900 and t.column("close")[0].as_py() == 351.6499
243+ assert sdk.datasets() and sdk.health()["status"] in ("ok", "warning", "degraded")
244+ f = sdk.fundamentals("TESTCO", as_of="2025-06-01T00:00:00Z", metrics=["rev"])
245+ assert f["data"][0]["value"] == 100.0
246+ out = sdk.export(["AAPL", "A"], "2026-10-01", "2026-10-30", dest="/tmp/findata-test-export.parquet", poll_s=0.2, timeout_s=60)
247+ pt = pq.read_table(out)
248+ assert pt.num_rows == 900 + 390 and set(pt.column("symbol_as_reported").unique().to_pylist()) == {"AAPL", "A"}
249+ H = {"Authorization": f"Bearer {api_key}"}
250+ lst = c.get("/api/v1/exports", headers=H).json()["data"]
251+ assert lst[0]["status"] == "ready" and lst[0]["snapshot"]["dataset_id"] == "market_bars.stock.1m.raw" and lst[0]["expires_at"]
252+ server.should_exit = True
253+ th.join(timeout=5)
254+
255+
256+def test_vendor_secret_never_in_responses_or_openapi(app_client, api_key):
257+ c = app_client
258+ H = {"Authorization": f"Bearer {api_key}"}
259+ csrf = _login(c)
260+ blobs = [c.get("/api/v1/openapi.json").text, c.get("/api/v1/catalog/datasets", headers=H).text, c.get("/api/v1/catalog/sources", headers=H).text,
261+ c.get("/api/v1/admin/settings").text, c.get("/api/v1/admin/storage").text, c.get("/api/v1/admin/audit").text, c.get("/api/v1/admin/jobs").text,
262+ c.get("/api/v1/health").text, c.get("/api/v1/health/summary").text]
263+ for b in blobs:
264+ assert TEST_USERID not in b
265+ # raw_archives request payloads are sanitized
266+ with connection() as conn:
267+ raws = conn.execute("SELECT request::text r FROM raw_archives").fetchall()
268+ jobs = conn.execute("SELECT coalesce(last_error,'') e, result::text r FROM ingest_jobs").fetchall()
269+ assert all(TEST_USERID not in x["r"] for x in raws) and all(TEST_USERID not in x["e"] + x["r"] for x in jobs)
270+ # security headers present
271+ r = c.get("/api/v1/health")
272+ 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"]
273+ assert c.get("/api/docs").status_code == 200
added uv.lock +2198 −0
@@ -0,0 +1,2198 @@
1+version = 1
2+revision = 3
3+requires-python = ">=3.12"
4+resolution-markers = [
5+ "python_full_version >= '3.14' and sys_platform == 'win32'",
6+ "python_full_version >= '3.14' and sys_platform == 'emscripten'",
7+ "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
8+ "python_full_version < '3.14' and sys_platform == 'win32'",
9+ "python_full_version < '3.14' and sys_platform == 'emscripten'",
10+ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
11+]
12+
13+[manifest]
14+members = [
15+ "findata-api",
16+ "findata-core",
17+ "findata-fundamentals",
18+ "findata-ingest",
19+ "findata-market-firstrate",
20+ "findata-os",
21+ "findata-quality",
22+ "findata-schema",
23+ "findata-sdk",
24+]
25+
26+[[package]]
27+name = "annotated-doc"
28+version = "0.0.5"
29+source = { registry = "https://pypi.org/simple" }
30+sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
31+wheels = [
32+ { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
33+]
34+
35+[[package]]
36+name = "annotated-types"
37+version = "0.8.0"
38+source = { registry = "https://pypi.org/simple" }
39+sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
40+wheels = [
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+]
43+
44+[[package]]
45+name = "anyio"
46+version = "4.16.0"
47+source = { registry = "https://pypi.org/simple" }
48+dependencies = [
49+ { name = "idna" },
50+ { name = "typing-extensions", marker = "python_full_version < '3.15'" },
51+]
52+sdist = { url = "https://files.pythonhosted.org/packages/a1/98/7e457a3b014976887a050561955c0b76e1e7e2d6e27b78ecef2ef80518f2/anyio-4.16.0.tar.gz", hash = "sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb", size = 310971, upload-time = "2026-11-01T11:20:07.282Z" }
53+wheels = [
54+ { url = "https://files.pythonhosted.org/packages/a9/16/7757fb84c819accc7d7e9af5291b261e4abb8627ee040f99e7d35ef2457a/anyio-4.16.0-py3-none-any.whl", hash = "sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026", size = 134521, upload-time = "2026-11-01T11:20:05.59Z" },
55+]
56+
57+[[package]]
58+name = "argon2-cffi"
59+version = "25.1.0"
60+source = { registry = "https://pypi.org/simple" }
61+dependencies = [
62+ { name = "argon2-cffi-bindings" },
63+]
64+sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
65+wheels = [
66+ { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
67+]
68+
69+[[package]]
70+name = "argon2-cffi-bindings"
71+version = "26.1.0"
72+source = { registry = "https://pypi.org/simple" }
73+dependencies = [
74+ { name = "cffi" },
75+]
76+sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" }
77+wheels = [
78+ { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" },
79+ { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" },
80+ { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" },
81+ { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" },
82+ { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" },
83+ { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" },
84+ { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" },
85+ { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" },
86+ { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" },
87+ { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" },
88+ { url = "https://files.pythonhosted.org/packages/bb/5f/f652055e18d2627e2eed94c7f31a792127cfe38df786635395d742321674/argon2_cffi_bindings-26.1.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:af11ac37a7c53dc16cb7950a6190851b0870fe218b6c60c0bb7ac355234e3083", size = 15434, upload-time = "2026-08-20T07:32:53.143Z" },
89+ { url = "https://files.pythonhosted.org/packages/76/38/de696045960f5b846d428c0fb6c130ed3da87aac2af209b05c193815404c/argon2_cffi_bindings-26.1.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:db0fcd827ca61622a01b220aadfbece01939acf53888f2cb98cd93e9b1e2c97e", size = 15449, upload-time = "2026-08-20T07:32:54.075Z" },
90+ { url = "https://files.pythonhosted.org/packages/91/0a/c25af768f6b75a5a71e31207f87c540656b2808c015260444a22763221ad/argon2_cffi_bindings-26.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:28524438cd3e723f25412f63d4fd516ff5bae9ae5aa56acbe2a1404398a0cf31", size = 25683, upload-time = "2026-08-20T07:32:55.05Z" },
91+ { url = "https://files.pythonhosted.org/packages/a8/7e/be212c751ab0bcea7f646615f933bf262e8e50b3f7bef32f861d0a2d066b/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac82fc756a446b6ccd7139ce70efa9d8bbe541e7ad579a12dcb52764b7175c5f", size = 27311, upload-time = "2026-08-20T07:32:56.166Z" },
92+ { url = "https://files.pythonhosted.org/packages/a6/ee/f84b28e4afd13d3cac36c1d8fa8c239d2dc2c51cd978d02ee5d5ad98d9bb/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a4e68eed961a8de6928d1c17ff3dc2a547e0e923c17f8f1cd79fb7bc9502f98", size = 26771, upload-time = "2026-08-20T07:32:57.206Z" },
93+ { url = "https://files.pythonhosted.org/packages/21/c3/95c07a023691ecd529da9cb6a8f0779e13ebc1bdfaa86d145fdc1c6e7e79/argon2_cffi_bindings-26.1.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:151dfaad9de753f4af2a7854e707e4784f2acc434340ade64239c5b104b2d605", size = 27568, upload-time = "2026-08-20T07:32:58.361Z" },
94+ { url = "https://files.pythonhosted.org/packages/e6/31/3a18e31406d8694b4d6a31573c3e572fff6bed318bb744453eb653766d22/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:061a6919145bbf282ebf1f9c59d3135d4833c25313c8595c0d68cf7712ddfce2", size = 27280, upload-time = "2026-08-20T07:32:59.343Z" },
95+ { url = "https://files.pythonhosted.org/packages/0b/39/d4be4577e178b2397aa5b5575c8a309bf0da2afe05fe0c72c8f398662d63/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:62ff20cd130c956c7c9144d5fe35228f98b51c579b2439e988b27ef93e16c02a", size = 27776, upload-time = "2026-08-20T07:33:00.325Z" },
96+ { url = "https://files.pythonhosted.org/packages/71/47/78f4dd96f7411339f723b96fe24039c1bd5835102b8a5ba71ac4ec712ac7/argon2_cffi_bindings-26.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19423e5d7ac1cc354baab59eaabf18db2ec04ef6593b5abe5a34f323c4a8f87a", size = 26932, upload-time = "2026-08-20T07:33:01.272Z" },
97+ { url = "https://files.pythonhosted.org/packages/3b/cd/96bfd37434cc0a848a9066c291d84b28846c4c9ea289ed9866b1164d622b/argon2_cffi_bindings-26.1.0-cp314-cp314t-win32.whl", hash = "sha256:4f84cdd868978d7b7350a566c254042d44216d9e37f241f3a6d3b1dfebeede35", size = 24878, upload-time = "2026-08-20T07:33:02.189Z" },
98+ { url = "https://files.pythonhosted.org/packages/f1/42/d8b6810abd9b1bd2f47ebbccf460da59c9f32e94888bea4f7b137d998797/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2b741888c93147444fdfc851abd81cc207f37f7f7da42062a00deb3888e57da8", size = 26656, upload-time = "2026-08-20T07:33:03.222Z" },
99+ { url = "https://files.pythonhosted.org/packages/a9/d1/095d95eaf2ed1d9f77268cf3291bde148c6cd56121f8db2c74c1ba618a0e/argon2_cffi_bindings-26.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6ab674f668d5962a3a4136ae0812519b0f1586874263723a32181d60d64137e1", size = 25378, upload-time = "2026-08-20T07:33:04.332Z" },
100+ { url = "https://files.pythonhosted.org/packages/66/cb/214092c39c4dbcb72cf98b12234ddac2221f8fe2c0acf29c6a70fa83be53/argon2_cffi_bindings-26.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1d98e33bd8bd67d7206c124e200bf2229c4cfa8c9c19f7b44a897f0fc71837eb", size = 25683, upload-time = "2026-08-20T07:33:05.337Z" },
101+ { url = "https://files.pythonhosted.org/packages/83/e5/02015b83e9b05ccb85ff2ced424cf6e83a12d3810bc7f66d679a92b69ffb/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ccaf0a46cbb380f1fd102a874e32aa629fd3cb0c0e94f4943fa1f6d5edc5dac6", size = 27310, upload-time = "2026-08-20T07:33:06.344Z" },
102+ { url = "https://files.pythonhosted.org/packages/c3/4a/85e612787d0796878b3b4f6bd53dcd5484b6fe7b64cc6fc7b6e6a04cf835/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0c3103fcff20183e593459cfea6e012281c0e76ae3ed8b5565ad1b92eac3990", size = 26771, upload-time = "2026-08-20T07:33:07.429Z" },
103+ { url = "https://files.pythonhosted.org/packages/f6/84/ccb003b6f9969820e87656398f4d49c857def71a85ca1588a0e809afd7ce/argon2_cffi_bindings-26.1.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c49e853a3bef9dd10329f31f702e7fa9b5c58229ff9c2ff6d069efaf09177c08", size = 27569, upload-time = "2026-08-20T07:33:08.598Z" },
104+ { url = "https://files.pythonhosted.org/packages/88/07/c26b76debf0998ee08fbe947ab2058ac5de37d4b9d46b06c17abaa6c4ce9/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:6376d4b3aca039375ca8bf92f770da0ec424a1ce3a37077a8d3c557411aa56ca", size = 27279, upload-time = "2026-08-20T07:33:09.518Z" },
105+ { url = "https://files.pythonhosted.org/packages/ee/0d/ead6ddc029f91bc9b9390686dad3c808ab08100d348f6266b5f93f8970ee/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:9bacedc04b0402837586a17f0919e3dfdd95291f441f1f56bd80ec274c2840a1", size = 27774, upload-time = "2026-08-20T07:33:10.728Z" },
106+ { url = "https://files.pythonhosted.org/packages/7d/47/c108530d9eb86036b78d3af4de28b83b4a2d9a70512bd10ff8e59966aab4/argon2_cffi_bindings-26.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76ae29acace5d33355344612844d588e19deaaba4639d8bb01601e4b1418ef36", size = 26933, upload-time = "2026-08-20T07:33:11.661Z" },
107+ { url = "https://files.pythonhosted.org/packages/a9/02/0bfc59e781c89acf64c31c388aade9d9d1c1ea38aa1ba1292fe07f607fe9/argon2_cffi_bindings-26.1.0-cp315-cp315t-win32.whl", hash = "sha256:df612391feca41c44d20118f3b88d1b86419465cd1f5496859f715ca60ec2210", size = 24875, upload-time = "2026-08-20T07:33:12.616Z" },
108+ { url = "https://files.pythonhosted.org/packages/61/c7/c3e46068cddffccecb8ad94d71135e9bf62bbc789589e7dfadc7c6f59214/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1a0a29ed86960e44eaace7e081bdfab4f08b012fd96ec8edba71e2ad020939e4", size = 26655, upload-time = "2026-08-20T07:33:13.521Z" },
109+ { url = "https://files.pythonhosted.org/packages/f4/ca/18b9c8c45fecf34b9100ec6d7946057f14a158f2eaa20ea123a3e82351cb/argon2_cffi_bindings-26.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d157ddfab1e8b21f2f1dedda9c09645d98b5ed0b667b0626be600a345d426440", size = 25376, upload-time = "2026-08-20T07:33:14.491Z" },
110+]
111+
112+[[package]]
113+name = "boolean-py"
114+version = "5.0"
115+source = { registry = "https://pypi.org/simple" }
116+sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" }
117+wheels = [
118+ { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" },
119+]
120+
121+[[package]]
122+name = "cachecontrol"
123+version = "0.14.4"
124+source = { registry = "https://pypi.org/simple" }
125+dependencies = [
126+ { name = "msgpack" },
127+ { name = "requests" },
128+]
129+sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" }
130+wheels = [
131+ { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" },
132+]
133+
134+[package.optional-dependencies]
135+filecache = [
136+ { name = "filelock" },
137+]
138+
139+[[package]]
140+name = "certifi"
141+version = "2026.10.23"
142+source = { registry = "https://pypi.org/simple" }
143+sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" }
144+wheels = [
145+ { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
146+]
147+
148+[[package]]
149+name = "cffi"
150+version = "2.1.1"
151+source = { registry = "https://pypi.org/simple" }
152+dependencies = [
153+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
154+]
155+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
156+wheels = [
157+ { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
158+ { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
159+ { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
160+ { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
161+ { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
162+ { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
163+ { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
164+ { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
165+ { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
166+ { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
167+ { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
168+ { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
169+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
170+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
171+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
172+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
173+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
174+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
175+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
176+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
177+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
178+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
179+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
180+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
181+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
182+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
183+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
184+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
185+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
186+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
187+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
188+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
189+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
190+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
191+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
192+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
193+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
194+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
195+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
196+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
197+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
198+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
199+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
200+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
201+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
202+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
203+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
204+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
205+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
206+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
207+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
208+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
209+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
210+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
211+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
212+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
213+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
214+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
215+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
216+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
217+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
218+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
219+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
220+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
221+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
222+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
223+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
224+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
225+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
226+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
227+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
228+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
229+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
230+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
231+]
232+
233+[[package]]
234+name = "charset-normalizer"
235+version = "3.5.2"
236+source = { registry = "https://pypi.org/simple" }
237+sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
238+wheels = [
239+ { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" },
240+ { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" },
241+ { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" },
242+ { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" },
243+ { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" },
244+ { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" },
245+ { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" },
246+ { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" },
247+ { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" },
248+ { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" },
249+ { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" },
250+ { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" },
251+ { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" },
252+ { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" },
253+ { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" },
254+ { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" },
255+ { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" },
256+ { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" },
257+ { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" },
258+ { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" },
259+ { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" },
260+ { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" },
261+ { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" },
262+ { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" },
263+ { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" },
264+ { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" },
265+ { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" },
266+ { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" },
267+ { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" },
268+ { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" },
269+ { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" },
270+ { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" },
271+ { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" },
272+ { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" },
273+ { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" },
274+ { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" },
275+ { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" },
276+ { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" },
277+ { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" },
278+ { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" },
279+ { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" },
280+ { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" },
281+ { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" },
282+ { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" },
283+ { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" },
284+ { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" },
285+ { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" },
286+ { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" },
287+ { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" },
288+ { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" },
289+ { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" },
290+ { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" },
291+ { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" },
292+ { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" },
293+ { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" },
294+ { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" },
295+ { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" },
296+ { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" },
297+ { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" },
298+ { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" },
299+ { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" },
300+ { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" },
301+ { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" },
302+ { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" },
303+ { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" },
304+ { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" },
305+ { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" },
306+ { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" },
307+ { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" },
308+ { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" },
309+ { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" },
310+ { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" },
311+ { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" },
312+ { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" },
313+ { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" },
314+ { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" },
315+ { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" },
316+ { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" },
317+ { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" },
318+ { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" },
319+ { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" },
320+ { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" },
321+ { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" },
322+ { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" },
323+ { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" },
324+ { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" },
325+ { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" },
326+ { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" },
327+ { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" },
328+ { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" },
329+ { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" },
330+ { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" },
331+ { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" },
332+ { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" },
333+ { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" },
334+ { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" },
335+ { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" },
336+ { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" },
337+ { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" },
338+ { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" },
339+ { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" },
340+ { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" },
341+ { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" },
342+ { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" },
343+ { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" },
344+ { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" },
345+ { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
346+ { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
347+ { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
348+ { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
349+ { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
350+ { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
351+ { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
352+ { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
353+ { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
354+ { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
355+ { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
356+ { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
357+ { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
358+ { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
359+ { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
360+ { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
361+ { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
362+]
363+
364+[[package]]
365+name = "click"
366+version = "8.5.0"
367+source = { registry = "https://pypi.org/simple" }
368+sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
369+wheels = [
370+ { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
371+]
372+
373+[[package]]
374+name = "colorama"
375+version = "0.4.6"
376+source = { registry = "https://pypi.org/simple" }
377+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
378+wheels = [
379+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
380+]
381+
382+[[package]]
383+name = "cyclonedx-python-lib"
384+version = "11.12.0"
385+source = { registry = "https://pypi.org/simple" }
386+dependencies = [
387+ { name = "license-expression" },
388+ { name = "packageurl-python" },
389+ { name = "py-serializable" },
390+ { name = "sortedcontainers" },
391+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
392+]
393+sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" }
394+wheels = [
395+ { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" },
396+]
397+
398+[[package]]
399+name = "defusedxml"
400+version = "0.7.1"
401+source = { registry = "https://pypi.org/simple" }
402+sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
403+wheels = [
404+ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
405+]
406+
407+[[package]]
408+name = "dnspython"
409+version = "2.9.1"
410+source = { registry = "https://pypi.org/simple" }
411+sdist = { url = "https://files.pythonhosted.org/packages/91/21/7fcd6780f61e9756aafaea760560509bb4111375daa2e23c1be7a89f33f1/dnspython-2.9.1.tar.gz", hash = "sha256:9a6d30e55f8dc63e6052597666112bff0586b95a2474a9deda03afbbf820d37a", size = 455302, upload-time = "2026-10-29T18:05:12.156Z" }
412+wheels = [
413+ { 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+]
415+
416+[[package]]
417+name = "duckdb"
418+version = "2.0.0"
419+source = { registry = "https://pypi.org/simple" }
420+sdist = { url = "https://files.pythonhosted.org/packages/c2/7c/dfdf5e9cebefd032f57f09b224993e861ecb55f61e36c709544b04b44f70/duckdb-2.0.0.tar.gz", hash = "sha256:09de511ef0dc76a5213e8d3d87a8b89bb4d3e8a6fe73a9f60dc001269946b44f", size = 16771077, upload-time = "2026-10-21T12:51:13.908Z" }
421+wheels = [
422+ { url = "https://files.pythonhosted.org/packages/41/28/1fb52f9ea10205a55a07c32e37cd4fd68fa9d1dc2520a12405ec9bb59cac/duckdb-2.0.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:cb1cc30318397c3bcffa6b683196293698052588de3c1ba1e144ba0c72560ce9", size = 37003294, upload-time = "2026-10-21T12:50:56.489Z" },
423+ { url = "https://files.pythonhosted.org/packages/07/5b/fbe92644709fb9162cc51753f92f892b1f333658079d1f11efaed43341d2/duckdb-2.0.0-cp312-abi3-macosx_10_15_x86_64.whl", hash = "sha256:46c7a299ff5f45be9068cfc0ceecb3fd574af9535de8cb6908ae8b52d2846cbd", size = 19689207, upload-time = "2026-10-21T12:50:59.331Z" },
424+ { url = "https://files.pythonhosted.org/packages/8f/a3/4d23938223626980cdcf18fd6b0dbf040823b5cf15beabbf4ab7ee276631/duckdb-2.0.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:b5d758b53751c930e09d371230241005ea5203c8edc65a1e1fafca83fcc906ef", size = 17597612, upload-time = "2026-10-21T12:51:01.818Z" },
425+ { url = "https://files.pythonhosted.org/packages/92/4a/985fb9d93d7f9f8e82092ddfe05ec8c9a1d0df7bdb0f562a10ced30e3e8f/duckdb-2.0.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e38daf0acbad9de7ce892d402b68409519291ee16af3c2cba85ace3b085c7eaa", size = 21655920, upload-time = "2026-10-21T12:51:04.36Z" },
426+ { url = "https://files.pythonhosted.org/packages/cc/33/01ad2535ee897db2bad70cbc800655167c53afd0b955eed84f0759c2f417/duckdb-2.0.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab51ec57d329d6f3df37c0287b55d71c3fc07bb54b61d1afed09104f953b1431", size = 24371005, upload-time = "2026-10-21T12:51:06.86Z" },
427+ { url = "https://files.pythonhosted.org/packages/09/d3/9e8ca1242b98f04041bef09686ecfe098b41f820aa23240f63f360b64eaf/duckdb-2.0.0-cp312-abi3-win_amd64.whl", hash = "sha256:f61c96547ed432f54dae42a717fd88563eda55cb47549715fac9f61f770c205a", size = 15368860, upload-time = "2026-10-21T12:51:09.395Z" },
428+ { url = "https://files.pythonhosted.org/packages/37/81/6ea5dc1050f86ec4dd038a2774766b42247ba6104e7785f13a0c600e2b33/duckdb-2.0.0-cp312-abi3-win_arm64.whl", hash = "sha256:c5e6708efaa8647e0914a16f1914354419e529a1925929950e7d610418e45873", size = 15726489, upload-time = "2026-10-21T12:51:11.643Z" },
429+]
430+
431+[[package]]
432+name = "email-validator"
433+version = "2.3.0"
434+source = { registry = "https://pypi.org/simple" }
435+dependencies = [
436+ { name = "dnspython" },
437+ { name = "idna" },
438+]
439+sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
440+wheels = [
441+ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
442+]
443+
444+[[package]]
445+name = "exchange-calendars"
446+version = "4.13.2"
447+source = { registry = "https://pypi.org/simple" }
448+dependencies = [
449+ { name = "korean-lunar-calendar" },
450+ { name = "numpy" },
451+ { name = "pandas" },
452+ { name = "pyluach" },
453+ { name = "toolz" },
454+ { name = "tzdata" },
455+]
456+sdist = { url = "https://files.pythonhosted.org/packages/47/73/460b0ece4e7444e3098b7738974c822787e134578d069d3a88b4be17d50a/exchange_calendars-4.13.2.tar.gz", hash = "sha256:a9459425dd64142cd54fbc639847403c7e0c33d60fbc326c94fc1d6bd127f002", size = 4137321, upload-time = "2026-03-10T03:24:38.536Z" }
457+wheels = [
458+ { url = "https://files.pythonhosted.org/packages/c8/4c/0469b40057bc9f8d9594dcc6024202626b981ae4b52dfcd304552e8e1c3a/exchange_calendars-4.13.2-py3-none-any.whl", hash = "sha256:fc5a2ad0d61b5c3a6539a3061cd4cbb55c59f4a903455cec7926e4b798919996", size = 213306, upload-time = "2026-03-10T03:24:37.055Z" },
459+]
460+
461+[[package]]
462+name = "fastapi"
463+version = "0.162.0"
464+source = { registry = "https://pypi.org/simple" }
465+dependencies = [
466+ { name = "annotated-doc" },
467+ { name = "opentelemetry-api" },
468+ { name = "pydantic" },
469+ { name = "starlette" },
470+ { name = "typing-extensions" },
471+ { name = "typing-inspection" },
472+]
473+sdist = { url = "https://files.pythonhosted.org/packages/e9/eb/2cf8c80abf09b2800aef0a83b41066fbfda8ee5495b7a08b9d1af40f7046/fastapi-0.162.0.tar.gz", hash = "sha256:6a22c1e2e6e32e0633ee7e170f00009a29b8228b16feb219ee42f32fd8d88a8f", size = 527867, upload-time = "2026-10-23T18:47:33.264Z" }
474+wheels = [
475+ { url = "https://files.pythonhosted.org/packages/b4/4f/3f2c25c9b43970b5e9a08beb5e04776247edea684ddc0930f4f02f0bb6cb/fastapi-0.162.0-py3-none-any.whl", hash = "sha256:3785963de305dd09912426cb81f2d230fce98ab3521cdfe97265c8d14983128c", size = 148912, upload-time = "2026-10-23T18:47:31.734Z" },
476+]
477+
478+[[package]]
479+name = "filelock"
480+version = "4.2.6"
481+source = { registry = "https://pypi.org/simple" }
482+sdist = { url = "https://files.pythonhosted.org/packages/35/6c/730b340182f1fd74d4ef8d3f35431ce84f8f97b1a1f2afd145174b7bb047/filelock-4.2.6.tar.gz", hash = "sha256:ee7af8ede36dcaf12e1016d9f7c3a63ecd5497d46f85dab21b8c779b8f944e97", size = 661339, upload-time = "2026-10-31T15:10:16.889Z" }
483+wheels = [
484+ { url = "https://files.pythonhosted.org/packages/b7/8d/3f849fcb79359259737bc2d8ddcaee6149ffb773239a260c9ff20179736f/filelock-4.2.6-py3-none-any.whl", hash = "sha256:fd7d4fca497bf3e3d0945a13261f8e684f2aef12ce7cf76aef26e13bf7388b88", size = 164378, upload-time = "2026-10-31T15:10:15.322Z" },
485+]
486+
487+[[package]]
488+name = "findata-api"
489+version = "0.1.0"
490+source = { editable = "services/api" }
491+dependencies = [
492+ { name = "argon2-cffi" },
493+ { name = "duckdb" },
494+ { name = "email-validator" },
495+ { name = "fastapi" },
496+ { name = "findata-core" },
497+ { name = "findata-fundamentals" },
498+ { name = "findata-ingest" },
499+ { name = "findata-market-firstrate" },
500+ { name = "findata-schema" },
501+ { name = "itsdangerous" },
502+ { name = "polars" },
503+ { name = "prometheus-client" },
504+ { name = "pyarrow" },
505+ { name = "pydantic" },
506+ { name = "pyotp" },
507+ { name = "python-multipart" },
508+ { name = "qrcode" },
509+ { name = "uvicorn", extra = ["standard"] },
510+]
511+
512+[package.metadata]
513+requires-dist = [
514+ { name = "argon2-cffi", specifier = ">=23.1" },
515+ { name = "duckdb", specifier = ">=1.1" },
516+ { name = "email-validator", specifier = ">=2.2" },
517+ { name = "fastapi", specifier = ">=0.115" },
518+ { name = "findata-core", editable = "packages/core" },
519+ { name = "findata-fundamentals", editable = "connectors/fundamentals" },
520+ { name = "findata-ingest", editable = "services/ingest" },
521+ { name = "findata-market-firstrate", editable = "connectors/market" },
522+ { name = "findata-schema", editable = "packages/schema" },
523+ { name = "itsdangerous", specifier = ">=2.2" },
524+ { name = "polars", specifier = ">=1.12" },
525+ { name = "prometheus-client", specifier = ">=0.21" },
526+ { name = "pyarrow", specifier = ">=18.0" },
527+ { name = "pydantic", specifier = ">=2.9" },
528+ { name = "pyotp", specifier = ">=2.9" },
529+ { name = "python-multipart", specifier = ">=0.0.12" },
530+ { name = "qrcode", specifier = ">=8.0" },
531+ { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
532+]
533+
534+[[package]]
535+name = "findata-core"
536+version = "0.1.0"
537+source = { editable = "packages/core" }
538+dependencies = [
539+ { name = "psycopg", extra = ["binary", "pool"] },
540+ { name = "pydantic" },
541+ { name = "pydantic-settings" },
542+ { name = "structlog" },
543+]
544+
545+[package.metadata]
546+requires-dist = [
547+ { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" },
548+ { name = "pydantic", specifier = ">=2.9" },
549+ { name = "pydantic-settings", specifier = ">=2.6" },
550+ { name = "structlog", specifier = ">=24.4" },
551+]
552+
553+[[package]]
554+name = "findata-fundamentals"
555+version = "0.1.0"
556+source = { editable = "connectors/fundamentals" }
557+dependencies = [
558+ { name = "findata-core" },
559+ { name = "findata-market-firstrate" },
560+ { name = "findata-schema" },
561+ { name = "httpx" },
562+]
563+
564+[package.metadata]
565+requires-dist = [
566+ { name = "findata-core", editable = "packages/core" },
567+ { name = "findata-market-firstrate", editable = "connectors/market" },
568+ { name = "findata-schema", editable = "packages/schema" },
569+ { name = "httpx", specifier = ">=0.28" },
570+]
571+
572+[[package]]
573+name = "findata-ingest"
574+version = "0.1.0"
575+source = { editable = "services/ingest" }
576+dependencies = [
577+ { name = "duckdb" },
578+ { name = "findata-core" },
579+ { name = "findata-fundamentals" },
580+ { name = "findata-market-firstrate" },
581+ { name = "findata-quality" },
582+ { name = "findata-schema" },
583+ { name = "polars" },
584+ { name = "pyarrow" },
585+ { name = "typer" },
586+]
587+
588+[package.metadata]
589+requires-dist = [
590+ { name = "duckdb", specifier = ">=1.1" },
591+ { name = "findata-core", editable = "packages/core" },
592+ { name = "findata-fundamentals", editable = "connectors/fundamentals" },
593+ { name = "findata-market-firstrate", editable = "connectors/market" },
594+ { name = "findata-quality", editable = "services/quality" },
595+ { name = "findata-schema", editable = "packages/schema" },
596+ { name = "polars", specifier = ">=1.12" },
597+ { name = "pyarrow", specifier = ">=18.0" },
598+ { name = "typer", specifier = ">=0.12" },
599+]
600+
601+[[package]]
602+name = "findata-market-firstrate"
603+version = "0.1.0"
604+source = { editable = "connectors/market" }
605+dependencies = [
606+ { name = "findata-core" },
607+ { name = "findata-quality" },
608+ { name = "findata-schema" },
609+ { name = "httpx" },
610+ { name = "polars" },
611+ { name = "pyarrow" },
612+]
613+
614+[package.metadata]
615+requires-dist = [
616+ { name = "findata-core", editable = "packages/core" },
617+ { name = "findata-quality", editable = "services/quality" },
618+ { name = "findata-schema", editable = "packages/schema" },
619+ { name = "httpx", specifier = ">=0.28" },
620+ { name = "polars", specifier = ">=1.12" },
621+ { name = "pyarrow", specifier = ">=18.0" },
622+]
623+
624+[[package]]
625+name = "findata-os"
626+version = "0.1.0"
627+source = { virtual = "." }
628+dependencies = [
629+ { name = "findata-api" },
630+ { name = "findata-core" },
631+ { name = "findata-fundamentals" },
632+ { name = "findata-ingest" },
633+ { name = "findata-market-firstrate" },
634+ { name = "findata-quality" },
635+ { name = "findata-schema" },
636+ { name = "findata-sdk" },
637+]
638+
639+[package.dev-dependencies]
640+dev = [
641+ { name = "freezegun" },
642+ { name = "httpx" },
643+ { name = "pip-audit" },
644+ { name = "pytest" },
645+ { name = "pytest-asyncio" },
646+ { name = "ruff" },
647+]
648+
649+[package.metadata]
650+requires-dist = [
651+ { name = "findata-api", editable = "services/api" },
652+ { name = "findata-core", editable = "packages/core" },
653+ { name = "findata-fundamentals", editable = "connectors/fundamentals" },
654+ { name = "findata-ingest", editable = "services/ingest" },
655+ { name = "findata-market-firstrate", editable = "connectors/market" },
656+ { name = "findata-quality", editable = "services/quality" },
657+ { name = "findata-schema", editable = "packages/schema" },
658+ { name = "findata-sdk", editable = "packages/python-sdk" },
659+]
660+
661+[package.metadata.requires-dev]
662+dev = [
663+ { name = "freezegun", specifier = ">=1.5" },
664+ { name = "httpx", specifier = ">=0.28" },
665+ { name = "pip-audit", specifier = ">=2.7" },
666+ { name = "pytest", specifier = ">=8.3" },
667+ { name = "pytest-asyncio", specifier = ">=0.24" },
668+ { name = "ruff", specifier = ">=0.8" },
669+]
670+
671+[[package]]
672+name = "findata-quality"
673+version = "0.1.0"
674+source = { editable = "services/quality" }
675+dependencies = [
676+ { name = "exchange-calendars" },
677+ { name = "findata-schema" },
678+ { name = "polars" },
679+ { name = "pyarrow" },
680+]
681+
682+[package.metadata]
683+requires-dist = [
684+ { name = "exchange-calendars", specifier = ">=4.5" },
685+ { name = "findata-schema", editable = "packages/schema" },
686+ { name = "polars", specifier = ">=1.12" },
687+ { name = "pyarrow", specifier = ">=18.0" },
688+]
689+
690+[[package]]
691+name = "findata-schema"
692+version = "0.1.0"
693+source = { editable = "packages/schema" }
694+dependencies = [
695+ { name = "pyarrow" },
696+ { name = "pydantic" },
697+]
698+
699+[package.metadata]
700+requires-dist = [
701+ { name = "pyarrow", specifier = ">=18.0" },
702+ { name = "pydantic", specifier = ">=2.9" },
703+]
704+
705+[[package]]
706+name = "findata-sdk"
707+version = "0.1.0"
708+source = { editable = "packages/python-sdk" }
709+dependencies = [
710+ { name = "httpx" },
711+ { name = "pyarrow" },
712+]
713+
714+[package.optional-dependencies]
715+pandas = [
716+ { name = "pandas" },
717+]
718+polars = [
719+ { name = "polars" },
720+]
721+
722+[package.metadata]
723+requires-dist = [
724+ { name = "httpx", specifier = ">=0.28" },
725+ { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2.2" },
726+ { name = "polars", marker = "extra == 'polars'", specifier = ">=1.12" },
727+ { name = "pyarrow", specifier = ">=18.0" },
728+]
729+provides-extras = ["pandas", "polars"]
730+
731+[[package]]
732+name = "freezegun"
733+version = "1.5.5"
734+source = { registry = "https://pypi.org/simple" }
735+dependencies = [
736+ { name = "python-dateutil" },
737+]
738+sdist = { url = "https://files.pythonhosted.org/packages/95/dd/23e2f4e357f8fd3bdff613c1fe4466d21bfb00a6177f238079b17f7b1c84/freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a", size = 35914, upload-time = "2025-08-09T10:39:08.338Z" }
739+wheels = [
740+ { url = "https://files.pythonhosted.org/packages/5e/2e/b41d8a1a917d6581fc27a35d05561037b048e47df50f27f8ac9c7e27a710/freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2", size = 19266, upload-time = "2025-08-09T10:39:06.636Z" },
741+]
742+
743+[[package]]
744+name = "h11"
745+version = "0.16.0"
746+source = { registry = "https://pypi.org/simple" }
747+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
748+wheels = [
749+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
750+]
751+
752+[[package]]
753+name = "httpcore"
754+version = "1.0.9"
755+source = { registry = "https://pypi.org/simple" }
756+dependencies = [
757+ { name = "certifi" },
758+ { name = "h11" },
759+]
760+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
761+wheels = [
762+ { 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+]
764+
765+[[package]]
766+name = "httptools"
767+version = "0.9.0"
768+source = { registry = "https://pypi.org/simple" }
769+sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
770+wheels = [
771+ { url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" },
772+ { url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" },
773+ { url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" },
774+ { url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" },
775+ { url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" },
776+ { url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" },
777+ { url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" },
778+ { url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" },
779+ { url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" },
780+ { url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" },
781+ { url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" },
782+ { url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" },
783+ { url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" },
784+ { url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" },
785+ { url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" },
786+ { url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" },
787+ { url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" },
788+ { url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" },
789+ { url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" },
790+ { url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" },
791+ { url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" },
792+ { url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" },
793+ { url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" },
794+ { url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" },
795+ { url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" },
796+ { url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" },
797+ { url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" },
798+ { url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" },
799+ { url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" },
800+ { url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" },
801+ { url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" },
802+ { url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" },
803+ { url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" },
804+ { url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" },
805+ { url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" },
806+ { url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" },
807+ { url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" },
808+ { url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" },
809+ { url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" },
810+ { url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" },
811+ { url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" },
812+ { url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" },
813+ { url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" },
814+ { url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" },
815+ { url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" },
816+ { url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" },
817+ { url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" },
818+ { url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" },
819+ { url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" },
820+ { url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" },
821+ { url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" },
822+ { url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" },
823+ { url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" },
824+ { url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" },
825+ { url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" },
826+ { url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" },
827+ { url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" },
828+ { url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" },
829+ { url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" },
830+ { url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" },
831+ { url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" },
832+ { url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" },
833+ { url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" },
834+ { url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" },
835+ { url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" },
836+ { url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" },
837+ { url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" },
838+ { url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" },
839+ { url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" },
840+ { url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" },
841+ { url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" },
842+ { url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" },
843+ { url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" },
844+ { url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" },
845+ { url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" },
846+ { url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" },
847+ { url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" },
848+ { url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" },
849+]
850+
851+[[package]]
852+name = "httpx"
853+version = "0.28.1"
854+source = { registry = "https://pypi.org/simple" }
855+dependencies = [
856+ { name = "anyio" },
857+ { name = "certifi" },
858+ { name = "httpcore" },
859+ { name = "idna" },
860+]
861+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
862+wheels = [
863+ { 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+]
865+
866+[[package]]
867+name = "idna"
868+version = "3.20"
869+source = { registry = "https://pypi.org/simple" }
870+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
871+wheels = [
872+ { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
873+]
874+
875+[[package]]
876+name = "iniconfig"
877+version = "2.3.1"
878+source = { registry = "https://pypi.org/simple" }
879+sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
880+wheels = [
881+ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
882+]
883+
884+[[package]]
885+name = "itsdangerous"
886+version = "2.2.0"
887+source = { registry = "https://pypi.org/simple" }
888+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
889+wheels = [
890+ { 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+]
892+
893+[[package]]
894+name = "korean-lunar-calendar"
895+version = "0.4.0"
896+source = { registry = "https://pypi.org/simple" }
897+sdist = { url = "https://files.pythonhosted.org/packages/d5/9f/6160e95f3934026194dc7a0b5c78fe5c6e74761830ddea3e9cfc38e3f983/korean_lunar_calendar-0.4.0.tar.gz", hash = "sha256:be56f27bc0594fdbbdf7bbe00f504a9f929a31e311bd7d9bb93561b645afade7", size = 13425, upload-time = "2026-06-15T14:19:57.163Z" }
898+wheels = [
899+ { url = "https://files.pythonhosted.org/packages/c1/88/f56033b09fdca5c8c5e5fc2f3513950a05cd62caa9bd8e6d2750a887c86e/korean_lunar_calendar-0.4.0-py3-none-any.whl", hash = "sha256:c042e20de0bb702add6bec8d0f6da1ea8d3b170838e63846f70420cf341fe4e7", size = 11323, upload-time = "2026-06-15T14:19:56.089Z" },
900+]
901+
902+[[package]]
903+name = "license-expression"
904+version = "30.4.4"
905+source = { registry = "https://pypi.org/simple" }
906+dependencies = [
907+ { name = "boolean-py" },
908+]
909+sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" }
910+wheels = [
911+ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" },
912+]
913+
914+[[package]]
915+name = "markdown-it-py"
916+version = "4.2.0"
917+source = { registry = "https://pypi.org/simple" }
918+dependencies = [
919+ { name = "mdurl" },
920+]
921+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
922+wheels = [
923+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
924+]
925+
926+[[package]]
927+name = "mdurl"
928+version = "0.1.2"
929+source = { registry = "https://pypi.org/simple" }
930+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
931+wheels = [
932+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
933+]
934+
935+[[package]]
936+name = "msgpack"
937+version = "1.2.4"
938+source = { registry = "https://pypi.org/simple" }
939+sdist = { url = "https://files.pythonhosted.org/packages/cc/52/1ec7ead2c7528bf3d1db73e886fa4df10f9e6f0eb7854a448b7090cfcef7/msgpack-1.2.4.tar.gz", hash = "sha256:475c44e12f2a5de766a8448783f149c9b33f3f49b88953c6cd739a77d93c732c", size = 197764, upload-time = "2026-10-26T17:55:48.593Z" }
940+wheels = [
941+ { url = "https://files.pythonhosted.org/packages/27/fd/45f6cd13cefbb659a8e0128ea64d51b349571163fec3c9c20f99792dc42a/msgpack-1.2.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:90cba4996b18ec1201fcaabd01ec48ff4b9755416bcd52105ded2f89fe145c49", size = 91903, upload-time = "2026-10-26T17:54:03.097Z" },
942+ { url = "https://files.pythonhosted.org/packages/7f/89/169c2a0b1ffeafe68a4f13d8d96b6ed1cd8d40d8262b3a6690851da7eda1/msgpack-1.2.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:112933726919f4239646cca9572e2c2fa5626358c670da8440aac09403100a48", size = 90573, upload-time = "2026-10-26T17:54:04.383Z" },
943+ { url = "https://files.pythonhosted.org/packages/cf/8e/272a384f2a001cfced1baab0a8abdd912d6281432670f4609d1aab718d7b/msgpack-1.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ca1e78106675f2aed07dc37b842ce19be70010ab22c5c5c04688f0840d48539", size = 461057, upload-time = "2026-10-26T17:54:05.821Z" },
944+ { url = "https://files.pythonhosted.org/packages/1e/d9/f59bd841fc2fd2b74dc50ce85860a6a25f58e588dd48946aeaf2862cf990/msgpack-1.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4d5bbdbea8f5669b3a734d6c226772804fb120848f99cbe63a0ddee63b049c9", size = 475652, upload-time = "2026-10-26T17:54:07.414Z" },
945+ { url = "https://files.pythonhosted.org/packages/00/03/afd000e1b1e28fd535dc8c7bc21ebb88923bc1964a2429fe660fb313ace3/msgpack-1.2.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d9ff7f3aaffd84010f1c501b05406a497584f5cab2f732eb9d89bf960e12f6f", size = 427082, upload-time = "2026-10-26T17:54:09.1Z" },
946+ { url = "https://files.pythonhosted.org/packages/f7/7a/e2890b272883c496faad777324ddb70e9cc4a6c63731962a4a39a6a40f44/msgpack-1.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa10dc04c03408f31948ac1896a942141fd8d7a954b3612edac79f19663e4076", size = 451956, upload-time = "2026-10-26T17:54:10.476Z" },
947+ { url = "https://files.pythonhosted.org/packages/c2/46/0c1b240b8a580af3e72139c33b018025440a1a42c2579e82e39b1195ee36/msgpack-1.2.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:472a25463678f2795143a7cb42e88a1b59cc2375e9881917e69a9323bde42114", size = 423833, upload-time = "2026-10-26T17:54:12.195Z" },
948+ { url = "https://files.pythonhosted.org/packages/80/8e/851415b1a9264e6abf64d8a9617d856769d59e247fb9470106f133aedbae/msgpack-1.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cbb8a677da3e845713925e9a203b8a120b1e9da9989836890ba4a4a187902701", size = 470951, upload-time = "2026-10-26T17:54:13.764Z" },
949+ { url = "https://files.pythonhosted.org/packages/c9/74/d75bf61aab9cb88b84f7d3a8eca8dd92e19a742b77ded300a1177e88d031/msgpack-1.2.4-cp312-cp312-win32.whl", hash = "sha256:2b0b7dff5beddc10f16ecea6b28c6b98014d1135d7015bd83adff5037f35848d", size = 68786, upload-time = "2026-10-26T17:54:15.049Z" },
950+ { url = "https://files.pythonhosted.org/packages/81/c5/dd0e78eb04f9a0773a810d25ef2cd8f16bb941487567d0c63f814881133c/msgpack-1.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:6e38b123681df22373d545520f54bbeddc4706d9235a1161061f12bd6648497d", size = 77272, upload-time = "2026-10-26T17:54:16.491Z" },
951+ { url = "https://files.pythonhosted.org/packages/65/e7/b38f63ae82777c0077daa56c4b09af39d29dcb8927ab2ff59268094069c5/msgpack-1.2.4-cp312-cp312-win_arm64.whl", hash = "sha256:6795c588b210b2f13d62c8fcc23bf779b509b839ec5c197a7ab4f5191459e126", size = 71884, upload-time = "2026-10-26T17:54:17.995Z" },
952+ { url = "https://files.pythonhosted.org/packages/2d/26/16eaad5fe6e199334fa5bd66070778665717c86e9a273525dc706ddb06a7/msgpack-1.2.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb811ac3d463a75013b234d2aae56b9f0bdb0105caf4dd2ee65305b6a8a6078d", size = 92057, upload-time = "2026-10-26T17:54:19.616Z" },
953+ { url = "https://files.pythonhosted.org/packages/9c/b8/9660be6d120820bc05106f291e824c73d35f4ab77d927788ae67ea80889c/msgpack-1.2.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:36258fd0909e1bf26e94604439108846432abe4ffa9a23c9dc2f6f1c60f94b60", size = 90542, upload-time = "2026-10-26T17:54:20.965Z" },
954+ { url = "https://files.pythonhosted.org/packages/6c/10/cd55a69fec29df26884b178dc4b1803442ec82abf929604aac619881917a/msgpack-1.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9c5ba0f053a7e98f013f083c9d1b2dffb26cad8c897c533d8286e44ed24c669", size = 455798, upload-time = "2026-10-26T17:54:22.434Z" },
955+ { url = "https://files.pythonhosted.org/packages/c9/94/562e0733593a780c70b0b18994f30c205149f7f9f4efbe3e9b00b88612c5/msgpack-1.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fabd415c41e62006c6641fcdf5b3e866f581c4e11c58328c0d0f69584b9577d", size = 469262, upload-time = "2026-10-26T17:54:24.043Z" },
956+ { url = "https://files.pythonhosted.org/packages/bb/92/76d1a5c597e04e1d360b05573d150bdb4502f5a014e6e9cf089c9537c156/msgpack-1.2.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7433d340de45003db3ed909a642c9469b598d175530f7a707cbaba64287d27ce", size = 420789, upload-time = "2026-10-26T17:54:25.587Z" },
957+ { url = "https://files.pythonhosted.org/packages/8a/c6/b72b8d8022718380db459ced8ffbabc7570ad2d22c7f2fa659c872cb6734/msgpack-1.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccd6e4af5347eb9828147320449bcd30e3321f6397d4583ff16047485dd8be3d", size = 447068, upload-time = "2026-10-26T17:54:27.721Z" },
958+ { url = "https://files.pythonhosted.org/packages/cb/dc/f378ef1def4aa135e70325c09e22457f3203ae2a390a192e892267906f33/msgpack-1.2.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23d1459a1d0b4c9f6629df072d592eb74424b3ed3656b69af507bc9023cc027f", size = 418896, upload-time = "2026-10-26T17:54:29.325Z" },
959+ { url = "https://files.pythonhosted.org/packages/62/93/a7e507378f43e91415cb6fda47e0ded0c9de1618a1f4ac4f2887912a92dd/msgpack-1.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e73761fe9fc55a4e9d2df8e8f59b0979231af4dbf80b8bf90cd64c74173d779", size = 465764, upload-time = "2026-10-26T17:54:30.719Z" },
960+ { url = "https://files.pythonhosted.org/packages/60/76/7f39f49cbcdaa4aafe64b4941b2640772dbdc1cac3a9332f4c9ba6994da9/msgpack-1.2.4-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:d088d1f552e129859db830692268747aba0a1ee3e6e0fe6344dbb3aab1df9f6c", size = 53615, upload-time = "2026-10-26T17:54:32.213Z" },
961+ { url = "https://files.pythonhosted.org/packages/67/9b/85b93350dbd7b52b358882f7f17a88a03664a7a59a359406e6315c162634/msgpack-1.2.4-cp313-cp313-win32.whl", hash = "sha256:892d4f17d7962bfd2c2928b0a47542bf2948f074a7c04721b73d4138b70481e4", size = 68724, upload-time = "2026-10-26T17:54:33.484Z" },
962+ { url = "https://files.pythonhosted.org/packages/02/4a/a7e2778ce09a943c17b6f91c7acb2f9488357657c01f2cccb278297621c4/msgpack-1.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:318d4e634d2b320be10decd9968ad1ae0be1d80cba23b5528c37b429eeb0e3e0", size = 77039, upload-time = "2026-10-26T17:54:35.016Z" },
963+ { url = "https://files.pythonhosted.org/packages/64/16/9ff7495a9853e9d7de74965fdadce15017e7213ce564bb5dafc5403c9108/msgpack-1.2.4-cp313-cp313-win_arm64.whl", hash = "sha256:bec3e34749b90cfcf116149aad11295c720ff4a74a7d0a35abdb968a396c1524", size = 71873, upload-time = "2026-10-26T17:54:36.19Z" },
964+ { url = "https://files.pythonhosted.org/packages/86/34/747f9ea2b428088201c73fcb3a885cebcbedcd71def0f1f3c36896294b27/msgpack-1.2.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:895e9369185df4a874768849405c30cba7990caceb3fcb1692ebe62bb050f55e", size = 92449, upload-time = "2026-10-26T17:54:37.503Z" },
965+ { url = "https://files.pythonhosted.org/packages/83/7e/52d208cb1561f869762c17be0e2c004e4d34f2842d239a450492d6246d62/msgpack-1.2.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c97f5f17407e1761f23f1944be33cbf1d5ce67017db32544dcd5e16e0b1b4a3b", size = 91202, upload-time = "2026-10-26T17:54:38.797Z" },
966+ { url = "https://files.pythonhosted.org/packages/2e/58/5516cabb60da83506024015c669622a0277979aef51eb611308cebaf4cc9/msgpack-1.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2444a4e04f924630d059ae6eae8f91fdfcd28ed9fd17b21bacf4e74550a091f7", size = 455185, upload-time = "2026-10-26T17:54:40.213Z" },
967+ { url = "https://files.pythonhosted.org/packages/39/8f/bf975b5d2111e4954a0f8567fc6b60bab22c8766dd4fa509af1334c91a4f/msgpack-1.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d65b791ce4283e02b6267742ad540899155e1b32887b9cbdda47f38f5a076b5", size = 465078, upload-time = "2026-10-26T17:54:41.971Z" },
968+ { url = "https://files.pythonhosted.org/packages/32/2f/c74baeafc53d1c1fadfbeb774444d0e6354778d12b40b827e437a7a35856/msgpack-1.2.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5e4c67dbac05dce90a3fa1d845137617383c415e6b319062baac9734540706ae", size = 420144, upload-time = "2026-10-26T17:54:44.994Z" },
969+ { url = "https://files.pythonhosted.org/packages/b0/f2/bdf2ce9e284821397dc5549101824c7607ee6ba6e0635390d168a77ac49c/msgpack-1.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:781e856db4f2cb398e4992e2a74305295d3ebf694eec26fa0f4bc766eb9fa0a0", size = 446479, upload-time = "2026-10-26T17:54:46.484Z" },
970+ { url = "https://files.pythonhosted.org/packages/76/8d/b4572d24bb75d98bcbbd031a934a3a1d03494ea88a100cd51ab16c4c7d57/msgpack-1.2.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cfc1cc237792f9a5664002b66acb961027d4184528ec9d10e0e42330f7972383", size = 418780, upload-time = "2026-10-26T17:54:48.04Z" },
971+ { url = "https://files.pythonhosted.org/packages/55/8e/4493bef4422730cfd3420b84094652fabd27ef8aadc8b443a8995b524d08/msgpack-1.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f60de6ae13d4d2a12a483f15c5e47cb6fa2731aa32d92fb31178e2017d6574a8", size = 462009, upload-time = "2026-10-26T17:54:49.433Z" },
972+ { url = "https://files.pythonhosted.org/packages/41/29/efdc3d1d09e4e0e45955b0aad559b4c1f5d908767c9fb1021990c52182a5/msgpack-1.2.4-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:06a0657a4110d563255006b7cb7147432f67827144e429c2cdb1c967548d1817", size = 53714, upload-time = "2026-10-26T17:54:50.753Z" },
973+ { url = "https://files.pythonhosted.org/packages/df/0b/08794c65e0a752acf20aa44e193e61dfb552b48fe6c27ba6023c146f9e77/msgpack-1.2.4-cp314-cp314-win32.whl", hash = "sha256:727a65b35db9f5ef516fc0229c4b05cab983092b1ae274a8a769b2d517fb0499", size = 70726, upload-time = "2026-10-26T17:54:52.397Z" },
974+ { url = "https://files.pythonhosted.org/packages/b5/da/e2cc63ec61e7ac05595b391cba01c10cb4657f4e517da1512e4a75d2d12e/msgpack-1.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:80f5d4fda1ba373d95933f98e6c1d18247feffac839fda3930bdd85e6561bb5f", size = 78346, upload-time = "2026-10-26T17:54:54.662Z" },
975+ { url = "https://files.pythonhosted.org/packages/93/e4/1b0eb49887009dc2c530171d659cf2aa37202b5f79c0b784e63777554b37/msgpack-1.2.4-cp314-cp314-win_arm64.whl", hash = "sha256:e5f67f0286f2ba2c6dbb4998404ed880112ca204ceda729cf239879cd89d3acf", size = 74152, upload-time = "2026-10-26T17:54:55.915Z" },
976+ { url = "https://files.pythonhosted.org/packages/d0/4a/6defc8fd0270c4ba675e6625f4f76ce936fd0c48733395fd7ba912cd423e/msgpack-1.2.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:45506bfb902cde24a0d33fb0581f58497ddb2682686dd1f6aa07e7b769df7ca4", size = 94083, upload-time = "2026-10-26T17:54:57.108Z" },
977+ { url = "https://files.pythonhosted.org/packages/c8/e8/59f7e076739048864710a4de3403ae1c1b43ed5077b796cd9e8cbce9b3f6/msgpack-1.2.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc9dc681958e69f46b9f37def3e7fd6cc5dcfaa5943fb00bfe9c223a1a1052f6", size = 95037, upload-time = "2026-10-26T17:54:58.489Z" },
978+ { url = "https://files.pythonhosted.org/packages/2c/dd/dbae250b23b831cd0571eb664089f80de3249a39e98c6ef55f9a0efe928d/msgpack-1.2.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bef691e2a95119cc6bf3ded29325dbd89c4b6f8ff233070431948ed70902421", size = 472722, upload-time = "2026-10-26T17:54:59.924Z" },
979+ { url = "https://files.pythonhosted.org/packages/a9/e2/383ece1c1b27c1ed322055fce8ab8972bc6d529ede881592065ce0c461bf/msgpack-1.2.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e6106d2d085af42b2de72efeb38ed255241a24b32295ca889707c1d679c442", size = 468726, upload-time = "2026-10-26T17:55:01.492Z" },
980+ { url = "https://files.pythonhosted.org/packages/37/2f/5410a8815ae74a824ac87543c612e40cb6674851b70a6ad858bb32813203/msgpack-1.2.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:996dfc4774c240c8e6adf1166ae102141cfbed33c8856fde821bd6c0bd35ee41", size = 420488, upload-time = "2026-10-26T17:55:02.848Z" },
981+ { url = "https://files.pythonhosted.org/packages/39/8e/16fdf02b4f91dff9c1480433c1bf80756c2fedc0b74fc6a231f02dfed007/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:efa1357fb0c7201e833de28638be371ee127476f7ccff9e7a94211b0afc33ddf", size = 455541, upload-time = "2026-10-26T17:55:04.564Z" },
982+ { url = "https://files.pythonhosted.org/packages/c4/76/2454ef54d780a167667552d02e7bc977a3d419943bcbdf6a96a4da6786ad/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b9270b63409e70c8c6deb5faedc81fc27c6bd0196b38084a8773f68286abf034", size = 419514, upload-time = "2026-10-26T17:55:06.257Z" },
983+ { url = "https://files.pythonhosted.org/packages/ac/8b/2af9bf378def33c24875f250421b0d0b1b06f72a1c2c3f964749fc4cdee1/msgpack-1.2.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b597bb01aff61d65e305c2cf2589c93c86609929d42d575bfc359f3c54ea8e1", size = 461910, upload-time = "2026-10-26T17:55:08.05Z" },
984+ { url = "https://files.pythonhosted.org/packages/0e/46/a133ac6c4cef4d3939383c00cc19baa16ee85fd70af0c5990e7a9ca8767f/msgpack-1.2.4-cp314-cp314t-win32.whl", hash = "sha256:ad5bc540cbc34db236f8535721a9a872d12a9d88d1322470f1fc7c5e9dadfdb2", size = 76441, upload-time = "2026-10-26T17:55:09.711Z" },
985+ { url = "https://files.pythonhosted.org/packages/d4/f5/8d22ec454c188f1795107a3fd9f68ada485076ebf394a4aaaa5fbb6093e7/msgpack-1.2.4-cp314-cp314t-win_amd64.whl", hash = "sha256:287e52c2ebaa94f8eca045628374859f51584e77bc07086e6835a9bb2204c774", size = 84321, upload-time = "2026-10-26T17:55:10.947Z" },
986+ { url = "https://files.pythonhosted.org/packages/41/52/1390514aee47d14ca3fe6ea0cf236fddc55021867ace30daf8761deba36f/msgpack-1.2.4-cp314-cp314t-win_arm64.whl", hash = "sha256:f3f19fe5decd348b35b003ae5f32d3fa90877413a0183da676f748a3a7ab9270", size = 78589, upload-time = "2026-10-26T17:55:12.425Z" },
987+ { url = "https://files.pythonhosted.org/packages/d0/d8/0465d475c1a7063af1a1fb4f8c69e0dd661c4dd172055c9757a343a07d5d/msgpack-1.2.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:765584b93ed6f0b561f0c56e9735f24945c0682f857c9b3ee3b4caad9c32f66a", size = 92449, upload-time = "2026-10-26T17:55:13.665Z" },
988+ { url = "https://files.pythonhosted.org/packages/b2/d0/5220a8a53579dda39f3526394ba5b7eafc28736085f2626deda7fcd22cd1/msgpack-1.2.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0221748c30ace75fde0dec8c470abe65fb5374cd4f37adb9db6aca7a3c362c0a", size = 91209, upload-time = "2026-10-26T17:55:14.913Z" },
989+ { url = "https://files.pythonhosted.org/packages/b2/98/f151742eba413503074a10d8f636bf03dd4f3f7cba29689767a35d19bd1c/msgpack-1.2.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5917972ce1bf5f85f761031e00dab6fe0ad9f880cb3d2d43ed74b330c850400", size = 455713, upload-time = "2026-10-26T17:55:16.406Z" },
990+ { url = "https://files.pythonhosted.org/packages/b0/54/890510a2c9841aff63258e085d15d4e140c7eef559d4da81ef2d2d867436/msgpack-1.2.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ae8cf6012372cecb43016132d1b87ccc2ebb8ae5ab5078687399e82423386a", size = 466177, upload-time = "2026-10-26T17:55:18.351Z" },
991+ { url = "https://files.pythonhosted.org/packages/3d/41/a6f20d7c2c380fb072b95ffc5b865a1d62eaf49d77fbee44466c45a1a8de/msgpack-1.2.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e78c66cf97ac5675037f705ca433ad98ac9177f9523e7fdbb34e486ae29044c8", size = 424398, upload-time = "2026-10-26T17:55:19.979Z" },
992+ { url = "https://files.pythonhosted.org/packages/89/86/fa9559961f2b0d498e6cefa226298ddee2b896cb223449c211702bc34071/msgpack-1.2.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:59887281c3c163a7afff7dffaaad2f78cd2b513b7e1a7843f1ee8e7cd6a94231", size = 445686, upload-time = "2026-10-26T17:55:21.775Z" },
993+ { url = "https://files.pythonhosted.org/packages/39/23/5a9d94036cdd77d4310bbb7cfaf3ccd1f087c4a5d5a4befec4361c5fc0dc/msgpack-1.2.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9f46e70c8d22e96856967ab9d80c51dcf5bc63133e4da2d66fab64c0d4119b82", size = 422096, upload-time = "2026-10-26T17:55:24Z" },
994+ { url = "https://files.pythonhosted.org/packages/6e/4b/86a5384112509a94ff6e20028aba6845138743effb7c6617e959ad1256ab/msgpack-1.2.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:0a1fd6c31952155b7cd01061288c70303e6554ae855244dfb38a897fd94f8749", size = 463407, upload-time = "2026-10-26T17:55:26.146Z" },
995+ { url = "https://files.pythonhosted.org/packages/f4/cf/14db289e0ad531e22ce0f03284865f81d14fbc80bf3f79d3b1714bcf76cf/msgpack-1.2.4-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:b1f08ec826417e3c8dc3edadb50f38debba033af6cb849229f0eac72f80f2342", size = 53743, upload-time = "2026-10-26T17:55:27.497Z" },
996+ { url = "https://files.pythonhosted.org/packages/46/72/a76fc90b51503c0bdacbc7fc823aa6fa62d40596ac47f9ea8492d6f95a34/msgpack-1.2.4-cp315-cp315-win32.whl", hash = "sha256:e8ba23c838739fe929e868095171e71f2f38d6ce0fcf5840b576fb82648e58e6", size = 70780, upload-time = "2026-10-26T17:55:28.705Z" },
997+ { url = "https://files.pythonhosted.org/packages/58/a7/fa9d4a1b5dfead83ea6e03b4007041a5da988c25eb941d42612e0211d3c0/msgpack-1.2.4-cp315-cp315-win_amd64.whl", hash = "sha256:67310606cf2528a6dc3ece1298dcd5cc6c57add148b16d22c5111138aa84820c", size = 78288, upload-time = "2026-10-26T17:55:29.928Z" },
998+ { url = "https://files.pythonhosted.org/packages/b5/f3/8163d7938a8fe003952fe2719ddb31da71998559bbe290c55ffe622b886b/msgpack-1.2.4-cp315-cp315-win_arm64.whl", hash = "sha256:ce76e7a7c11459157887a8dadf07c107ff74eca98cf914f0de6ee7ab4b47104d", size = 74235, upload-time = "2026-10-26T17:55:31.112Z" },
999+ { url = "https://files.pythonhosted.org/packages/b4/fa/37c3219b250d81bee788c0dd78ae2fc673db2da1c21ae51f7982d85698f7/msgpack-1.2.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fb9ddbf23b8c6e89752af399fcf4daed60055e239cda44c6acfaa76d19331e8c", size = 93793, upload-time = "2026-10-26T17:55:32.304Z" },
1000+ { url = "https://files.pythonhosted.org/packages/83/9f/b8afcd20d32cf4be1fa3c327e997656d6d446e667452b0fcc66b91710e82/msgpack-1.2.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e283cf563166c21762131b88e3870ee2ec57757b08a8736929757b5a67541061", size = 94746, upload-time = "2026-10-26T17:55:33.624Z" },
1001+ { url = "https://files.pythonhosted.org/packages/e6/4f/03348d381fcbbc2685356833408cc7e9d2af0fe0775926935a2889650f64/msgpack-1.2.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e91bf8325f492e8eb3c3aa6de06b20593c225336bb52b585c913d7c1da025b", size = 468480, upload-time = "2026-10-26T17:55:34.991Z" },
1002+ { url = "https://files.pythonhosted.org/packages/16/46/e54af8d9a51fb562e068e69a0d75a51e9c2fedec49f911f888515f5477ab/msgpack-1.2.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfa86432bf329d6b976f5a00ad4c9064fd51736e4967a52526750236345cc2da", size = 470064, upload-time = "2026-10-26T17:55:36.615Z" },
1003+ { url = "https://files.pythonhosted.org/packages/74/f5/8f9a6b3af1d803e832b11017c8dad44d761fefdb91dba04ee58cb51838da/msgpack-1.2.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:42e0a7dc26df739149478585d4264338b4d330f24b3e3b57d5aa89e48866e8ab", size = 421267, upload-time = "2026-10-26T17:55:38.076Z" },
1004+ { url = "https://files.pythonhosted.org/packages/4b/8f/7302b0d18eadbbfef9be1dbbdc49905f7f3b847200c3e47068d093976be4/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8e680367090a58c966052d61418cc943111863538fd82471d61fca5271d911e1", size = 451244, upload-time = "2026-10-26T17:55:39.639Z" },
1005+ { url = "https://files.pythonhosted.org/packages/36/c6/05717fb56b510b5e974446f2cb8e9b26fa4347fafc0c5927745b176939b3/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:a6c9926f8871b261622fe6b599db72c69be5bca8fdc0976886f9a531a301dee9", size = 420273, upload-time = "2026-10-26T17:55:41.488Z" },
1006+ { url = "https://files.pythonhosted.org/packages/05/d9/a19077d664c37319097e16ba8de14e6b3a2edcc8ee78a2069c0808e38960/msgpack-1.2.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c50bac325dbf1a9c80e10da6dbd1100d7082f6b12e153084501e18e86cd3a037", size = 460421, upload-time = "2026-10-26T17:55:43.223Z" },
1007+ { url = "https://files.pythonhosted.org/packages/a2/bd/0e2033a7435f8d2311be37363fedc88bdfa9fd95c364ee8ac3216eb63e36/msgpack-1.2.4-cp315-cp315t-win32.whl", hash = "sha256:34051de19ae532ba47fcab5b0abd08c25c7a64ff763ad819f18764d45f3c2c81", size = 76256, upload-time = "2026-10-26T17:55:44.588Z" },
1008+ { url = "https://files.pythonhosted.org/packages/b3/3f/aa7265f25dafb6af7567a1a7d0a95c5566803db2226c5e084dc5850cfd96/msgpack-1.2.4-cp315-cp315t-win_amd64.whl", hash = "sha256:6ad2f0808099d4281208ff2c756efa958a55a7c8eb21fe1c7052b95c584f02f1", size = 83939, upload-time = "2026-10-26T17:55:45.773Z" },
1009+ { url = "https://files.pythonhosted.org/packages/bf/f2/c53904c4bc3da828fb75678bf489ece808a277affa73f8a7a91dfb7c5851/msgpack-1.2.4-cp315-cp315t-win_arm64.whl", hash = "sha256:e38d854f1c60e94f46d998765201126338bde3fe1b029be0d849094bcbef72c2", size = 77995, upload-time = "2026-10-26T17:55:47.016Z" },
1010+]
1011+
1012+[[package]]
1013+name = "numpy"
1014+version = "2.5.4"
1015+source = { registry = "https://pypi.org/simple" }
1016+sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" }
1017+wheels = [
1018+ { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" },
1019+ { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" },
1020+ { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" },
1021+ { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" },
1022+ { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" },
1023+ { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" },
1024+ { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" },
1025+ { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" },
1026+ { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" },
1027+ { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" },
1028+ { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" },
1029+ { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" },
1030+ { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" },
1031+ { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" },
1032+ { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" },
1033+ { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" },
1034+ { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" },
1035+ { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" },
1036+ { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" },
1037+ { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" },
1038+ { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" },
1039+ { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" },
1040+ { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" },
1041+ { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" },
1042+ { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" },
1043+ { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" },
1044+ { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" },
1045+ { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" },
1046+ { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" },
1047+ { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" },
1048+ { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" },
1049+ { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" },
1050+ { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" },
1051+ { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" },
1052+ { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" },
1053+ { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" },
1054+ { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" },
1055+ { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" },
1056+ { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" },
1057+ { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" },
1058+ { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" },
1059+ { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" },
1060+ { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" },
1061+ { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" },
1062+ { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" },
1063+ { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" },
1064+ { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" },
1065+ { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" },
1066+ { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" },
1067+ { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" },
1068+ { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" },
1069+ { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" },
1070+ { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" },
1071+ { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" },
1072+ { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" },
1073+ { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" },
1074+ { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" },
1075+ { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" },
1076+ { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" },
1077+ { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" },
1078+ { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" },
1079+ { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" },
1080+ { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" },
1081+ { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" },
1082+ { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" },
1083+]
1084+
1085+[[package]]
1086+name = "opentelemetry-api"
1087+version = "1.45.2"
1088+source = { registry = "https://pypi.org/simple" }
1089+dependencies = [
1090+ { name = "typing-extensions" },
1091+]
1092+sdist = { url = "https://files.pythonhosted.org/packages/19/14/cf3a6b6448849656e1e5f4e2101f3988dc6789724050096aba1bc5ccf592/opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4", size = 73277, upload-time = "2026-10-29T17:29:37.888Z" }
1093+wheels = [
1094+ { url = "https://files.pythonhosted.org/packages/a9/2d/610db864d7513158cebb8f36cc88e67e73bd6c7aa1905ef18d89e1281c81/opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34", size = 60335, upload-time = "2026-10-29T17:29:06.955Z" },
1095+]
1096+
1097+[[package]]
1098+name = "packageurl-python"
1099+version = "0.17.6"
1100+source = { registry = "https://pypi.org/simple" }
1101+sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" }
1102+wheels = [
1103+ { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" },
1104+]
1105+
1106+[[package]]
1107+name = "packaging"
1108+version = "26.3"
1109+source = { registry = "https://pypi.org/simple" }
1110+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
1111+wheels = [
1112+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
1113+]
1114+
1115+[[package]]
1116+name = "pandas"
1117+version = "3.1.0"
1118+source = { registry = "https://pypi.org/simple" }
1119+dependencies = [
1120+ { name = "numpy" },
1121+ { name = "python-dateutil" },
1122+ { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
1123+]
1124+sdist = { url = "https://files.pythonhosted.org/packages/55/b1/a10c0d1a7e3ce44eb42635771004aa503e3b9ddde0bd39c524dda65f7eff/pandas-3.1.0.tar.gz", hash = "sha256:ecb9b2d8a817b1e1ba1fdb90c0abe9eab31b4fdf2562b7a56b86ac66ace17e49", size = 5933421, upload-time = "2026-10-22T11:57:03.087Z" }
1125+wheels = [
1126+ { url = "https://files.pythonhosted.org/packages/e9/12/b94c28870f351e9650c724e6daedf661455b348a9dca6d717c42c30fa216/pandas-3.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4772320b7735d96dbbef437129f840dec4175f8a18a2391bd1222ed8943825c", size = 11587014, upload-time = "2026-10-22T11:55:07.208Z" },
1127+ { url = "https://files.pythonhosted.org/packages/1b/4b/af8ca15261db52ab07f5d0c2d4c1bc28d822ea4c2a79756bc2606c012e7c/pandas-3.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cc5e4e12d287ea77bbf4c4d4bf47ed152d82a990d3a6794dee7c350f887532b5", size = 11167712, upload-time = "2026-10-22T11:55:09.523Z" },
1128+ { url = "https://files.pythonhosted.org/packages/49/c4/cd5127751e2bef3d10f07e0d856e120916a3df0df6450f759ddb48c78b1c/pandas-3.1.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b4e696ff70ed06e986feeec09356c491ce878d0df69a36c8e591b6a38afa29b", size = 11476964, upload-time = "2026-10-22T11:55:11.889Z" },
1129+ { url = "https://files.pythonhosted.org/packages/c5/a6/dc89cdd95e12093bc813e9b814a09f1a056be3e1304edaef9bf9dbee108a/pandas-3.1.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9433b5dc2f26ef9d9b56daaf4d41d078bf4372c5d88c42ed5b397a6eea80de16", size = 12019688, upload-time = "2026-10-22T11:55:14.246Z" },
1130+ { url = "https://files.pythonhosted.org/packages/36/87/da14aa671da792612f15847451e731d5dc0f06f6747b76b15f197ffcce13/pandas-3.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3cba20fd269808e224d6212273008173978033a1dee0f3f217bfdbd58775a72c", size = 12490149, upload-time = "2026-10-22T11:55:16.675Z" },
1131+ { url = "https://files.pythonhosted.org/packages/5e/a0/1ce996c7354ad37983955571058ba0dde585663163ab067275197183868f/pandas-3.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:743554ed96755ad1963397ccadbe9965d92db8777ca2ae8c1fe58bd9549d7f28", size = 13090624, upload-time = "2026-10-22T11:55:18.854Z" },
1132+ { url = "https://files.pythonhosted.org/packages/92/72/d235f82b51a22478938a3583b68e8dc05811937543adf410f34bab76004a/pandas-3.1.0-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:ac2f833557874a79fc0b5fac4c86e402ea6498beba8753dc29ca23d08481689a", size = 8091628, upload-time = "2026-10-22T11:55:21.285Z" },
1133+ { url = "https://files.pythonhosted.org/packages/10/72/06aefff8fe5efc5e1ca8bfb2695b648a435358f6dc224d4e40159314181a/pandas-3.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:b8c75dbb27c7d170b25c13dd35515da4bb92d40fe8bb3bd2c69a95de2218889b", size = 10748836, upload-time = "2026-10-22T11:55:23.47Z" },
1134+ { url = "https://files.pythonhosted.org/packages/f6/b6/4c86f659f5b14790d14e17253becc93c4e3d1511b73f0ba2184f06b164f8/pandas-3.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:73b6cea22090b7123c78ee662a5108ff36616b0bec4fe47026f86fb04a49bce7", size = 10128898, upload-time = "2026-10-22T11:55:25.995Z" },
1135+ { url = "https://files.pythonhosted.org/packages/01/54/eb85f8b2ce121c21286842cf81abaa670ec3570173c512298f91128927a5/pandas-3.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:849d5019dead65944875ce21fbf71a2ca18e982164cd7367623cbda8567c3122", size = 11609893, upload-time = "2026-10-22T11:55:28.592Z" },
1136+ { url = "https://files.pythonhosted.org/packages/9b/f2/b327e814327b7e7e61b5c5cceafd5c0c20f0d38e94f99ebf2dd6c1cec85b/pandas-3.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c1e084760f3b275eed5863407d025b933d25f3247515fcc84bf664405e8bb3d2", size = 11182834, upload-time = "2026-10-22T11:55:30.912Z" },
1137+ { url = "https://files.pythonhosted.org/packages/4e/6d/2b918a45ad31d85f898567c948f11d0789ffe7fbadd6f487565e99d179b6/pandas-3.1.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1009f5b6819b81319cf177b4f01a2823ed54c81db8bd11cdf2375f42d064c02", size = 11421478, upload-time = "2026-10-22T11:55:33.257Z" },
1138+ { url = "https://files.pythonhosted.org/packages/4b/6f/b63e1d43eef5746014ae3f4786c72e0020e61b6b373a2abb6ebf1be3df49/pandas-3.1.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de6d0fc96e8551b83f400bf212611a0b3a2b389594d20a2540dacc61b21e7bce", size = 11991052, upload-time = "2026-10-22T11:55:35.556Z" },
1139+ { url = "https://files.pythonhosted.org/packages/fd/43/2cf85145d822957a3f8959fd0a850e6bf687dac2823c612b3912b8888dae/pandas-3.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5611344f9f0ed51619ac7acdbf72b86f5043801a66e3f8730c8971257082e8ad", size = 12455560, upload-time = "2026-10-22T11:55:37.656Z" },
1140+ { url = "https://files.pythonhosted.org/packages/7f/72/0cb19cea2e6afbb0854cbde0ed1345b61600e0de84eb7b73583fde3e3b55/pandas-3.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:39c67ed9e56ad3ac0727dc2ae6a1eef766988c762d5ce2e2a162613d81c8990b", size = 13045764, upload-time = "2026-10-22T11:55:39.969Z" },
1141+ { url = "https://files.pythonhosted.org/packages/e3/5c/ea5472e8444f5871427f3a6818725684fcdce74223ca1b22aa7ca345d490/pandas-3.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:07807035ad66de0d833d68f9bee05801dbf08b88d56a233b0e9fd189372c3261", size = 10729719, upload-time = "2026-10-22T11:55:42.008Z" },
1142+ { url = "https://files.pythonhosted.org/packages/45/f7/8cdd350c67bc62865d1e30cd7add43dd631420223487ba42dfcfd0d0e052/pandas-3.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:54fed4a50b8321ff8d413b46b7e31a023cecefd06c7210c02aa980a9836428b2", size = 10116651, upload-time = "2026-10-22T11:55:44.367Z" },
1143+ { url = "https://files.pythonhosted.org/packages/c3/a9/e82d44fe20dc61dd2e15bdd17c78aa0023d860d55620e4fe82f9e644a089/pandas-3.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a8c44d40cbb6d00128bf2d8f8bb66c8d561744b6d44686b5a1a01f3b3fd1245f", size = 11675868, upload-time = "2026-10-22T11:55:46.617Z" },
1144+ { url = "https://files.pythonhosted.org/packages/83/38/6be618d98e2cbbb99a40349717532f0c2b6794ddc62f1199e3f83e45a40f/pandas-3.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:89b66823678cdd53cf97187e7cd6861402bdd147d11a383706093136a4e83f45", size = 11262553, upload-time = "2026-10-22T11:55:49.143Z" },
1145+ { url = "https://files.pythonhosted.org/packages/06/5c/66e27fb0d691e835bd55b336bdf47312a541a66a17ba4d72a988d743bb79/pandas-3.1.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a8dc7780720b9e2182799ee86279d4cc7d1fd30b230ea1b202da5b6f7e14c3a", size = 11548155, upload-time = "2026-10-22T11:55:51.674Z" },
1146+ { url = "https://files.pythonhosted.org/packages/37/1e/42e81484c5a6791ab1b8c52c1ff1ef0483c119f710ed58ee603d241b8792/pandas-3.1.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e4455cdcc03e42c17233184e8e4ea65c3ed2925d8b1d9d7dd8e6fced3f6e43c1", size = 12029136, upload-time = "2026-10-22T11:55:53.823Z" },
1147+ { url = "https://files.pythonhosted.org/packages/cd/c9/a3b4fd156457f3eb2585838cd56427a113eeeba679204de435a3224b8cc3/pandas-3.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:224a9267e5fa70c16bf4f8a317435eef98387940ce6c8c611f1f68f2c3415e10", size = 12575350, upload-time = "2026-10-22T11:55:56.006Z" },
1148+ { url = "https://files.pythonhosted.org/packages/4e/25/23a017003d792117b297bbd87e35a1bb5658509f610ecd5aeaebb84762aa/pandas-3.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c261c7a808982557c2917d40ffd27916769f26aacb4e6775c01e4467bcd34c3b", size = 13096142, upload-time = "2026-10-22T11:55:58.434Z" },
1149+ { url = "https://files.pythonhosted.org/packages/1c/d2/f4074b7d1234d112aaa23a796be1574441a9fa873707eb98312a237a6185/pandas-3.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:e77112cfcabb3bef7281a72170670db9a5b31409a1cd3199939abb1fba67a7ad", size = 10885958, upload-time = "2026-10-22T11:56:00.895Z" },
1150+ { url = "https://files.pythonhosted.org/packages/25/9f/bcfc9e0ea07f5c7583e129e62cd0529747317977ac1b85878b80235bb322/pandas-3.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:6c51273b89784c9d54020ed2f60e20524169aef349bddf6cba9f5e83b46077a7", size = 10281257, upload-time = "2026-10-22T11:56:03.478Z" },
1151+ { url = "https://files.pythonhosted.org/packages/05/e4/b3bf0b702c0a886e4abcfc2ebdc681310498592a10d9ca0ce5db6df47b70/pandas-3.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84377fa82d0b40165379a0058d8a1a267ceb09e65160de6fcc75475d4d595884", size = 12134028, upload-time = "2026-10-22T11:56:05.696Z" },
1152+ { url = "https://files.pythonhosted.org/packages/3e/ff/9e08cf048d73f4dfdb10da859790cd1a4213465dec39ea367a606d580c4a/pandas-3.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:37f88576f3449bfd86c1052f6b42274b61d7250c8d9dcc55f9dc4de7fe9883ae", size = 11729164, upload-time = "2026-10-22T11:56:08.335Z" },
1153+ { url = "https://files.pythonhosted.org/packages/1f/ff/a0df60d70fac10006841457c30d35647ae0c93af33e420e5e047b15e058d/pandas-3.1.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:756fd67995f3efb6b25f8eaea95c0e893f84e0068e2c53c5fdbb65275041d4db", size = 11413041, upload-time = "2026-10-22T11:56:11.38Z" },
1154+ { url = "https://files.pythonhosted.org/packages/55/04/e6742d85ba7d166a1a8a99ff9f4aa9e585fb24028522daa187859ce50b5d/pandas-3.1.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4d9252b57bded194d09cd5ebf18d9b4edac238b16f56984ea8281d2ecfe39cd", size = 11869427, upload-time = "2026-10-22T11:56:13.637Z" },
1155+ { url = "https://files.pythonhosted.org/packages/0a/a1/c9f4915408ef10b541f31b7812ca038dd903572c163b7be7bdcece8a5110/pandas-3.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:461b79db058639e446ddf90dd7f4804fae385acf0669f6e31321333e14cb6bc5", size = 12430367, upload-time = "2026-10-22T11:56:15.829Z" },
1156+ { url = "https://files.pythonhosted.org/packages/9f/ed/636b9cc8d30b3e2027b43036dd5a8e9e7cfdd5aa86f4454235c5ef7f96db/pandas-3.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bfac077874a396b7ae61a307b27aa6f0256cf31c143b7f96ac622765754cc300", size = 12945204, upload-time = "2026-10-22T11:56:18.419Z" },
1157+ { url = "https://files.pythonhosted.org/packages/e6/5e/5c28942fa8c6cf2d8b4e941abee43a44ba0a123f9fef6411f0319aefddd5/pandas-3.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1340f823a1d189fae8365de314c4d3c2ea0173c61bca66cc76a91385c64d9d3d", size = 11297553, upload-time = "2026-10-22T11:56:20.934Z" },
1158+ { url = "https://files.pythonhosted.org/packages/cd/a4/5991dfb2cccd233ae754f2fb37a4b1474d90595d83f6790467dc5a7d8041/pandas-3.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2bb3f8cc42fae0c0de5186778f4a4957656b9b3955aca48383abfdac44a83598", size = 10626068, upload-time = "2026-10-22T11:56:23.187Z" },
1159+ { url = "https://files.pythonhosted.org/packages/77/26/5cd42609d8e6cb3991242116a0e499d09f29f5bcf574f72f0a4aba13a87e/pandas-3.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:04ca88727dfa86837030c493d7512bb1c008aa4f5f14149c5f8d3ff446791340", size = 11666949, upload-time = "2026-10-22T11:56:25.842Z" },
1160+ { url = "https://files.pythonhosted.org/packages/97/5f/ca1ac99da700c215c52fb0f5063a7fbf14878c6e100f687704c21985640e/pandas-3.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c0bfec4d188fb0c05bba47b9f9def87624b8c545913aa3585a28f19f27ce847e", size = 11255062, upload-time = "2026-10-22T11:56:28.201Z" },
1161+ { url = "https://files.pythonhosted.org/packages/d3/eb/8d7aef062e7d1f48869288e5c5ae5d109923f37ed3bbd0fdcaecc87784e9/pandas-3.1.0-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfb087db83eb79ca348047961d367bdd4bf9820d90a5945f3652c7060da7363", size = 11547558, upload-time = "2026-10-22T11:56:30.492Z" },
1162+ { url = "https://files.pythonhosted.org/packages/06/c4/a041c786bc29935afe44806f72c0111f2643cbac5cab761007a227e12e97/pandas-3.1.0-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b5ce18612ca955e7a95d0bacc20120aedf97e2fbbebbe4de029fc24479e79b8", size = 12026259, upload-time = "2026-10-22T11:56:33.219Z" },
1163+ { url = "https://files.pythonhosted.org/packages/d3/4a/ce6690c7e6081c96857ad7dcbe3b978394d8db682610755919e61863516f/pandas-3.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a13199463b129363701a540c7b79586581b246b10b6a7ad682c41b268e7991d5", size = 12563821, upload-time = "2026-10-22T11:56:35.402Z" },
1164+ { url = "https://files.pythonhosted.org/packages/75/ec/ae440ecbfa8f5ba0230541b4519c1fed0c4293ff362f18f7f353b12e2dca/pandas-3.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8e4de3fe8017e3445f1174707cd977e4e1154a704af2c060a1f648862f81acb9", size = 13086426, upload-time = "2026-10-22T11:56:37.666Z" },
1165+ { url = "https://files.pythonhosted.org/packages/e7/1e/6459d4b0ed9c3d72b9ab63387ec11e1e658579e702e0ef117be8b5d7d0d1/pandas-3.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:eee72bba8a72f0bf9522132e5c87cd10fce9d77440b50f65326b093ec9fb8d43", size = 10877850, upload-time = "2026-10-22T11:56:39.994Z" },
1166+ { url = "https://files.pythonhosted.org/packages/f5/22/e5fad1828e5f923e80a8d7ba9187f687159004a10f99f95dd5346b04c54e/pandas-3.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:9ea2983fe096a3d997c8c4b9f26775fa51e7fb251b10634bd7f87e6fe3542d52", size = 10276384, upload-time = "2026-10-22T11:56:42.193Z" },
1167+ { url = "https://files.pythonhosted.org/packages/59/76/89820c2e1950425f39d30695d2296a8c7d400c4c2d279e7618363a7289ac/pandas-3.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:84d73e0814a38711e4f2a24b272f748a0b4335169d2f9752407ae26bde22990f", size = 12107257, upload-time = "2026-10-22T11:56:44.498Z" },
1168+ { url = "https://files.pythonhosted.org/packages/c8/2c/af4397e6e855d2c0dcb3392ab7788087be4ef53f6a3f166daad8b83c783e/pandas-3.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d3920d9b4de89fbef76ba6b863d58a330b5bfb720c9f8f58eb9b7da9b711d034", size = 11704430, upload-time = "2026-10-22T11:56:46.791Z" },
1169+ { url = "https://files.pythonhosted.org/packages/fd/9e/7791cd269099f9fca7dc4718955cfe4bb94aedb6634b59eeffefb1ed6a74/pandas-3.1.0-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1d61e0eae5219cd024fcbc21304de759964b5642ddcdbb28d9a8ba442ab6901", size = 11378442, upload-time = "2026-10-22T11:56:49.113Z" },
1170+ { url = "https://files.pythonhosted.org/packages/54/6c/da4013bd33c49a12eb3127771111caeed3222a985651e3465bc06f839ce7/pandas-3.1.0-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbb121205af7bc5e587b94a07b5f67b5ba687a918665b9e8748f21388a88b044", size = 11850193, upload-time = "2026-10-22T11:56:51.558Z" },
1171+ { url = "https://files.pythonhosted.org/packages/14/89/8c820bd6fb6ad8f0edb2877639bc7e164bfb4b8e4cdf7a2202f54959ea1b/pandas-3.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:684ef3add5a92abb99c1622f2bd04b54b8843075a266e8240106ca94ce1d1237", size = 12394022, upload-time = "2026-10-22T11:56:53.785Z" },
1172+ { url = "https://files.pythonhosted.org/packages/92/f5/9d753d85c4ea85b6d1c3735496ae5b416d7ef6905f1845c70e9521d124d9/pandas-3.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dde9a55ecd3c4f9100ac50ab15e852e4c54fcf372d800e4495e0b2159a0122ab", size = 12916085, upload-time = "2026-10-22T11:56:56.084Z" },
1173+ { url = "https://files.pythonhosted.org/packages/e6/f9/4a6965677477dbbb19f8881befb01b67eb6009809db73f7f1b7a0132faf1/pandas-3.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:3fe234bfb9999552dc54c45851752e56e163218d7663897dca950b4a7c32af39", size = 11267097, upload-time = "2026-10-22T11:56:58.479Z" },
1174+ { url = "https://files.pythonhosted.org/packages/99/35/e60fd8228db4eb848802988dbc6e3a0f1a81c57f06ad95ebe4cd534fa655/pandas-3.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f1a93c24175129bfc9922ef748ddae1d8137c743ed835e2615be329f606fed4d", size = 10597861, upload-time = "2026-10-22T11:57:00.776Z" },
1175+]
1176+
1177+[[package]]
1178+name = "pip"
1179+version = "26.2.2"
1180+source = { registry = "https://pypi.org/simple" }
1181+sdist = { url = "https://files.pythonhosted.org/packages/fa/e8/0842d757e483e895e670f655084cfe8b8c72580c6769a76b30b2789a0608/pip-26.2.2.tar.gz", hash = "sha256:3a8e5c800492787f33a4114ef54444546c340df428974ebba17e0fd47e11af2f", size = 1863336, upload-time = "2026-10-20T16:36:29.008Z" }
1182+wheels = [
1183+ { url = "https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl", hash = "sha256:fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70", size = 1819850, upload-time = "2026-10-20T16:36:27.046Z" },
1184+]
1185+
1186+[[package]]
1187+name = "pip-api"
1188+version = "0.0.35"
1189+source = { registry = "https://pypi.org/simple" }
1190+dependencies = [
1191+ { name = "pip" },
1192+]
1193+sdist = { url = "https://files.pythonhosted.org/packages/fc/08/47dfc28aa16ef0dbc38dd7c6e4fe24d124c3ed3a722a4a3e5021584a588c/pip_api-0.0.35.tar.gz", hash = "sha256:14dea05d7b3b68f4c7582213e3ae8932bbba8e1f754bf06b95263d5fa6ab4363", size = 123884, upload-time = "2026-09-08T19:35:12.876Z" }
1194+wheels = [
1195+ { url = "https://files.pythonhosted.org/packages/1d/2e/d424c5b5877be862dd9a2c8b7e9ddb13382bb25e132e6dd50cc09c0a7dde/pip_api-0.0.35-py3-none-any.whl", hash = "sha256:66990d0da25378555cbb538db79384bab92d4617c6350dfd7a2126ba2a3a9894", size = 123943, upload-time = "2026-09-08T19:35:11.537Z" },
1196+]
1197+
1198+[[package]]
1199+name = "pip-audit"
1200+version = "2.10.1"
1201+source = { registry = "https://pypi.org/simple" }
1202+dependencies = [
1203+ { name = "cachecontrol", extra = ["filecache"] },
1204+ { name = "cyclonedx-python-lib" },
1205+ { name = "packaging" },
1206+ { name = "pip-api" },
1207+ { name = "pip-requirements-parser" },
1208+ { name = "platformdirs" },
1209+ { name = "requests" },
1210+ { name = "rich" },
1211+ { name = "tomli" },
1212+ { name = "tomli-w" },
1213+]
1214+sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" }
1215+wheels = [
1216+ { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" },
1217+]
1218+
1219+[[package]]
1220+name = "pip-requirements-parser"
1221+version = "32.0.1"
1222+source = { registry = "https://pypi.org/simple" }
1223+dependencies = [
1224+ { name = "packaging" },
1225+ { name = "pyparsing" },
1226+]
1227+sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" }
1228+wheels = [
1229+ { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" },
1230+]
1231+
1232+[[package]]
1233+name = "platformdirs"
1234+version = "4.14.3"
1235+source = { registry = "https://pypi.org/simple" }
1236+sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/886dc7c9d21e2d95aceb696f1e31271742033aeef3661f6e9c8921493592/platformdirs-4.14.3.tar.gz", hash = "sha256:021add1ab113483edbcd4694f5fbb2d688b893ea30d35e1155bb371cf4be0be8", size = 71124, upload-time = "2026-11-01T16:27:32.928Z" }
1237+wheels = [
1238+ { url = "https://files.pythonhosted.org/packages/93/41/f0aa003909ee77a9e7e55f8659abb635ca23ab0d022a449bbb18da87a1ef/platformdirs-4.14.3-py3-none-any.whl", hash = "sha256:cba2d05fe5e18b26b1fa6f376b8e6234c012abe1f8696c486fface7d0054e931", size = 35276, upload-time = "2026-11-01T16:27:31.498Z" },
1239+]
1240+
1241+[[package]]
1242+name = "pluggy"
1243+version = "1.7.0"
1244+source = { registry = "https://pypi.org/simple" }
1245+sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
1246+wheels = [
1247+ { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
1248+]
1249+
1250+[[package]]
1251+name = "polars"
1252+version = "2.1.0"
1253+source = { registry = "https://pypi.org/simple" }
1254+dependencies = [
1255+ { name = "polars-runtime-32" },
1256+]
1257+sdist = { url = "https://files.pythonhosted.org/packages/b9/da/c27daf205a824f89f71172a75ad0098c5837a5a31ddeaac7f8737ea8574b/polars-2.1.0.tar.gz", hash = "sha256:9eee488d6e10773c028753dfc8dac647fd60a708108dcb818a733e85d33df253", size = 811508, upload-time = "2026-10-25T18:00:33.43Z" }
1258+wheels = [
1259+ { url = "https://files.pythonhosted.org/packages/d5/d4/e696a46c74ec8cac6434764deaf72f7edcc2e91c2145a9b3f61900a7aa7a/polars-2.1.0-py3-none-any.whl", hash = "sha256:63c50ce3533f5a03d17c3a2a24f1d0e0c7eda98fbde4268a3cba900d34060e67", size = 911474, upload-time = "2026-10-25T17:58:26.863Z" },
1260+]
1261+
1262+[[package]]
1263+name = "polars-runtime-32"
1264+version = "2.1.0"
1265+source = { registry = "https://pypi.org/simple" }
1266+sdist = { url = "https://files.pythonhosted.org/packages/e0/a6/46e09af693326735b1fba3a8bf5a725b57136fb1342e707e75d3113c826b/polars_runtime_32-2.1.0.tar.gz", hash = "sha256:c575f5f3cdb8a5f9be65477b9b4202b062593687fe62a0082add87fbd4571067", size = 3878004, upload-time = "2026-10-25T18:00:34.932Z" }
1267+wheels = [
1268+ { url = "https://files.pythonhosted.org/packages/7a/40/b424f3946c702ff48eef14e44d35df81a25a784f20f77355f39c746528fc/polars_runtime_32-2.1.0-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:de243b6e5a0b9f3abeaefe542136fcdc6dd8df5be3c988a40838d075314e246c", size = 52169672, upload-time = "2026-10-25T17:58:32.437Z" },
1269+ { url = "https://files.pythonhosted.org/packages/fe/38/07066430ac655a8cab2f9e96ea1fec6ca1f20ec47844afe117662d43ace0/polars_runtime_32-2.1.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:317314d65949f1d58c4939adf83aa0d2cfaf8da50fd4a725a2de27bde2c10018", size = 47626883, upload-time = "2026-10-25T17:58:38.688Z" },
1270+ { url = "https://files.pythonhosted.org/packages/49/a8/a773e20b451bbaab4d8267867b7c43a0c3aa67ebb6f781423a42a5a1af37/polars_runtime_32-2.1.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:777228cc8bf9cda36dee85ba74d13a25974ad4caffc5da93e10b1da9d810ce2d", size = 50033514, upload-time = "2026-10-25T17:58:44.261Z" },
1271+ { url = "https://files.pythonhosted.org/packages/d2/e6/649c524252d32b24d0ba92278033823fa8e0040ee3748ce5afbeda107403/polars_runtime_32-2.1.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b838056cf365d7497971d9d9825c547766923338771c48c545b86ead2ebc572", size = 53863954, upload-time = "2026-10-25T17:58:48.737Z" },
1272+ { url = "https://files.pythonhosted.org/packages/90/f7/2eb405bf5ead6373a33fa5dd4637fe996565a1f07ccce8911ed167673740/polars_runtime_32-2.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04cfc3db1571b74ea4145944de7feceed4bfc9b26e5b2c265e27ad3b2d3866a9", size = 50204290, upload-time = "2026-10-25T17:58:53.33Z" },
1273+ { url = "https://files.pythonhosted.org/packages/33/4d/d76cd42042605e079d6df91cc4fea09ed5f4cb836c72c952a17f585860a9/polars_runtime_32-2.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:af58b5f6e5cc44b578f2a93d752f87c135c3a53205b4360301bc3a9b89dfbfd1", size = 53794253, upload-time = "2026-10-25T17:58:57.99Z" },
1274+ { url = "https://files.pythonhosted.org/packages/4d/4e/2a419d0def2908255dd1b7318c48430319859dc92ca156e2f81ff7bcd78b/polars_runtime_32-2.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:f2fe32f48af2395cd6ec52a433a03970a69aab1dbb66a613729fd549ce92c904", size = 53926265, upload-time = "2026-10-25T17:59:03.13Z" },
1275+ { url = "https://files.pythonhosted.org/packages/c8/1b/15ffba3264baf21d2f04c51f54b01159cb6f4fad4e84de1404be552d601f/polars_runtime_32-2.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:0af30dd53a7725f703d0d21dd421a39e0165378bfaa41431008f286608376d71", size = 48636436, upload-time = "2026-10-25T17:59:08.476Z" },
1276+]
1277+
1278+[[package]]
1279+name = "prometheus-client"
1280+version = "0.26.0"
1281+source = { registry = "https://pypi.org/simple" }
1282+sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
1283+wheels = [
1284+ { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
1285+]
1286+
1287+[[package]]
1288+name = "psycopg"
1289+version = "3.3.6"
1290+source = { registry = "https://pypi.org/simple" }
1291+dependencies = [
1292+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
1293+ { name = "tzdata", marker = "sys_platform == 'win32'" },
1294+]
1295+sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" }
1296+wheels = [
1297+ { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" },
1298+]
1299+
1300+[package.optional-dependencies]
1301+binary = [
1302+ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
1303+]
1304+pool = [
1305+ { name = "psycopg-pool" },
1306+]
1307+
1308+[[package]]
1309+name = "psycopg-binary"
1310+version = "3.3.6"
1311+source = { registry = "https://pypi.org/simple" }
1312+wheels = [
1313+ { url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" },
1314+ { url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" },
1315+ { url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" },
1316+ { url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" },
1317+ { url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" },
1318+ { url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" },
1319+ { url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" },
1320+ { url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" },
1321+ { url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" },
1322+ { url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" },
1323+ { url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" },
1324+ { url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" },
1325+ { url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" },
1326+ { url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" },
1327+ { url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" },
1328+ { url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" },
1329+ { url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" },
1330+ { url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" },
1331+ { url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" },
1332+ { url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" },
1333+ { url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" },
1334+ { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" },
1335+ { url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" },
1336+ { url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" },
1337+ { url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" },
1338+ { url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" },
1339+ { url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" },
1340+ { url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" },
1341+ { url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" },
1342+ { url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" },
1343+ { url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" },
1344+ { url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" },
1345+ { url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" },
1346+ { url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" },
1347+ { url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" },
1348+ { url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" },
1349+ { url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" },
1350+ { url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" },
1351+ { url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" },
1352+ { url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" },
1353+ { url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" },
1354+ { url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" },
1355+ { url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" },
1356+ { url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" },
1357+]
1358+
1359+[[package]]
1360+name = "psycopg-pool"
1361+version = "3.3.3"
1362+source = { registry = "https://pypi.org/simple" }
1363+dependencies = [
1364+ { name = "typing-extensions" },
1365+]
1366+sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" }
1367+wheels = [
1368+ { url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" },
1369+]
1370+
1371+[[package]]
1372+name = "py-serializable"
1373+version = "2.1.0"
1374+source = { registry = "https://pypi.org/simple" }
1375+dependencies = [
1376+ { name = "defusedxml" },
1377+]
1378+sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
1379+wheels = [
1380+ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
1381+]
1382+
1383+[[package]]
1384+name = "pyarrow"
1385+version = "26.0.0"
1386+source = { registry = "https://pypi.org/simple" }
1387+sdist = { url = "https://files.pythonhosted.org/packages/ec/34/17c34cb38e5d940e38f0f0d9fdfa0e8a506676409ea9b85aff7e3079f831/pyarrow-26.0.0.tar.gz", hash = "sha256:0cccd36e00ea3afeb52ded61f2721ce71f604853d70c45365c58324eb773d6ae", size = 1239433, upload-time = "2026-10-09T08:26:25.315Z" }
1388+wheels = [
1389+ { url = "https://files.pythonhosted.org/packages/b3/60/6793778f2617cce469383dac0ba08c4f2401cf342df0c7b9ca53939d9b46/pyarrow-26.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:90ddaf7c625307ad52f31a9b25c34fe5e4897c7529ee3481135822b2b6842ff1", size = 36333953, upload-time = "2026-10-09T08:14:00.387Z" },
1390+ { url = "https://files.pythonhosted.org/packages/db/81/f944cc63ce8a753e5fbff25de6d1d475ebd7fffdf9cf98c65130294fc896/pyarrow-26.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee341973f78a0b46e073d065e88e75026a9c584051e97f98a0d05d96c6bac7dd", size = 38688456, upload-time = "2026-10-09T08:14:04.344Z" },
1391+ { url = "https://files.pythonhosted.org/packages/f5/2d/7e5c722fa5d5d9f3b75e62fe11694b34217664d4f05ac88031197166b277/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:01c863a18bd9c8412453dd0d92de6d0ee7b2b3d6fb079d9734a4b2a3c8bd4453", size = 50867603, upload-time = "2026-10-09T08:14:09.115Z" },
1392+ { url = "https://files.pythonhosted.org/packages/88/e4/9cd356d906e71bd79b0c3fc5c9a54e01a0020dcf14c152ccfbcb503c7298/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6a628922ba20705fa964ca73e4ef959c2fb2f14b9bbec5589a6a1e68e6257c85", size = 53931932, upload-time = "2026-10-09T08:14:24.051Z" },
1393+ { url = "https://files.pythonhosted.org/packages/bb/e4/5bae3133b7fe04c24907a20f3bc1fba388cbbde659199e7b76445982047a/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:954d971b363b16ee41f89389a4053315dc71265f2ce5c2468eb0a910b1166268", size = 54444720, upload-time = "2026-10-09T08:14:31.214Z" },
1394+ { url = "https://files.pythonhosted.org/packages/ba/b4/ee422493bb6dafdbef776cfe2c2a73106a1063a79bf4e78d1e5f51176885/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5d5768d03426abe6526d5274adefa00abf00a7f81118c46e98b5a46390f5549e", size = 57388949, upload-time = "2026-10-09T08:14:38.964Z" },
1395+ { url = "https://files.pythonhosted.org/packages/54/3c/1783aab1dac28e175dcf26dfc7123725efc474caecaed91e8a34cb89cad0/pyarrow-26.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cc903e1069e9dd5e9dcf780324c0112e27e051e422ecfaff574fb33ed65d9160", size = 28567581, upload-time = "2026-10-09T08:14:44.279Z" },
1396+ { url = "https://files.pythonhosted.org/packages/4d/35/ca95493712af97c46a312945c8e9d16b21c5fe2f148be5466168d0290505/pyarrow-26.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a6ca849f90cf73fe361f08a5762c783ead9671e4548c1f558cc637b54c9103f2", size = 36336700, upload-time = "2026-10-09T08:14:51.399Z" },
1397+ { url = "https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:c2ba350957076b1b3a22f549261dc3e9c67ca20816d8bd5f79d7b9c69be4c4c2", size = 38698502, upload-time = "2026-10-09T08:14:57.114Z" },
1398+ { url = "https://files.pythonhosted.org/packages/3b/7c/cea852a832a327a8de797b3a68e5c25ce0f5aa1d20503807671bd90ec642/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e3b190ba1d3d22a5a8758597f797111b77d433473744352a184a5ee0a42d672e", size = 50865064, upload-time = "2026-10-09T08:20:01.614Z" },
1399+ { url = "https://files.pythonhosted.org/packages/4f/d6/e95834b29360092376fe4da9956ba41bb7b021869efe6ee9d4172d05cb15/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:240bd18a7487f8767616a948a69dd4e740a8bc36a1c9da49e4dc9a32c5c2faed", size = 53926722, upload-time = "2026-10-09T08:23:10.829Z" },
1400+ { url = "https://files.pythonhosted.org/packages/e0/7f/98257444e2aea2e1fddceee3af3bd2077236d550428413f80393bd1f888d/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2b5fcd69c0e1107b79e55839877db5a6ed04651b73fd6fec581d09e230bed5e4", size = 54443093, upload-time = "2026-10-09T08:23:16.971Z" },
1401+ { url = "https://files.pythonhosted.org/packages/88/ca/dac99cfb25cfa62bf7194600cc99abc14a6bd2af50d7fdb7f15eeaf6e202/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7444ea6975c49a857c68f9bd8fa11acae96dede63d120ffb3bf0a603ea82516", size = 57381937, upload-time = "2026-10-09T08:23:24.95Z" },
1402+ { url = "https://files.pythonhosted.org/packages/c0/ed/138d29fddaf803b90f4527e124bb6aaddc18aaf4a6c50fd0a5f577c94989/pyarrow-26.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:3de30a7432b48b98b9decbd9e25a53bb9251d202c2e6c5a29a50869592ccb117", size = 28478571, upload-time = "2026-10-09T08:23:30.535Z" },
1403+ { url = "https://files.pythonhosted.org/packages/8c/32/01858422a37f083911c2bb4d15cc32c5eeaa9d9b2bf5ddedee995a7146a6/pyarrow-26.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5780d487ff6c6ed7b42298609680d87fe0036e529a9dc2e1105364bce9697f50", size = 36378402, upload-time = "2026-10-09T08:23:36.537Z" },
1404+ { url = "https://files.pythonhosted.org/packages/00/85/f6b5976c2878b752d0804d371684e0495a71de296b6dc6559e6fbaa4311a/pyarrow-26.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:a0e4e92eeb088f1d7c2c04d6c7de8434c75abb4b4ccf0bbcd045aa7164c68d93", size = 38733074, upload-time = "2026-10-09T08:23:42.873Z" },
1405+ { url = "https://files.pythonhosted.org/packages/81/bc/c90fcbbcf893631e23dab1b0fb3fa29a508a8614326571b03c0894eda00b/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:eaf9e7cc7ab59f6c760232bbde18f64d559bbc50544841303bfb32be53533297", size = 50929201, upload-time = "2026-10-09T08:23:50.507Z" },
1406+ { url = "https://files.pythonhosted.org/packages/ec/c1/0c1ff38ab7df1b2cf54cf0ad9f19a516c4e416c6c9b4c966cc2c9d587f77/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ab6914db225d7f399652ae1f08588dfbc9efe617612715701e3d9d5cfa5ca19f", size = 53951865, upload-time = "2026-10-09T08:23:57.692Z" },
1407+ { url = "https://files.pythonhosted.org/packages/9f/70/6a6b170496925472adad45a32528770fc8632db35fc60d4edd1e9ce1be0b/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41dd3661ef40790a78870052ad7a58ad827b27c67a4511f06962eb9e9b74d19b", size = 54496388, upload-time = "2026-10-09T08:24:05.23Z" },
1408+ { url = "https://files.pythonhosted.org/packages/a8/32/033ef9dba80976820190e292a10a5a23e9406572b76bbeb4d685d90e5c8d/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6e949744dcfc2d379808f7013c5f9cafaf0f817656dff7d46c6931528dd1784b", size = 57411588, upload-time = "2026-10-09T08:24:12.043Z" },
1409+ { url = "https://files.pythonhosted.org/packages/1e/ff/a74892c50aaf1f9f744a84493e08a2f99221e77c39d2d4a926de21a99edf/pyarrow-26.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a5fa8dc70dd50808990ff36faf44088e357b353d86c7682dd92d4b78d4c97d5", size = 29237858, upload-time = "2026-10-09T08:24:58.106Z" },
1410+ { url = "https://files.pythonhosted.org/packages/03/10/f0ee0976ef08a851a743c57608917ac9a47623f688b9ee0efe5429975ba1/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:e2a1856e9565fe2679863b372478c681806aebbf7d0a6e72f33e77f804e647d6", size = 36495870, upload-time = "2026-10-09T08:24:16.479Z" },
1411+ { url = "https://files.pythonhosted.org/packages/27/ca/0bc431a509bf10b4472dbb94f4184752ecbbddeb7f467152dac0fdaed469/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:4bcba83299cb2b8f8e443d36c6ba6269a5034431879015fb0719495df8a14de2", size = 38819754, upload-time = "2026-10-09T08:24:20.875Z" },
1412+ { url = "https://files.pythonhosted.org/packages/61/59/2be41d26af7a07fb71581fb753cae396403ba1a2978355fd553929d44a9a/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3a4d235876f14b4136b4d616ec42eb469ea0d6ead336cae631aa1dd29b21c962", size = 50933671, upload-time = "2026-10-09T08:24:27.199Z" },
1413+ { url = "https://files.pythonhosted.org/packages/4b/cb/b6d5048cf3178be9678f5c9c60040199894b2f69c3439c87ced91fd24da9/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:210cc9b83888b87cdc8f793eebb264f22b20d0dedbedefc73b9687a7047b4747", size = 53906419, upload-time = "2026-10-09T08:24:33.536Z" },
1414+ { url = "https://files.pythonhosted.org/packages/09/2b/23e30fbd776c81d18d134d2592eb60daca13e8a57ab087d0fa042f9d9f3d/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca77c43ca55bfc9a4eeb1f0cd5f093f08731b77c24cdba0829035f084959b0bb", size = 54527960, upload-time = "2026-10-09T08:24:41.292Z" },
1415+ { url = "https://files.pythonhosted.org/packages/e2/23/fce251cd6b0546dfc181b00d5c8ef1c95a8c4cae83266bc3dfd5f719c62c/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:290a74c48e9491b436fd5edacfadf357943f82aa45c81110bd83a69aab33d1cf", size = 57388010, upload-time = "2026-10-09T08:24:48.186Z" },
1416+ { url = "https://files.pythonhosted.org/packages/44/a5/0126fb0ef8d59bf257bdd68bb41623b72afc6e81790a0b4ac863a0f58861/pyarrow-26.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:515a10dae2a1d236bc9c9209d0317acb6746ea63cd4f98704904af7156d90ed1", size = 29406123, upload-time = "2026-10-09T08:24:53.387Z" },
1417+ { url = "https://files.pythonhosted.org/packages/ed/66/8ada1b5165359d84b4b9b5384742304d1081da670f77d458fd9c9b8a2161/pyarrow-26.0.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e890816e5ee89c74a0f8b9379fe8b5ba83f46132b2a0bbb9b1c21359ec30dfda", size = 36373215, upload-time = "2026-10-09T08:25:03.067Z" },
1418+ { url = "https://files.pythonhosted.org/packages/c4/83/74f10c3d803a6834b2acab21847724d4bdbc74d246eb17321432844707f3/pyarrow-26.0.0-cp315-cp315-macosx_12_0_x86_64.whl", hash = "sha256:9db18a9dc0af52135c9eac549d80a7a882696efbe5406cf882b044525d4ecc2e", size = 38730866, upload-time = "2026-10-09T08:25:07.924Z" },
1419+ { url = "https://files.pythonhosted.org/packages/e2/5a/ea2fa2163b1bd8ff73efd39c4060be63fd6ddec03e7887a471acd1e042a4/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:734312d3d99088d9ec28c5b17bad40389bd8373a1afc10acb60b83fd217af087", size = 50924443, upload-time = "2026-10-09T08:25:13.864Z" },
1420+ { url = "https://files.pythonhosted.org/packages/78/80/8c47b6cf8cfd42826df65193eff026c1cc81fa6cb213a3c3f5d203e6f67a/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:24f892fdf1ae1942d69d3f7742e2f49960ec95277cfb1a70b8a1d91f4a96d935", size = 53948540, upload-time = "2026-10-09T08:25:19.305Z" },
1421+ { url = "https://files.pythonhosted.org/packages/69/1f/3a506a76d944ec5c5e4b7f01d8d0446b392a6fb384de627a12e503f616b4/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:879331ddea2a26479fa18fade71e6facf684a6cf19f67daec3775c871569e8e5", size = 54494863, upload-time = "2026-10-09T08:25:24.517Z" },
1422+ { url = "https://files.pythonhosted.org/packages/3d/50/08c4bb04d651788d2eaca78065743f4f6ded974d4ef96ae3c473993e9d0c/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:5b827650e874f1f9f9392524ea3e9e3e8a245de5ba64acca1f81ab188090afb9", size = 57409877, upload-time = "2026-10-09T08:25:31.157Z" },
1423+ { url = "https://files.pythonhosted.org/packages/d4/f3/c64781fbd7b6d3c07993b698c14944d0d195f07e800fa931c486ae6ab36a/pyarrow-26.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:8e8e28c464552b5ca03e30d4504168c4425ce383884f8611b00e972f9fd933fc", size = 29236658, upload-time = "2026-10-09T08:26:22.607Z" },
1424+ { url = "https://files.pythonhosted.org/packages/06/55/2ee3729daea999f19f061f03898d4895a242c4cd94f26e1324e5fdfbfe10/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:ce28748cbeb0f29c3ce9603782979c7117580fc76f16aa3ca448b38a22281adb", size = 36489011, upload-time = "2026-10-09T08:25:37.64Z" },
1425+ { url = "https://files.pythonhosted.org/packages/6a/7d/3eb17f601f2bf13eda5f2ed28956379ca628b4dda97619cbb1cb1721622d/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_x86_64.whl", hash = "sha256:106bb9290fc6fd9a84138a9440038ef184bac86463543c5ff099229cb30d996c", size = 38808480, upload-time = "2026-10-09T08:25:43.579Z" },
1426+ { url = "https://files.pythonhosted.org/packages/0e/e3/f0047360b0f4bfc031b256dc0aec3837a61f245b2fb70f8363438e2db665/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2e4a413046eba9896e632925066c74095182200ba32e19ff0166bf64d2f936ac", size = 50923273, upload-time = "2026-10-09T08:25:51.445Z" },
1427+ { url = "https://files.pythonhosted.org/packages/38/d9/56d9fb91210407df31cbeb9b91138601c88c7c8fb5f6bf773b20d65509bf/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:d58798c4d8d629700058e9afc1e16b9801023f3ce4dc1c92d945e79b5ffe4e98", size = 53900905, upload-time = "2026-10-09T08:25:59.554Z" },
1428+ { url = "https://files.pythonhosted.org/packages/cf/40/8e8a7e9e027c731520c7eb179dd00a153b76ebf0bc11d213c6c8f8502851/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:645917e976671debabf854abab6e2b75c571ca4f82adc33a2d338697f7c27d93", size = 54518345, upload-time = "2026-10-09T08:26:07.125Z" },
1429+ { url = "https://files.pythonhosted.org/packages/be/89/1e768a3fdb88d34e708ad2dc00dbf8e4e30290784eb84198d59308963bea/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7c3fda041e7078802589cf257750323ee3d0cd1e56e53a9b20ec845697fb3d28", size = 57379403, upload-time = "2026-10-09T08:26:13.624Z" },
1430+ { url = "https://files.pythonhosted.org/packages/96/be/7b81a44d6a8e70581dcc1d6f01541f9000a973b1e5d75394aec91e7b179a/pyarrow-26.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:68cd662e9e2b00876a131950cf32336ace2d0865e1f9418763e3d3be8481dfa4", size = 29389953, upload-time = "2026-10-09T08:26:18.277Z" },
1431+]
1432+
1433+[[package]]
1434+name = "pycparser"
1435+version = "3.12"
1436+source = { registry = "https://pypi.org/simple" }
1437+sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" }
1438+wheels = [
1439+ { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" },
1440+]
1441+
1442+[[package]]
1443+name = "pydantic"
1444+version = "2.14.2"
1445+source = { registry = "https://pypi.org/simple" }
1446+dependencies = [
1447+ { name = "annotated-types" },
1448+ { name = "pydantic-core" },
1449+ { name = "typing-extensions" },
1450+ { name = "typing-inspection" },
1451+]
1452+sdist = { url = "https://files.pythonhosted.org/packages/9f/d5/b4867fa7c7f04c34997d7d8aad744f64d210bb99d67cf18bf3fb1eea1aed/pydantic-2.14.2.tar.gz", hash = "sha256:fb7b2db6cefe8414dd612edeede49c4c433acb0432b0f92b95024e45f66c505d", size = 911596, upload-time = "2026-10-29T16:37:12.476Z" }
1453+wheels = [
1454+ { url = "https://files.pythonhosted.org/packages/97/01/c71a71258b58659c72504e5fd65b451f6e36e34dc931d0b237ded213c814/pydantic-2.14.2-py3-none-any.whl", hash = "sha256:7efbf7507b937931cd32a5755acb00ea1cf5ccb1d4e8067a5850d5069e382118", size = 490403, upload-time = "2026-10-29T16:37:10.181Z" },
1455+]
1456+
1457+[[package]]
1458+name = "pydantic-core"
1459+version = "2.50.2"
1460+source = { registry = "https://pypi.org/simple" }
1461+dependencies = [
1462+ { name = "typing-extensions" },
1463+]
1464+sdist = { url = "https://files.pythonhosted.org/packages/af/8e/23ec1021834e35041fbb2f10946141e6acdfd39379c45cc4d65a4e8a1d98/pydantic_core-2.50.2.tar.gz", hash = "sha256:1b0e8dc3932cf4e9ed5727570325cacc716b9409ffc794a221c0ccc72e8eb775", size = 518711, upload-time = "2026-10-29T16:34:57.465Z" }
1465+wheels = [
1466+ { url = "https://files.pythonhosted.org/packages/d6/65/6609a66cda909d414c58438f9bd70b1e947fe12c25f6bd40c93121afd7ef/pydantic_core-2.50.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e3d824b6152e1c0fd58dc6266f2de470eb7b2286135294f5f7cd26a0f0693738", size = 2220341, upload-time = "2026-10-29T16:32:10.193Z" },
1467+ { url = "https://files.pythonhosted.org/packages/f9/0b/92463ae7be78d73885487f72c4599902c2cc190378bccaad5718b7495250/pydantic_core-2.50.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:596921489390a002d6a5e303384a0f1f98384f8bb550fbf38a1aafa6093da0f9", size = 2093701, upload-time = "2026-10-29T16:32:11.502Z" },
1468+ { url = "https://files.pythonhosted.org/packages/62/d3/24147a1aa22f7eade546172750e1110fd7b2793998057809800d3ebeae4e/pydantic_core-2.50.2-cp312-cp312-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:36d8b9725684ca3bf658b90a101d335fc8cae405bb22196b7b5ef41344913756", size = 2253622, upload-time = "2026-10-29T16:32:13.051Z" },
1469+ { url = "https://files.pythonhosted.org/packages/f6/50/2628ca3c83a0d9a6fda3305fe59b1b28e40c0221e8f234cd1ef86fb64745/pydantic_core-2.50.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43a8fd56f3c681f3cda2d5c842ad28e88241df742dfcbe8fd1dd437a3056e1f6", size = 2114101, upload-time = "2026-10-29T16:32:14.702Z" },
1470+ { url = "https://files.pythonhosted.org/packages/21/c8/cc6d7304a93f91f5e06260ccad71cc7d882489663a37dec009532379fc46/pydantic_core-2.50.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:6f5a432c6c1ef18a1a02a04b3616b8d3f2f88b13faa46caae1ac6559ab202234", size = 2199541, upload-time = "2026-10-29T16:32:16.153Z" },
1471+ { url = "https://files.pythonhosted.org/packages/65/d9/18f44ac5696b3e2b2384cb89c9fbe6ed69860f528e232b9fb46cc5b45a17/pydantic_core-2.50.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5d3cd8be71c4da3c9b42cc92f17e5e3036bdca98bf18555a0e072c3fe87abbb4", size = 2368597, upload-time = "2026-10-29T16:32:17.544Z" },
1472+ { url = "https://files.pythonhosted.org/packages/1d/22/5f2a48ef819a7e1b74fb003a85d27576ad2f9ddf3901b88998ac63ce1e00/pydantic_core-2.50.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a438cb8a3f87f2c49e82e43fb19511d4d5043f53e0787dc4df937ccb904373a", size = 2451701, upload-time = "2026-10-29T16:32:18.956Z" },
1473+ { url = "https://files.pythonhosted.org/packages/08/93/2a6556bbdb8e918175af9d849a5d0b367b5644e41939d040f3a7215b1339/pydantic_core-2.50.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9667637665acba166a7dbbc786f14c50512d3a01f7199e0e1a13b929e92d7c7a", size = 2242782, upload-time = "2026-10-29T16:32:20.381Z" },
1474+ { url = "https://files.pythonhosted.org/packages/90/ed/dd9f29a92257f32eb5b99d5120826186ee0632ecc3aaf6c23e2fab89ed6d/pydantic_core-2.50.2-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:b3e681208e2d39fde13fbea449ef2736a85ec1ea2afa41e9193a8c536e71dafe", size = 2253706, upload-time = "2026-10-29T16:32:22.293Z" },
1475+ { url = "https://files.pythonhosted.org/packages/11/19/1ccf3bf38eb4ed97d8aeefc558458834ae0ad53fdbb1edf61af7228ea500/pydantic_core-2.50.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6eb6ff07964f39e33c6f57fefe939514e76b8c0d4a694e5663e2e04e80cc1798", size = 2288603, upload-time = "2026-10-29T16:32:23.465Z" },
1476+ { url = "https://files.pythonhosted.org/packages/ea/cd/bae2320fc89a65bcd87bf66ec83aac51b3b43b970a2edece15feb8af64dd/pydantic_core-2.50.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:4eb54c25e3463d73e1e4ed439ffc45d1d7e590df40bc7bb06f11a906afd996c3", size = 2476597, upload-time = "2026-10-29T16:32:24.824Z" },
1477+ { url = "https://files.pythonhosted.org/packages/db/35/1bdb8d52c8e562e95c5e6ebb5784792af568ac6a81470dd546d95f392b60/pydantic_core-2.50.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:91238117cd296352f54f24117ff5a227bdea428865dba1244aa0cdd1424c0bc2", size = 2478664, upload-time = "2026-10-29T16:32:26.197Z" },
1478+ { url = "https://files.pythonhosted.org/packages/12/72/0179b40ac6a6b0899c61826f2e3e80f88063f6d821e0dd9641927de46700/pydantic_core-2.50.2-cp312-cp312-win32.whl", hash = "sha256:98722314e12a8e4fa346e0afd3fff2098b5732875ff45592b450a7b589b43c4f", size = 2108820, upload-time = "2026-10-29T16:32:27.736Z" },
1479+ { url = "https://files.pythonhosted.org/packages/b4/fb/679144362d7e5162519d9f6631ec982521417de3196351c6c56485f5b29f/pydantic_core-2.50.2-cp312-cp312-win_amd64.whl", hash = "sha256:b381574614245f18fdb2d6d1aa8f149e225869c47b5ee7f81812de8977817314", size = 2252238, upload-time = "2026-10-29T16:32:29.572Z" },
1480+ { url = "https://files.pythonhosted.org/packages/0f/68/e3ccc885c3e2086ff84d7ca496f45b974a9f0a49ce2da8973aaa3a369a4b/pydantic_core-2.50.2-cp312-cp312-win_arm64.whl", hash = "sha256:f07c965a8aa6d62145f122641f292129984f39a3e3697585b19298a1488287db", size = 2162565, upload-time = "2026-10-29T16:32:30.853Z" },
1481+ { url = "https://files.pythonhosted.org/packages/c2/fc/ac3ae9d934932ce3c380e7fe53e01372b49ef5dcb49db56f1a1f005b4d8c/pydantic_core-2.50.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:93eb41d626d9a2749a678823c70bba8c25033cef474ae8001fd7621adbbd8437", size = 2222463, upload-time = "2026-10-29T16:32:32.125Z" },
1482+ { url = "https://files.pythonhosted.org/packages/f7/0b/7605223654ed4e777d05668e914d32b476b1fa2bb756bff9820fe270456b/pydantic_core-2.50.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c539d452cba22f250103c7d1c00cbe9038eb705e657476e701c43374efd9f2e", size = 2093995, upload-time = "2026-10-29T16:32:33.496Z" },
1483+ { url = "https://files.pythonhosted.org/packages/b4/29/4a1a653cfedd3f5e1ccd3b1e919fe324fb45607d12c30fdd7ca5af16ba8d/pydantic_core-2.50.2-cp313-cp313-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:53bd25e8ab54c02bb4c55d4848c1d36f75f3b9a7356e83179fe27794d9e9e6c4", size = 2253178, upload-time = "2026-10-29T16:32:34.77Z" },
1484+ { url = "https://files.pythonhosted.org/packages/6c/f5/03ce5ed01c98b60272cdee8ac1abb819b64ef347717c11d96215e32d9952/pydantic_core-2.50.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f142cd663d1148ed32993ed9a58e5c67ea312f446a01a6c13a956945aafc9f23", size = 2113894, upload-time = "2026-10-29T16:32:39.241Z" },
1485+ { url = "https://files.pythonhosted.org/packages/d3/bc/f7644474ac0faad9a7fdf5179239893b21339169b8b4f89e3075c4be16b2/pydantic_core-2.50.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:126f158474346f457c0759c29c1a6334acd5f7f99dd272bb27d19314f3d81b4d", size = 2198248, upload-time = "2026-10-29T16:32:42.357Z" },
1486+ { url = "https://files.pythonhosted.org/packages/c2/8f/3d3a1c1d313e02774f6c034eb1910eb6e795922e66e81f0f053f98afe866/pydantic_core-2.50.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0b94a515f44f1ebcb25395bcdba506c4cdaf6d3fcb0466b9658521b41e5ff057", size = 2368505, upload-time = "2026-10-29T16:32:43.755Z" },
1487+ { url = "https://files.pythonhosted.org/packages/03/33/38fb4545c00ce2bfebc2a0ffd99f7f580f1e1efb8db3b2faf88def640435/pydantic_core-2.50.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a55c1163e829a08707b4a92c8ee02a5a3a3847ced74aa4da94e3e975ffe1ec63", size = 2451704, upload-time = "2026-10-29T16:32:45.075Z" },
1488+ { url = "https://files.pythonhosted.org/packages/79/be/b559a1ceaff8b1d4581733d47d2ffa785635ac05716eae4c964466aac6e8/pydantic_core-2.50.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a1fb3ffbd1195b24490f671d12623dd4c9bfa56d6af0a299fa43b516134f5b", size = 2241760, upload-time = "2026-10-29T16:32:46.382Z" },
1489+ { url = "https://files.pythonhosted.org/packages/2d/7c/3c8ab7cb45e4dc7687a4f16a953fee14d2eca18dd90d6be8bd470ef06962/pydantic_core-2.50.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:83d6f42ae6eba44548a1a3d4f900a5b1500fdd14f09f04754a8800ab0d544432", size = 2254115, upload-time = "2026-10-29T16:32:47.818Z" },
1490+ { url = "https://files.pythonhosted.org/packages/5c/ad/3f3471206224cdac2af3492a3f7c724c47a82f5f8850ff364c2e36023497/pydantic_core-2.50.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:9fcde11462e0faab4c2a2ae8cd4516f7dd8239eec583bd88c5524d8064ad2d0c", size = 2288598, upload-time = "2026-10-29T16:32:49.404Z" },
1491+ { url = "https://files.pythonhosted.org/packages/a9/1e/2070b388b1d8633cfb72e3964ac3c95aaeb195c240209894ff386847b494/pydantic_core-2.50.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:848984e231190ae49bc8b5229e64d2a6fba4231dc6046e1b6b28ddc060bc7601", size = 2475116, upload-time = "2026-10-29T16:32:51.414Z" },
1492+ { url = "https://files.pythonhosted.org/packages/3d/96/e96ff19527c73b9aaddce2a4424006dc15439fd89ef2d39641048c5ee85a/pydantic_core-2.50.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:8772c2c0091e6fc2d0499151793d31bf811826986b0727052935a95104845544", size = 2478926, upload-time = "2026-10-29T16:32:52.856Z" },
1493+ { url = "https://files.pythonhosted.org/packages/35/10/2fefc082eb46f959e80735e859161305c0c2fbb3d5d20ac5547c49a84a3b/pydantic_core-2.50.2-cp313-cp313-win32.whl", hash = "sha256:bd6ecae08c0d871cf93b6f0d4ee76b06c65be44ea22311981a022b2f1948cf9d", size = 2108354, upload-time = "2026-10-29T16:32:54.276Z" },
1494+ { url = "https://files.pythonhosted.org/packages/6f/dd/14eeec394ec1422f9c4e9743cd746f4502232a7c65eb235d1a03729d94ab/pydantic_core-2.50.2-cp313-cp313-win_amd64.whl", hash = "sha256:e5c801bdf3cc92274b8fc2c7023c8751e5ae81260c009ac558ff55ce712a2d45", size = 2250556, upload-time = "2026-10-29T16:32:55.396Z" },
1495+ { url = "https://files.pythonhosted.org/packages/75/b7/a014acf1ae4b4aa7d32ff957e2250698637daa3a29448444180360b92284/pydantic_core-2.50.2-cp313-cp313-win_arm64.whl", hash = "sha256:fd9aa71a800c87c0f7ab53fa38009169a23a82c685989626ca74a4bd483e1b67", size = 2162633, upload-time = "2026-10-29T16:32:56.792Z" },
1496+ { url = "https://files.pythonhosted.org/packages/f2/64/fc525bb981a9f287cfa34346e7ed1d5ef7c043c2b9b517f54c6136032d10/pydantic_core-2.50.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:efad0bdc67d3b119c3bab6b94d341a27333c2bb89ed75dfa182a4a06cd0ea329", size = 2226414, upload-time = "2026-10-29T16:32:58.559Z" },
1497+ { url = "https://files.pythonhosted.org/packages/db/68/315701095f25687938ab79d263a6d939337456c2840fe69038fd38000338/pydantic_core-2.50.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ffb4dce3ecb2cd563aa30f9eb5088a1cf1f4f688b90cbb374fa96e49fc50b02", size = 2095041, upload-time = "2026-10-29T16:32:59.873Z" },
1498+ { url = "https://files.pythonhosted.org/packages/b8/4b/efd3abf31aa24bc97b311735f418886f947b4f193df02a1977643963b03e/pydantic_core-2.50.2-cp314-cp314-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:150f6e101f575c21a4ecfed1be1c05bb89a0ad49cb9c846ed377583bd71b32c2", size = 2257635, upload-time = "2026-10-29T16:33:01.096Z" },
1499+ { url = "https://files.pythonhosted.org/packages/2d/6c/146b36eed4de8115a0fda76840703e75f5f8b36aeb3af86976bf094d1a17/pydantic_core-2.50.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1db9cab6197e797824f0479dd2ae5fd2e272aa61c61b7faa5a6e450ca7a0d3b7", size = 2116807, upload-time = "2026-10-29T16:33:02.692Z" },
1500+ { url = "https://files.pythonhosted.org/packages/fb/94/db006ea79bbe405cc38b25899319c7d461ff1813cbdfe6aa90a65ed1dfff/pydantic_core-2.50.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1b9f652e17a13bbc09b45f74c3c326cca72059ce4dcdef687fcb04389db413a9", size = 2201037, upload-time = "2026-10-29T16:33:03.942Z" },
1501+ { url = "https://files.pythonhosted.org/packages/41/f9/ea2bc1ec13a67af4a63c9a887fa0aa6ef4ade9018e2bb412d4fc80110e19/pydantic_core-2.50.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:823b222c4fc861a82c650998a6117ecbd150cc71bf8b78f9a31bf3f48c456108", size = 2374904, upload-time = "2026-10-29T16:33:05.458Z" },
1502+ { url = "https://files.pythonhosted.org/packages/44/8e/26a6a075301f0e563646037ea69bef8c63911d38fbd257f94cec69cdf985/pydantic_core-2.50.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:783abdfac21247d33bec40aa683d26bba30d61655c40de38b2decc4797aabf92", size = 2457626, upload-time = "2026-10-29T16:33:07.271Z" },
1503+ { url = "https://files.pythonhosted.org/packages/9d/a2/8eeabaf1d394f0d3b9a27129ac21556eabee6f2ddcb418069ef4037e00b0/pydantic_core-2.50.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b29b7c5adf4b2be7c941f7f76432aa3269cd5052ff8610376b8ece24d7b02c51", size = 2245505, upload-time = "2026-10-29T16:33:08.824Z" },
1504+ { url = "https://files.pythonhosted.org/packages/bd/54/449778cd397e2682b30d1bcfab0872f6ac36702b59cc326296b7f4f14928/pydantic_core-2.50.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4ac996e42a93d80122a23edce44e02c6b9c4913f5c6198a077da31475341a39a", size = 2259563, upload-time = "2026-10-29T16:33:10.107Z" },
1505+ { url = "https://files.pythonhosted.org/packages/26/c2/2f65691d25488ee0f3c17861d079e8ccb7817955522d07cd3c3970d30af5/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:99e1d482138a71902415ba375bb4627679a5fefd3bd07803c3a8f37632f4981d", size = 2290470, upload-time = "2026-10-29T16:33:11.273Z" },
1506+ { url = "https://files.pythonhosted.org/packages/9f/32/aa7c40234cd24536afa0895ff1d26f9f15638004e25242c5193ce053b96b/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:a53d5f11cd6d15569ba3e53f3498077f0d5e633f6ec604d2ea613e895017ebee", size = 2477272, upload-time = "2026-10-29T16:33:12.771Z" },
1507+ { url = "https://files.pythonhosted.org/packages/c4/43/c347c68e040cf93d8487a8f4fc28f2768312834c7d21c301538f416f9955/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23385c4626f8b5a7792aa381ea573d9432cfbf4c21375a0a5eed2a4fb64e0e58", size = 2484352, upload-time = "2026-10-29T16:33:14.485Z" },
1508+ { url = "https://files.pythonhosted.org/packages/b8/8e/72556126c8b6154fe7acaf757b96b2daa37aeffc3a517deb76e272f5ad09/pydantic_core-2.50.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9a8a62281fd9c6a373b3fd3d473685e6838d43341eecde5f47729748e2c11ee3", size = 1491226, upload-time = "2026-10-29T16:33:15.603Z" },
1509+ { url = "https://files.pythonhosted.org/packages/5b/66/004c85511812964a36b3535af022c5c0dfcb760a31e4905b96310e8dd6c8/pydantic_core-2.50.2-cp314-cp314-win32.whl", hash = "sha256:18f008ef58cd30782f81b6e0119e20b7e884edcefbc2d370162ebe033f6e5c99", size = 2113134, upload-time = "2026-10-29T16:33:16.87Z" },
1510+ { url = "https://files.pythonhosted.org/packages/a3/5f/dcd3df20ab2a7279d42a8415fc184e8e77bcf9aa3c9ac3e938c1300a242c/pydantic_core-2.50.2-cp314-cp314-win_amd64.whl", hash = "sha256:b62c89545d21d930c333208fd548e7b4f262228ba3a322fd75c987a75ab11f31", size = 2253407, upload-time = "2026-10-29T16:33:18.47Z" },
1511+ { url = "https://files.pythonhosted.org/packages/4e/fb/0685895f5d199b215011dc4eb8ccea032a64ec23dfa88eeb5acca939c2d2/pydantic_core-2.50.2-cp314-cp314-win_arm64.whl", hash = "sha256:6e731bf5a5e8e25398544d7709e39068ec4230b0177e4db7fa955692fd415ba3", size = 2160456, upload-time = "2026-10-29T16:33:19.782Z" },
1512+ { url = "https://files.pythonhosted.org/packages/84/2f/4c8f67efe62837a508b74cf910904f73029cb211371351c5ceb36d56e85e/pydantic_core-2.50.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:33c6800ab9563adaa0d7893e9cd8f5b972ef1e6a23df70498e05c24f3fcec5e1", size = 2217558, upload-time = "2026-10-29T16:33:21.323Z" },
1513+ { url = "https://files.pythonhosted.org/packages/ce/73/e0ef88cfbcca5409f57751289bc20be63a58af596a36e94b8cd795e59747/pydantic_core-2.50.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f81df206b937d0dca5efb38c25fc99037d82dbcd3740e34c3f81997d2322ac1c", size = 2074335, upload-time = "2026-10-29T16:33:22.658Z" },
1514+ { url = "https://files.pythonhosted.org/packages/7b/cc/47844a3179ebce27064e13619594b3fc1a9b4509d8dec1e3960c5f87f5bb/pydantic_core-2.50.2-cp314-cp314t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:21ea3b00f1e87c66f3f9e92f9f6fec20495ac0e60d7d2e68ba1fc49739d7cf40", size = 2264201, upload-time = "2026-10-29T16:33:24.953Z" },
1515+ { url = "https://files.pythonhosted.org/packages/bc/d1/30e9d3c86d81884ee3199560ddfa2c1a66a8dc0736441748eae43064abbc/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9eff570402175c7d5fff8497d5a9c05580664a79bb66c07b8f205d17f69e580b", size = 2094353, upload-time = "2026-10-29T16:33:26.739Z" },
1516+ { url = "https://files.pythonhosted.org/packages/d4/8f/392a5999480d0bcab4539db0dafa93265152396fd63e87be4d24e13d6d82/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:240fec32a74af0979f7253532859b3fd209ad5eea822416bb010929dba445c3d", size = 2203119, upload-time = "2026-10-29T16:33:28.154Z" },
1517+ { url = "https://files.pythonhosted.org/packages/cb/80/6e4e276a3fde5500224a39150e06a4f134f136165908be69402720718a36/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92acf02c770b9d5c4e9a3e8d64e9d95ae26456a86ebb7b7cf64a97f899e5871b", size = 2392881, upload-time = "2026-10-29T16:33:29.305Z" },
1518+ { url = "https://files.pythonhosted.org/packages/70/d5/5eb56c6317ab4abf33ef9519a50bfac0c96f7f408bad1bc36da6f27d43e4/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1ac5b99bc7b0a68ec64f5f75a6828a7bab5535c8df0cdc07901deb50e0815910", size = 2457290, upload-time = "2026-10-29T16:33:30.587Z" },
1519+ { url = "https://files.pythonhosted.org/packages/7b/9e/f1fe9384bb37e4e728c51c79151808deacf7b2502e245f50b57c85eeb199/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:713d7c33c0e231e3786eb0ec95885814dd65d5f3a09754399b6acbf5e9365f11", size = 2241568, upload-time = "2026-10-29T16:33:32.04Z" },
1520+ { url = "https://files.pythonhosted.org/packages/9d/f1/cffc4f8456c35690bcb65a8f2304afbf2f519766345a28aba553d5a55cc8/pydantic_core-2.50.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:61e13100b30d8d25fc0d1f0297014b32941bf0975a3584bd08f3adebb68d1ead", size = 2257185, upload-time = "2026-10-29T16:33:33.36Z" },
1521+ { url = "https://files.pythonhosted.org/packages/84/34/ccd4df1cc39cb1e42fb7ec86a0dc0a23e8479de04d3012ca6b0bf2279a03/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:523fe6574aea50f456cbc301c1cdddcbf237939f7657f9af99b4f0f71942b59c", size = 2299384, upload-time = "2026-10-29T16:33:34.799Z" },
1522+ { url = "https://files.pythonhosted.org/packages/83/60/404b4b0c416a61867ee642cdc1450bca4d8a9a10e5f5f21e1ecaad05fdc8/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:6c25b8826be85714b27dafe908d3461e1b09e3e31c0279a7029040c832c781ef", size = 2479384, upload-time = "2026-10-29T16:33:36.106Z" },
1523+ { url = "https://files.pythonhosted.org/packages/ca/0d/57c81418a2fdfde0578c71396df773965adebd4421d9985a0f8dcbe9ec4d/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a59db03b80a156a6fbeae41096866d1cb89cdf31031ba44c768a804538880636", size = 2504616, upload-time = "2026-10-29T16:33:37.423Z" },
1524+ { url = "https://files.pythonhosted.org/packages/08/66/dec78e28a604f3f4985f5a795d4da3e5fef78b0921aeb7f614ed7c780a32/pydantic_core-2.50.2-cp314-cp314t-win32.whl", hash = "sha256:0d090ae76d5b09c003600c7779b3adcf3d1b49268f616c5c51d844e54c8e318d", size = 2093179, upload-time = "2026-10-29T16:33:38.705Z" },
1525+ { url = "https://files.pythonhosted.org/packages/d2/ea/79d9ee60648463804145d44b08f1e7e2474427d9704e3331d814483908b3/pydantic_core-2.50.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9b01a092955c1c7530196ad526755cff4f2d11f37ca9a6fc9ebe37ca49a48977", size = 2220575, upload-time = "2026-10-29T16:33:39.929Z" },
1526+ { url = "https://files.pythonhosted.org/packages/eb/01/aa6a7bd017c3228a41942da4fd788b86fb4aad9677212a4737a0c157fe74/pydantic_core-2.50.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4ea01f30e119f1ec7a2034e85c40f491d469896aef02cd6436fd9292fe1be3fb", size = 2160866, upload-time = "2026-10-29T16:33:41.237Z" },
1527+ { url = "https://files.pythonhosted.org/packages/4b/7f/ec2974015b4be5e50266ce732b8942d3d0b3bff751ace8900999c6577bce/pydantic_core-2.50.2-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:a3317630e2791f56071b82f89bb71624146125e4311e6643f708c6ba744b5ac5", size = 2230936, upload-time = "2026-10-29T16:33:43.656Z" },
1528+ { url = "https://files.pythonhosted.org/packages/ae/ff/af99a09eddc348c1d0744c0df28dfcd4839ca497067f0fa2168208b41b1b/pydantic_core-2.50.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f73930dacad5e611fe019e18df6f53c30629b1843d038d303b8dec86a32493bd", size = 2100409, upload-time = "2026-10-29T16:33:44.946Z" },
1529+ { url = "https://files.pythonhosted.org/packages/84/75/8db3c46f02d6005277e5c2ff5933c85e7e1eb8798aa334c676c828650c3f/pydantic_core-2.50.2-cp315-cp315-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:e2e6240a0a5ad847017b3d65187877fdb7a11d5a58d43c30736f9f523478913b", size = 2259728, upload-time = "2026-10-29T16:33:46.461Z" },
1530+ { url = "https://files.pythonhosted.org/packages/ee/9d/98c8d524921ca24e5c8a81a4b8f12209ac6ab05d0dd58b374e357da0ffc5/pydantic_core-2.50.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fdf5b52ed22f1404c2433d0b46df2d86d9befb742d0b9b4042a2325c43cdb97", size = 2121771, upload-time = "2026-10-29T16:33:47.952Z" },
1531+ { url = "https://files.pythonhosted.org/packages/c4/ea/68c3b1fa919946012c940dea88e94e33e7dde46abe3e3d57053793c9bab8/pydantic_core-2.50.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:4e9392ddf67fe97ab9889f796d468c9908a8754b8b6c7520eb03a65012b9a1e7", size = 2196328, upload-time = "2026-10-29T16:33:49.131Z" },
1532+ { url = "https://files.pythonhosted.org/packages/eb/9b/cfb6312a5b65b02eab6bc00fb0d258d0e2e49468dab29b430e7cb49b2ceb/pydantic_core-2.50.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:48e944091b7bff233920bed8a60b0ed6c96c73d3c5836bf51c4b9a4bfb58e4ca", size = 2380152, upload-time = "2026-10-29T16:33:50.503Z" },
1533+ { url = "https://files.pythonhosted.org/packages/41/5a/7fcced1d84ea294200abfa93e488f36d00732101d17bf5e1f1389e66de26/pydantic_core-2.50.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:91c92856b846aa690ea6239ffd6c5003c5e3e296a69095570bf4efee5bfa0bdc", size = 2462299, upload-time = "2026-10-29T16:33:51.884Z" },
1534+ { url = "https://files.pythonhosted.org/packages/e6/d0/dd0e355694094f0045922f4da60ac9bc7a314babc494e46089fb00640fd3/pydantic_core-2.50.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5299296ff5cafcb69851fd652de8f9268a0bb036bcbedbfcaa94cf743baf3986", size = 2249999, upload-time = "2026-10-29T16:33:53.333Z" },
1535+ { url = "https://files.pythonhosted.org/packages/3a/79/f5f4b36434604d22aacb7152667f87a470e68847c89b136c68a278cf409d/pydantic_core-2.50.2-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d3d049b557875f9d15230eec8a731749a7a873d8ed6a051903b05bdf46e0e65f", size = 2262745, upload-time = "2026-10-29T16:33:54.79Z" },
1536+ { url = "https://files.pythonhosted.org/packages/14/21/a4c47a286745857758a4bb8d4d5e260c736a7bd6fab5da618f5711d1a3f1/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b0b25d647036972c2951059046cb498f72b7af5a2def27bf8e5f44c3067ab7dd", size = 2293663, upload-time = "2026-10-29T16:33:56.132Z" },
1537+ { url = "https://files.pythonhosted.org/packages/0d/db/25330e609fd219ce16d0a089af1b4b62095f01595dbe6972f7a7dfc313b9/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:8835566b3e94026da3ce2249f9ee35c9d2ebe4cd131f6a39bfc82708d3010a46", size = 2473247, upload-time = "2026-10-29T16:33:57.607Z" },
1538+ { url = "https://files.pythonhosted.org/packages/53/16/704ca88b82156dc485da2dfb11d5d52a10b9ef15a294639c0aa5ebd1643e/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:3fbcd402e4b607d4b8de5d4591215f31f042af03f9d09e58f693a6221e32833f", size = 2490536, upload-time = "2026-10-29T16:33:59.297Z" },
1539+ { url = "https://files.pythonhosted.org/packages/50/34/1fdd0eebee9f8b6c4ec3c2490f49dc59a652c8c5a7fa0cbcaada0d91cda9/pydantic_core-2.50.2-cp315-cp315-win32.whl", hash = "sha256:8431ecb131489cbdaf6f51f7ade47475f73aff4f1912552f75ba19a6cfe233a8", size = 2117204, upload-time = "2026-10-29T16:34:00.514Z" },
1540+ { url = "https://files.pythonhosted.org/packages/24/6b/702adc91486fb96ba55675d58ee15ddc38fb48553fb35e26fd005e303206/pydantic_core-2.50.2-cp315-cp315-win_amd64.whl", hash = "sha256:04cf672960b8c4fc28bb159c8cdd101fbd311c520237a5c0360448b851adfb41", size = 2261064, upload-time = "2026-10-29T16:34:01.773Z" },
1541+ { url = "https://files.pythonhosted.org/packages/36/0d/7f117432b8614526aa0d96fadd2b7fb7393156ddf2caf26a6fe606e6cfb6/pydantic_core-2.50.2-cp315-cp315-win_arm64.whl", hash = "sha256:36917e29c0b815d6b940bc91560fcf453b5b85d55c579796373f0ea11d1c111a", size = 2161783, upload-time = "2026-10-29T16:34:02.966Z" },
1542+ { url = "https://files.pythonhosted.org/packages/45/1c/2ba7a34fe8da4c83ec200fb9f85ab4d54b961be08367922743623f987849/pydantic_core-2.50.2-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:bd1fe69056a19c377a6c6de2c1240fb8676ba0512a969965efb486510c5e0f1f", size = 2223184, upload-time = "2026-10-29T16:34:04.214Z" },
1543+ { url = "https://files.pythonhosted.org/packages/47/01/f71a8da1f53a5be1787d2a97ba6fd9adba115305c7f15ea0bf733ed8336c/pydantic_core-2.50.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9b2383933b1dd7a8c93e02f99ebfd305f1286057172483b8e6898bed3fef69d3", size = 2078174, upload-time = "2026-10-29T16:34:05.426Z" },
1544+ { url = "https://files.pythonhosted.org/packages/f6/0a/847e1f5427372692f6eb5c38261fed6b8cedf379e37d9f6134d48b9c8ee9/pydantic_core-2.50.2-cp315-cp315t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:a1b4d60bad57d6e0e7cd30cc4d1dc646d258dd6dfb7935ce76c7dd72dd1e98b0", size = 2268813, upload-time = "2026-10-29T16:34:06.733Z" },
1545+ { url = "https://files.pythonhosted.org/packages/f5/bb/12966dc82b5e9c347981658b52e24349b6242902b644725c00ee7bdf4267/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:93bedc68c4138088ea3c042189c01d2945f530f0dcc36e1ea02df8418bd4f1cd", size = 2098331, upload-time = "2026-10-29T16:34:08.69Z" },
1546+ { url = "https://files.pythonhosted.org/packages/d9/fe/340416b7ebe664524e7ada16ad2e3e069888b168010d75bda6a502bf2bf8/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:d684ff87d00de2f6abcbbfa9b0fdce8b513dd799a900809dcfa7eb62ad69832a", size = 2197432, upload-time = "2026-10-29T16:34:10.091Z" },
1547+ { url = "https://files.pythonhosted.org/packages/c9/4d/425162c3d88af0d190c4dc4f940402cc6dd44d934359a37f50da6b7e4672/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bb6d49f00e2babd40af4889d6b9f4bf50879f522dd4e2e22cb705cac5e0c5e8e", size = 2399300, upload-time = "2026-10-29T16:34:11.462Z" },
1548+ { url = "https://files.pythonhosted.org/packages/2b/fe/2882bddf2e2038c8f41fa69f16ddeb6eeb1c002cc0cc0eb2157f73bb34e9/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6d47b2d0e00e030e0ebdb08115a37da7294e7a6d196d134a77ed7ee6c142284b", size = 2462672, upload-time = "2026-10-29T16:34:12.79Z" },
1549+ { url = "https://files.pythonhosted.org/packages/6e/ac/d7e6bfc6a95573647d1a699e19228dd99914ae6aab79a2bec293a038e980/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:95838be125133fa314753f8cc5168f7c41e3a9dd5eef5383dbbbeed511bd4853", size = 2246293, upload-time = "2026-10-29T16:34:14.065Z" },
1550+ { url = "https://files.pythonhosted.org/packages/74/64/97473ccaeb4ae5eb338162bd8479af121484e67f7078499730e3ee0a5592/pydantic_core-2.50.2-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:2d4a043c820363359e40ac96270e1915e5376e116be1f59cee0701775de3e376", size = 2259507, upload-time = "2026-10-29T16:34:15.391Z" },
1551+ { url = "https://files.pythonhosted.org/packages/48/15/78a9b7a6a0d0679ce1e46cdd38ff95afd18dd2d6a01ed87c80a01bc003f7/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:57bccd3e1128736c792094da1feac6da46ec3feb1957ef649a9b3e4f8de6fa04", size = 2303550, upload-time = "2026-10-29T16:34:17.162Z" },
1552+ { url = "https://files.pythonhosted.org/packages/39/ba/b697173f82e99cdcf5ec6654b2edb66a00f2f92ef9d9a168861fca3fcded/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:24120c644322a35b5eb765416ac8442214c45689e255f5fc6e761e7e7c743c8b", size = 2474919, upload-time = "2026-10-29T16:34:18.841Z" },
1553+ { url = "https://files.pythonhosted.org/packages/71/10/986be76723784ae87f9664658fae39df340eaa21681cea95b6339d2e1b3e/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:b1adf2f37521b578c438f6ec61d1c1d0c20b076863ddd5d8b6f3ab658857db3f", size = 2509095, upload-time = "2026-10-29T16:34:20.16Z" },
1554+ { url = "https://files.pythonhosted.org/packages/5f/0b/38dbd38c67de7642f87348da70122b89ca4c5fe8227cfdd5cc00c1cdb454/pydantic_core-2.50.2-cp315-cp315t-win32.whl", hash = "sha256:d79aaa468193c1bed6b0560d3c5c9f9e369e3efb0b128b38a2537e2d51d42773", size = 2096972, upload-time = "2026-10-29T16:34:21.715Z" },
1555+ { url = "https://files.pythonhosted.org/packages/7a/df/154baf8413b08d6daf2f5dec211c303ec6bed50c032717f354f8dd85c994/pydantic_core-2.50.2-cp315-cp315t-win_amd64.whl", hash = "sha256:cc488daac19e6f5292180fac5e0ef7efffabeef4e096af9a17a75a7d3d6781f6", size = 2226659, upload-time = "2026-10-29T16:34:23.177Z" },
1556+ { url = "https://files.pythonhosted.org/packages/b5/17/eea7a1e7d98a79ef11b6d31748b70c58f8723f87d7ee42edbb6a101f6245/pydantic_core-2.50.2-cp315-cp315t-win_arm64.whl", hash = "sha256:91315aea7adb51821d4a3378ede953bfdd76e3bf71551de4a82d7638cef7dc02", size = 2166387, upload-time = "2026-10-29T16:34:24.433Z" },
1557+ { url = "https://files.pythonhosted.org/packages/64/4d/23560b11366b547e3e68a3b8f9c02557cee707effa54fd39dbb3c4fff1fa/pydantic_core-2.50.2-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:d18d56c1ec63b304f4bb3c7db7e0d311e61cca1b1c944590a4b13ac39b2ca92b", size = 2204868, upload-time = "2026-10-29T16:34:34.745Z" },
1558+ { url = "https://files.pythonhosted.org/packages/06/58/b9ddfaad189143d6568627166a730b6624ca90bd9a9a4af4854df88d444b/pydantic_core-2.50.2-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:41ed8abeffda6a880207fdc0cd1343a448cec3cc6b38b53f730c0745f745292d", size = 2049162, upload-time = "2026-10-29T16:34:36.186Z" },
1559+ { url = "https://files.pythonhosted.org/packages/8b/20/99d259bf7835b2f13c6d0b3eb7bcf669fb6349986736fa7a8308a9e620b0/pydantic_core-2.50.2-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b7abcfaf66c1dc14c5ed928c349614f223c972a8b021b4be9548dbea6c817371", size = 2112135, upload-time = "2026-10-29T16:34:37.777Z" },
1560+ { url = "https://files.pythonhosted.org/packages/9b/ca/652c8fe599abacafd852479059d209e608596ad3de82f1bff22e9f0f02fe/pydantic_core-2.50.2-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7460abce637f90e37f8c443559fbd687c38884d05cb69ef265e634945f4324", size = 2270339, upload-time = "2026-10-29T16:34:39.039Z" },
1561+]
1562+
1563+[[package]]
1564+name = "pydantic-settings"
1565+version = "2.16.2"
1566+source = { registry = "https://pypi.org/simple" }
1567+dependencies = [
1568+ { name = "pydantic" },
1569+ { name = "python-dotenv" },
1570+ { name = "typing-extensions" },
1571+ { name = "typing-inspection" },
1572+]
1573+sdist = { url = "https://files.pythonhosted.org/packages/d0/31/21d21e0e38a8f5f7a7f0cde22820117b6d17d6bc36f348129527f915d7f4/pydantic_settings-2.16.2.tar.gz", hash = "sha256:3dc08ffabed2bbd5dc2dd55ead7b78b4e4c5cd776aecd04310db5f1224caaf46", size = 309225, upload-time = "2026-10-28T11:14:28.066Z" }
1574+wheels = [
1575+ { url = "https://files.pythonhosted.org/packages/39/88/4131c51ff87b7cd7f82a1194d0c0a2b7ca68c72d9da04c3b7e69ab91a79f/pydantic_settings-2.16.2-py3-none-any.whl", hash = "sha256:8d523e5c129b097686bde019bd6109bff53f4c8c3f63f1ddf9933ffa42e5ab4f", size = 81994, upload-time = "2026-10-28T11:14:26.716Z" },
1576+]
1577+
1578+[[package]]
1579+name = "pygments"
1580+version = "2.21.0"
1581+source = { registry = "https://pypi.org/simple" }
1582+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
1583+wheels = [
1584+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
1585+]
1586+
1587+[[package]]
1588+name = "pyluach"
1589+version = "2.3.0"
1590+source = { registry = "https://pypi.org/simple" }
1591+sdist = { url = "https://files.pythonhosted.org/packages/11/11/42568c1568a75f8803c59f26d29af01a0890352b7a8e03d41ecda8bfb5dd/pyluach-2.3.0.tar.gz", hash = "sha256:ec6e30669d1df50c9ca160486da44a8195bb4c7a5d3d533990d0c5b03accd281", size = 26910, upload-time = "2025-09-09T20:24:39.651Z" }
1592+wheels = [
1593+ { url = "https://files.pythonhosted.org/packages/8a/c8/f96208ade3ca4c23b372497d0788bcf0f2e0ff4310e5ee693366bc33fdf0/pyluach-2.3.0-py3-none-any.whl", hash = "sha256:4497b731aef59508b079dbf5f00bc5bf4329ac45090a6cd37b5a83756f0e69ab", size = 25914, upload-time = "2025-09-09T20:24:37.831Z" },
1594+]
1595+
1596+[[package]]
1597+name = "pyotp"
1598+version = "2.10.0"
1599+source = { registry = "https://pypi.org/simple" }
1600+sdist = { url = "https://files.pythonhosted.org/packages/2d/c6/c5d96a86fd0bf6fa1bbb5c5c341ff3208638b692727a683c8289068d9a11/pyotp-2.10.0.tar.gz", hash = "sha256:d01e9703443616b03c57c700b5cbffd56a1f929c1b0f8f03131bc78c1fca9d3f", size = 18625, upload-time = "2026-06-14T03:48:49.221Z" }
1601+wheels = [
1602+ { url = "https://files.pythonhosted.org/packages/65/33/7b83bde70eddaaaaef487751a9c3a5cc0c0be54620ded0e120ebdc401ff9/pyotp-2.10.0-py3-none-any.whl", hash = "sha256:1df2f6a1bcc3bb0716172a5215ddc2f8c7c7fd26a13df9927d52e1746934836c", size = 13768, upload-time = "2026-06-14T03:48:47.831Z" },
1603+]
1604+
1605+[[package]]
1606+name = "pyparsing"
1607+version = "3.3.3"
1608+source = { registry = "https://pypi.org/simple" }
1609+sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" }
1610+wheels = [
1611+ { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" },
1612+]
1613+
1614+[[package]]
1615+name = "pytest"
1616+version = "9.2.0"
1617+source = { registry = "https://pypi.org/simple" }
1618+dependencies = [
1619+ { name = "colorama", marker = "sys_platform == 'win32'" },
1620+ { name = "iniconfig" },
1621+ { name = "packaging" },
1622+ { name = "pluggy" },
1623+ { name = "pygments" },
1624+]
1625+sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
1626+wheels = [
1627+ { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
1628+]
1629+
1630+[[package]]
1631+name = "pytest-asyncio"
1632+version = "1.4.1.post1"
1633+source = { registry = "https://pypi.org/simple" }
1634+dependencies = [
1635+ { name = "pytest" },
1636+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
1637+]
1638+sdist = { url = "https://files.pythonhosted.org/packages/73/7f/c60a4338420b33453d4f814ac0c930abd399f162b9bdd21af4b9e363037f/pytest_asyncio-1.4.1.post1.tar.gz", hash = "sha256:f582d9ea4a1c082195abe8bd29a1fe106aeb1cd435e3dad2d3bc2749935c36a2", size = 145360, upload-time = "2026-11-01T18:26:03.732Z" }
1639+wheels = [
1640+ { url = "https://files.pythonhosted.org/packages/e3/de/adbc7578959cfab07e5e7c05cf6d54224a51e7667ea2c07458b6bc571e75/pytest_asyncio-1.4.1.post1-py3-none-any.whl", hash = "sha256:0755e488cc0a2b9bd2eab7292cc6390d4cbb8390f1020745647bae5a708e0165", size = 17140, upload-time = "2026-11-01T18:26:02.168Z" },
1641+]
1642+
1643+[[package]]
1644+name = "python-dateutil"
1645+version = "4.0.0"
1646+source = { registry = "https://pypi.org/simple" }
1647+dependencies = [
1648+ { name = "tzdata" },
1649+]
1650+sdist = { url = "https://files.pythonhosted.org/packages/18/39/b3927f3616f6223832f9b760e4208e8a7c7e1dea2b4d9ad7d32a5d214c3a/python_dateutil-4.0.0.tar.gz", hash = "sha256:d7295ff14fdc0f92f662100bfb6c551d160ca090f2939f1ec5b9794265c03f06", size = 250419, upload-time = "2026-11-01T07:02:42.426Z" }
1651+wheels = [
1652+ { url = "https://files.pythonhosted.org/packages/2f/a9/52b89563d154433d79b51ed1eb4842bf7334cb2696891a7fe135618853d7/python_dateutil-4.0.0-py3-none-any.whl", hash = "sha256:a5e246f79eb80475e691bf93862cead98decd8a498cf53ba0516fcdc0f50dc1c", size = 92853, upload-time = "2026-11-01T07:02:40.797Z" },
1653+]
1654+
1655+[[package]]
1656+name = "python-dotenv"
1657+version = "1.2.4"
1658+source = { registry = "https://pypi.org/simple" }
1659+sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
1660+wheels = [
1661+ { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
1662+]
1663+
1664+[[package]]
1665+name = "python-multipart"
1666+version = "0.0.33"
1667+source = { registry = "https://pypi.org/simple" }
1668+sdist = { url = "https://files.pythonhosted.org/packages/9f/b7/15b905f6142a9a8a68f66900788da1a32e4d4254452783b37ad1eccb0506/python_multipart-0.0.33.tar.gz", hash = "sha256:b8112424a6abd162ec08d390565dd113ad699f2fe6fef3c987b8bbcbfe9e390d", size = 50499, upload-time = "2026-10-31T14:23:47.461Z" }
1669+wheels = [
1670+ { url = "https://files.pythonhosted.org/packages/dd/1b/6b1b4164ec734cac911460ab599bfbe46afedd42c5c9627fe4235942c314/python_multipart-0.0.33-py3-none-any.whl", hash = "sha256:8f16c07b36add9da6b2ee87d3efcaf0a45a6de9c3d0a5dd94e9ccf7675a7bc45", size = 32253, upload-time = "2026-10-31T14:23:46.33Z" },
1671+]
1672+
1673+[[package]]
1674+name = "pyyaml"
1675+version = "6.0.3"
1676+source = { registry = "https://pypi.org/simple" }
1677+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
1678+wheels = [
1679+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
1680+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
1681+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
1682+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
1683+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
1684+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
1685+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
1686+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
1687+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
1688+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
1689+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
1690+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
1691+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
1692+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
1693+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
1694+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
1695+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
1696+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
1697+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
1698+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
1699+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
1700+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
1701+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
1702+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
1703+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
1704+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
1705+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
1706+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
1707+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
1708+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
1709+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
1710+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
1711+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
1712+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
1713+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
1714+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
1715+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
1716+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
1717+]
1718+
1719+[[package]]
1720+name = "qrcode"
1721+version = "8.2"
1722+source = { registry = "https://pypi.org/simple" }
1723+dependencies = [
1724+ { name = "colorama", marker = "sys_platform == 'win32'" },
1725+]
1726+sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
1727+wheels = [
1728+ { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
1729+]
1730+
1731+[[package]]
1732+name = "requests"
1733+version = "2.34.2"
1734+source = { registry = "https://pypi.org/simple" }
1735+dependencies = [
1736+ { name = "certifi" },
1737+ { name = "charset-normalizer" },
1738+ { name = "idna" },
1739+ { name = "urllib3" },
1740+]
1741+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
1742+wheels = [
1743+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
1744+]
1745+
1746+[[package]]
1747+name = "rich"
1748+version = "15.0.0"
1749+source = { registry = "https://pypi.org/simple" }
1750+dependencies = [
1751+ { name = "markdown-it-py" },
1752+ { name = "pygments" },
1753+]
1754+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
1755+wheels = [
1756+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
1757+]
1758+
1759+[[package]]
1760+name = "ruff"
1761+version = "0.17.3"
1762+source = { registry = "https://pypi.org/simple" }
1763+sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
1764+wheels = [
1765+ { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
1766+ { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
1767+ { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
1768+ { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
1769+ { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
1770+ { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
1771+ { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
1772+ { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
1773+ { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
1774+ { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
1775+ { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
1776+ { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
1777+ { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
1778+ { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
1779+ { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
1780+ { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
1781+ { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
1782+]
1783+
1784+[[package]]
1785+name = "shellingham"
1786+version = "1.5.4"
1787+source = { registry = "https://pypi.org/simple" }
1788+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
1789+wheels = [
1790+ { 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+]
1792+
1793+[[package]]
1794+name = "sortedcontainers"
1795+version = "2.4.0"
1796+source = { registry = "https://pypi.org/simple" }
1797+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
1798+wheels = [
1799+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
1800+]
1801+
1802+[[package]]
1803+name = "starlette"
1804+version = "1.10.0"
1805+source = { registry = "https://pypi.org/simple" }
1806+dependencies = [
1807+ { name = "anyio" },
1808+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
1809+]
1810+sdist = { url = "https://files.pythonhosted.org/packages/48/bb/3d8757f21e9a53d9035c9eb039a777047139a16bfdbedfc7a58241c3d914/starlette-1.10.0.tar.gz", hash = "sha256:b8399098d4c1e3522734f7235dc79b1fc967f96cf139c81e0fa30479e0685f9b", size = 2734356, upload-time = "2026-10-24T15:04:12.342Z" }
1811+wheels = [
1812+ { url = "https://files.pythonhosted.org/packages/88/2e/947cfe02def9371c07cd5bf5b6b61c829e9aecb9904db02a66cfafbc1eec/starlette-1.10.0-py3-none-any.whl", hash = "sha256:478c0ab3b4ced6e82719849c118fa406f0278a3499c1a898cd15cd31bdc38ce4", size = 80571, upload-time = "2026-10-24T15:04:10.371Z" },
1813+]
1814+
1815+[[package]]
1816+name = "structlog"
1817+version = "26.1.0"
1818+source = { registry = "https://pypi.org/simple" }
1819+sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" }
1820+wheels = [
1821+ { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" },
1822+]
1823+
1824+[[package]]
1825+name = "tomli"
1826+version = "2.5.0"
1827+source = { registry = "https://pypi.org/simple" }
1828+sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" }
1829+wheels = [
1830+ { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" },
1831+ { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" },
1832+ { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" },
1833+ { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" },
1834+ { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" },
1835+ { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" },
1836+ { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" },
1837+ { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" },
1838+ { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" },
1839+ { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" },
1840+ { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" },
1841+ { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" },
1842+ { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" },
1843+ { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" },
1844+ { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" },
1845+ { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" },
1846+ { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" },
1847+ { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" },
1848+ { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" },
1849+ { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" },
1850+ { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" },
1851+ { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" },
1852+ { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" },
1853+ { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" },
1854+ { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" },
1855+ { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" },
1856+ { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" },
1857+ { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" },
1858+ { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" },
1859+ { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" },
1860+ { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" },
1861+ { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" },
1862+ { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" },
1863+ { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" },
1864+ { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" },
1865+ { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" },
1866+ { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" },
1867+ { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" },
1868+ { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" },
1869+ { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" },
1870+ { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" },
1871+ { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" },
1872+ { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" },
1873+ { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" },
1874+ { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" },
1875+ { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" },
1876+ { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" },
1877+ { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" },
1878+ { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" },
1879+ { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" },
1880+ { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" },
1881+ { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" },
1882+ { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" },
1883+ { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" },
1884+ { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" },
1885+]
1886+
1887+[[package]]
1888+name = "tomli-w"
1889+version = "1.2.0"
1890+source = { registry = "https://pypi.org/simple" }
1891+sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
1892+wheels = [
1893+ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
1894+]
1895+
1896+[[package]]
1897+name = "toolz"
1898+version = "1.2.0"
1899+source = { registry = "https://pypi.org/simple" }
1900+sdist = { url = "https://files.pythonhosted.org/packages/31/6f/ae20c212a07aa2d156c787383d8088a5e045ee39628661edb190c97e1659/toolz-1.2.0.tar.gz", hash = "sha256:9667a038e9d6ecba37995e26cb2f59ec6420b6ad8dd9677de59db9b956b08490", size = 55442, upload-time = "2026-10-07T04:16:25.639Z" }
1901+wheels = [
1902+ { 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+]
1904+
1905+[[package]]
1906+name = "typer"
1907+version = "0.27.3"
1908+source = { registry = "https://pypi.org/simple" }
1909+dependencies = [
1910+ { name = "annotated-doc" },
1911+ { name = "colorama", marker = "sys_platform == 'win32'" },
1912+ { name = "rich" },
1913+ { name = "shellingham" },
1914+]
1915+sdist = { url = "https://files.pythonhosted.org/packages/03/51/d33db42cc72ffd8c30777547b42d01f0cbf9d95a770457698d0174b3ed71/typer-0.27.3.tar.gz", hash = "sha256:d0396f770a560ab1b0a8504e13b5f254b728cedb05c61cf0359e944e50ce8901", size = 205303, upload-time = "2026-10-06T17:24:16.61Z" }
1916+wheels = [
1917+ { url = "https://files.pythonhosted.org/packages/07/ea/2e31b67051e91a133189e9c000c222502ddc6969856416de0d095de4c0b0/typer-0.27.3-py3-none-any.whl", hash = "sha256:e50022f28b82a86313e54501317a1db64bf8f8d036ff8cfe5ca7e47675454aff", size = 123312, upload-time = "2026-10-06T17:24:15.054Z" },
1918+]
1919+
1920+[[package]]
1921+name = "typing-extensions"
1922+version = "4.16.0"
1923+source = { registry = "https://pypi.org/simple" }
1924+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
1925+wheels = [
1926+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
1927+]
1928+
1929+[[package]]
1930+name = "typing-inspection"
1931+version = "0.4.4"
1932+source = { registry = "https://pypi.org/simple" }
1933+dependencies = [
1934+ { name = "typing-extensions" },
1935+]
1936+sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
1937+wheels = [
1938+ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
1939+]
1940+
1941+[[package]]
1942+name = "tzdata"
1943+version = "2026.6"
1944+source = { registry = "https://pypi.org/simple" }
1945+sdist = { url = "https://files.pythonhosted.org/packages/7a/29/6a1391d31cd00ff9e927215a224a880e1e84e826b64ba80a499e51c074d8/tzdata-2026.6.tar.gz", hash = "sha256:85d2cd9c09f81f0bb29db27759fc83af4f1e620efb814f8c5bf8b4cc4feece6d", size = 201017, upload-time = "2026-10-28T09:57:09.137Z" }
1946+wheels = [
1947+ { url = "https://files.pythonhosted.org/packages/0a/66/9a06c2227fedfd4919f0eaaa8b94377d9730882a7e4f95823c9a331ab5e1/tzdata-2026.6-py2.py3-none-any.whl", hash = "sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec", size = 348210, upload-time = "2026-10-28T09:57:07.276Z" },
1948+]
1949+
1950+[[package]]
1951+name = "urllib3"
1952+version = "2.8.0"
1953+source = { registry = "https://pypi.org/simple" }
1954+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
1955+wheels = [
1956+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
1957+]
1958+
1959+[[package]]
1960+name = "uvicorn"
1961+version = "0.54.0"
1962+source = { registry = "https://pypi.org/simple" }
1963+dependencies = [
1964+ { name = "click" },
1965+ { name = "h11" },
1966+]
1967+sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
1968+wheels = [
1969+ { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
1970+]
1971+
1972+[package.optional-dependencies]
1973+standard = [
1974+ { name = "httptools" },
1975+ { name = "python-dotenv" },
1976+ { name = "pyyaml" },
1977+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
1978+ { name = "watchfiles" },
1979+ { name = "websockets" },
1980+]
1981+
1982+[[package]]
1983+name = "uvloop"
1984+version = "0.23.0"
1985+source = { registry = "https://pypi.org/simple" }
1986+sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
1987+wheels = [
1988+ { url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
1989+ { url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
1990+ { url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
1991+ { url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
1992+ { url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
1993+ { url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
1994+ { url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
1995+ { url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
1996+ { url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
1997+ { url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
1998+ { url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
1999+ { url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
2000+ { url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
2001+ { url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
2002+ { url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
2003+ { url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
2004+ { url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
2005+ { url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
2006+ { url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
2007+ { url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
2008+ { url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
2009+ { url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
2010+ { url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
2011+ { url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
2012+ { url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
2013+ { url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
2014+ { url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
2015+ { url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
2016+ { url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
2017+ { url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
2018+ { url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
2019+ { url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
2020+ { url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
2021+ { url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
2022+ { url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
2023+ { url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
2024+]
2025+
2026+[[package]]
2027+name = "watchfiles"
2028+version = "1.3.0"
2029+source = { registry = "https://pypi.org/simple" }
2030+dependencies = [
2031+ { name = "anyio" },
2032+]
2033+sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
2034+wheels = [
2035+ { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
2036+ { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
2037+ { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
2038+ { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
2039+ { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
2040+ { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
2041+ { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
2042+ { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
2043+ { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
2044+ { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
2045+ { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
2046+ { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
2047+ { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
2048+ { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
2049+ { url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
2050+ { url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
2051+ { url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
2052+ { url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
2053+ { url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
2054+ { url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
2055+ { url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
2056+ { url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
2057+ { url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
2058+ { url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
2059+ { url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
2060+ { url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
2061+ { url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
2062+ { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
2063+]
2064+
2065+[[package]]
2066+name = "websockets"
2067+version = "17.4"
2068+source = { registry = "https://pypi.org/simple" }
2069+sdist = { url = "https://files.pythonhosted.org/packages/75/79/590c5f0cc7d1661195aeefccb0e342b79f4c77d368d526d028821a390332/websockets-17.4.tar.gz", hash = "sha256:09d3939b70df0c8ad2da145ebd50978bccaee1d565e838b0e7921a34d38571eb", size = 191391, upload-time = "2026-10-30T15:10:35.873Z" }
2070+wheels = [
2071+ { url = "https://files.pythonhosted.org/packages/af/71/b020221345ea6c012dcf7496827a48a744b58696470b2b07fef95a32905a/websockets-17.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3d512e3a6c528c42ce220ed9c3caff83c6fdcd98a1b98249f08ccfc66ad111b1", size = 218779, upload-time = "2026-10-30T15:07:11.522Z" },
2072+ { url = "https://files.pythonhosted.org/packages/8e/84/1c372ae7c032021a84e5c9e9b76b0dddefb354e9b4af783a97efd4d80ab3/websockets-17.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7390e060f830b3d132f484b21ac2783a2d227b682ef62bb8146e1c1b4ca0e45a", size = 216457, upload-time = "2026-10-30T15:07:13Z" },
2073+ { url = "https://files.pythonhosted.org/packages/74/b6/cff249107473f149c6c916a49cba967379fa63b0a7d2dce6896d6848f0a3/websockets-17.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bcad49104711f88b2b7c3ba166de172857d837248205e541b044a552558fecc3", size = 216726, upload-time = "2026-10-30T15:07:14.468Z" },
2074+ { url = "https://files.pythonhosted.org/packages/77/5d/aff8ecbf1c55c4d96f6dacc46a52102895f58b85bb284c8d325aaf31606a/websockets-17.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf99debb17227a50620aee16647bb33a0387f5fa5418d628666c0f05745e0d2a", size = 226043, upload-time = "2026-10-30T15:07:15.937Z" },
2075+ { url = "https://files.pythonhosted.org/packages/cb/18/6229d6583031ca81d092a0cd887a13a57b402fb555acc323d39b45967eb7/websockets-17.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ae1429ee9e52b139167ed0169757806a27ed7307e942e8c62037a9184e9843b", size = 226321, upload-time = "2026-10-30T15:07:17.563Z" },
2076+ { url = "https://files.pythonhosted.org/packages/b8/61/7aba018fd53873e792a25afe1f4c005518133477b727d7455f2f7550c425/websockets-17.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51adfc30f467077293a6695d8149bcef501c0728386f3a491054f835dadb60b2", size = 227559, upload-time = "2026-10-30T15:07:18.998Z" },
2077+ { url = "https://files.pythonhosted.org/packages/fb/66/37b207df9cdacaa017aa4487f3a9f2fcd668f6f7cfbafdac5b95ea258ee0/websockets-17.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c564d1e6531611bf87078c28780456ecc75459dcca189629fff2fe7c020d8e67", size = 230386, upload-time = "2026-10-30T15:07:20.431Z" },
2078+ { url = "https://files.pythonhosted.org/packages/ad/f6/87507e11fbae53817a435945273953b5b020589e309fac4fd60527ee5999/websockets-17.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c792e6cce24d08f92199d907785c9e64f74c357deeed3dbaf7d699ed5930b64", size = 228193, upload-time = "2026-10-30T15:07:22.112Z" },
2079+ { url = "https://files.pythonhosted.org/packages/2f/3d/90a04f0aa989b68fbd2efcd38237420afe2abe5a1cab0b5d7c2aa4f0dbb6/websockets-17.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c44776f82b04e623eb9e1b81fcd17ac6e9808ae1ba5cdf8edce1f7c869a95a9b", size = 226952, upload-time = "2026-10-30T15:07:23.643Z" },
2080+ { url = "https://files.pythonhosted.org/packages/ad/3e/50952af22b8e3e2d03f7e83f0e335851c6b484c3fd88586626fb1c2a34b3/websockets-17.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d2636a5b61c69e32db0e74b57073dd4a66f1ad195e9398c5f01bba41090deea", size = 224183, upload-time = "2026-10-30T15:07:25.507Z" },
2081+ { url = "https://files.pythonhosted.org/packages/c4/73/2a4a5d9c6bca1127f24af7bf08f406a69052d7f341bc81f08d9e8eb446b4/websockets-17.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cfbbba4597f19ad717f79fd3f2a67ae7efae647bd6a61e14554e1b5ad242a5bf", size = 226991, upload-time = "2026-10-30T15:07:27.355Z" },
2082+ { url = "https://files.pythonhosted.org/packages/68/de/90d96a579839bfe541ef214315df81d2bd519d952847c303328518ffdd02/websockets-17.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c7b996c02de5e032cb63dc65482b73693b7ecaa586654126be2749ddea239c40", size = 225600, upload-time = "2026-10-30T15:07:28.973Z" },
2083+ { url = "https://files.pythonhosted.org/packages/91/17/12c15ed446d842e40918cd16e79b56ee4422d6fbf210258cb8882f50bbd5/websockets-17.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a920e99155d75d1b1c08172da8aaa7200e7b30871fb628d1631d7891a333e30e", size = 226366, upload-time = "2026-10-30T15:07:30.472Z" },
2084+ { url = "https://files.pythonhosted.org/packages/9d/73/cb4c927e806a73a0377696e031ef3c0db409028cdb2373f96ee863e8df8e/websockets-17.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8c00c5c49f80a588a2c4f99d519d2abed4ce26cdca11273041bc8b7e60af60de", size = 227470, upload-time = "2026-10-30T15:07:32.122Z" },
2085+ { url = "https://files.pythonhosted.org/packages/33/b3/e682b3ea069933044f4bf4809eea0cd83e3a5bdbdff601d1777a9ae90795/websockets-17.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:52c31d123f5adf14dec694c2a4036bf42c523df7cefc641d31cd05653de56e49", size = 225026, upload-time = "2026-10-30T15:07:33.544Z" },
2086+ { url = "https://files.pythonhosted.org/packages/2d/e9/d039757260705e5a265a31c9fff36b0d4567b842ce2b54e5d14369f200bd/websockets-17.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d0acd8191c0e5a61d1c27bfa33b6ec8431982299f5d71c02129aa4f16f182ad0", size = 226007, upload-time = "2026-10-30T15:07:34.954Z" },
2087+ { url = "https://files.pythonhosted.org/packages/db/e7/cb9907fac6f9b28f93beded6efda2953960cd02b633d432acf3451590859/websockets-17.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2a9cb5bbfc33b4ebd3c8d3714d6e09805437f60a14977774e4bd11b5ace46c23", size = 226286, upload-time = "2026-10-30T15:07:36.292Z" },
2088+ { url = "https://files.pythonhosted.org/packages/39/99/85f6f4dd104a5d9a33a1ef272f501d0f2e8527ddf66be1b383d51f90ce56/websockets-17.4-cp312-cp312-win32.whl", hash = "sha256:c63c0d7832956d5f3bb77888b93c854569d13f806289ff75c43926d25c6e5fd2", size = 219256, upload-time = "2026-10-30T15:07:37.686Z" },
2089+ { url = "https://files.pythonhosted.org/packages/59/31/d1636ba67ee9232a60033d90483e48686aa3856014e59110f29015f97bd2/websockets-17.4-cp312-cp312-win_amd64.whl", hash = "sha256:dde9cd67f18803949ebf96de8aba12b8ec5ee3115e34c75a2db85a3b0a6c20d5", size = 219561, upload-time = "2026-10-30T15:07:39.19Z" },
2090+ { url = "https://files.pythonhosted.org/packages/23/aa/e3859d52188f041a5a05c9f3ac005a9c829723f521f0fe60e2c5f9c93bb9/websockets-17.4-cp312-cp312-win_arm64.whl", hash = "sha256:c634053459278cf31fb404ddb5ecd38124e4609e8b609166da621edc2d5796db", size = 219487, upload-time = "2026-10-30T15:07:41.161Z" },
2091+ { url = "https://files.pythonhosted.org/packages/cd/58/eaf27fce59e3f5b9997cd7c77749ef214e077daeffac95479be6ff9f5cfa/websockets-17.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f24bed98595b9d854da8d6debaf82754abf3e8fe63ec90505ef560566814df7e", size = 218769, upload-time = "2026-10-30T15:07:42.558Z" },
2092+ { url = "https://files.pythonhosted.org/packages/13/8e/287eca6d399a953253492189153976676994dfba1b7e82f0f992d14c1404/websockets-17.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:156221ad7638b5abccefd6f798916bf3f8ffd6ac51480eb4ba5b05df914ccbf3", size = 216454, upload-time = "2026-10-30T15:07:43.874Z" },
2093+ { url = "https://files.pythonhosted.org/packages/b3/65/098fd4d221d691395492fcba88c895dde19a51d371b06b8d0be71d3f781b/websockets-17.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:74e46409666c358b93fce0d8c6f4cb107d8dd7cba1302a38d49adf6fa8193c7e", size = 216715, upload-time = "2026-10-30T15:07:45.409Z" },
2094+ { url = "https://files.pythonhosted.org/packages/e9/29/9f9833bc83c1bf2e5597d1ef374d3ccd6467b9b45400cff399c85331ef6c/websockets-17.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e3e80cad85ba68c07c1e8d4ccc9cf51e63bf6a73d6bf631640be947bd72b6b63", size = 226096, upload-time = "2026-10-30T15:07:46.929Z" },
2095+ { url = "https://files.pythonhosted.org/packages/ef/75/6a95f93661bba2f1a4ec82eb26c6d30e6ba253569ecabb7a675139db6051/websockets-17.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:25eb661d8ba7e2b85ebb4e7a1bb9f6a3280910ed10b84330a7abfb5ed32d2764", size = 226382, upload-time = "2026-10-30T15:07:48.332Z" },
2096+ { url = "https://files.pythonhosted.org/packages/80/b0/b0d9427dde0609e2727a9a966fb5fc5c6ef6aa7eba4cfc98791f8b7f1b75/websockets-17.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c74954409ea5789f7a60daa5f94580c00ad0b60b20751c47b35c4784877b5057", size = 227617, upload-time = "2026-10-30T15:07:49.799Z" },
2097+ { url = "https://files.pythonhosted.org/packages/3b/00/fb1016d850d61676d1732434e2361d116c1de369078e59d21f4713115a19/websockets-17.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:47beedf07a706140b50786457e082497ee6b5970c41e49bcd1572ee3f4c73f80", size = 229054, upload-time = "2026-10-30T15:07:51.258Z" },
2098+ { url = "https://files.pythonhosted.org/packages/77/c4/bb666e196541e42237fdb625303ad099005cc835c4e2e0a5d97bb9c4185c/websockets-17.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e282f35ece8895b9fa5af406fc6a4cb42f01e1009525cf46641de584781c83b", size = 228243, upload-time = "2026-10-30T15:07:52.975Z" },
2099+ { url = "https://files.pythonhosted.org/packages/6d/f1/32ae0f45d8464e1606ef3f6a54318d8754b27889de18bb1b2176a19bea39/websockets-17.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:361f9a21a33191d5287e7a75f508fc3b9bf47aaffb0241198d6b7be3384f294e", size = 226995, upload-time = "2026-10-30T15:07:54.574Z" },
2100+ { url = "https://files.pythonhosted.org/packages/f8/9f/5680151ef10e0bfb6f3bf09a992ce6c3e93bbb84fc8ec5d03ad570ac6532/websockets-17.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4073ab7cefe5a2e7dc368c1f01eb27716dc8ea8f1c69e9b7725ebce05aa53cf7", size = 224244, upload-time = "2026-10-30T15:07:56.158Z" },
2101+ { url = "https://files.pythonhosted.org/packages/e2/66/409117b6882b91960598d62b95fac33d70a7aa2e1e1e854c6b64e76712f6/websockets-17.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a50bd9b44dd9b7b845802030b4d88610c01c7f95571b4cbd3a1aeec17a34c9fd", size = 227058, upload-time = "2026-10-30T15:07:57.539Z" },
2102+ { url = "https://files.pythonhosted.org/packages/35/e6/34cab5913bea9d011ae2592a7672340179ff14fb98e889e5e41b071d8134/websockets-17.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ad2e9e152bec6c4f7e4cdb4adcea6b708400090eec04bfe5ee5b30d368ffd721", size = 225654, upload-time = "2026-10-30T15:07:59.044Z" },
2103+ { url = "https://files.pythonhosted.org/packages/a6/b0/6cfe18e12f23618a3d69723c828037c1282fee7ffce006ae7b5cc0e58ca4/websockets-17.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5830f94a7190a33e55d06d89242ea951ee2009c3a27fc31538f3ac9386b27edd", size = 226425, upload-time = "2026-10-30T15:08:00.492Z" },
2104+ { url = "https://files.pythonhosted.org/packages/c9/71/14d31ea34bafc07c6d96b362a44b301b9a0f2fb260490a8a6cb7db682bc4/websockets-17.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c0cc2cb47face4dae64bd70a9268dbebddb3e48c1b48cf6d7b6ac1471609fd88", size = 227532, upload-time = "2026-10-30T15:08:01.932Z" },
2105+ { url = "https://files.pythonhosted.org/packages/28/69/df3398ca1320a8b76e57ee41053d5c09955383cf60888ffa5771f51a4cc8/websockets-17.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d0b6a9bd86f865ebc9d847a1eb0a655a0e2b447cbcde743dda97c4298415cc0b", size = 225090, upload-time = "2026-10-30T15:08:03.328Z" },
2106+ { url = "https://files.pythonhosted.org/packages/d6/94/06afe778cf9893e9ac10209c0d4c70aaf0b72289245a8b7efb92cc80927c/websockets-17.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7a7e9321822ca59ba56fa8744c3e3f01ef74d547aafe0d383d000d7a4d11eb79", size = 226041, upload-time = "2026-10-30T15:08:04.714Z" },
2107+ { url = "https://files.pythonhosted.org/packages/c6/7a/28e9822dbf0bc63341dc45d8d5f1f1f7f375991fc8895bc731105fbde371/websockets-17.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de29795f23833cdfb68ba45c8a5cc0ec49778ea5deb670188f4465bba5930d25", size = 226323, upload-time = "2026-10-30T15:08:06.103Z" },
2108+ { url = "https://files.pythonhosted.org/packages/5a/d8/606c9c404e82731e4520f6397c8c16549e9cbd39ac583e7ea7d8972a6ae7/websockets-17.4-cp313-cp313-win32.whl", hash = "sha256:37ed7782972545e67d97ef9a172e9499d25459b1e32cb99d1c2a30c572f2441b", size = 219248, upload-time = "2026-10-30T15:08:07.617Z" },
2109+ { url = "https://files.pythonhosted.org/packages/60/a1/17e507dadbfccb755758ba977b165fec92924dfc4a8d2573b4fc1561b0cd/websockets-17.4-cp313-cp313-win_amd64.whl", hash = "sha256:203e42eb77e06f85778a908b34c5ad032f34e98a68ef33920d6685c1e65a9420", size = 219560, upload-time = "2026-10-30T15:08:09.108Z" },
2110+ { url = "https://files.pythonhosted.org/packages/01/ba/4f51fe8db73889ea3ecfdeee283d4ac3b7ad7a609c0e4c1fc6de04078021/websockets-17.4-cp313-cp313-win_arm64.whl", hash = "sha256:d8255971f6789cf0cb365e3e22b9b166eba978548a26241eaa5b7af0440d885a", size = 219496, upload-time = "2026-10-30T15:08:10.574Z" },
2111+ { url = "https://files.pythonhosted.org/packages/1f/5b/085530961668661300de7248f9bf3ba84e98f47875f0fcc87637d024f82b/websockets-17.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d0f943b53bb1d7389d72a5380078670e5fcc34a1328142148b72d20fd82031c7", size = 218810, upload-time = "2026-10-30T15:08:12.14Z" },
2112+ { url = "https://files.pythonhosted.org/packages/f2/fb/5a29e9569f74988822b7bc076287dd7f9f5b68a2070cf5eb4cfada5863d8/websockets-17.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2fd562372d4afaf18d1c7f1c68e39bd32818bb16d5f57f778a4a4ed88eedcaae", size = 216506, upload-time = "2026-10-30T15:08:13.561Z" },
2113+ { url = "https://files.pythonhosted.org/packages/df/73/eb8544367c13cd87df39d41b74853741fb7182e2a4bb659ad7d36f9066c8/websockets-17.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a0457561e8a87a6a3b509e21c35084d7d207ced262139de3f738e9676a476bbd", size = 216719, upload-time = "2026-10-30T15:08:15.08Z" },
2114+ { url = "https://files.pythonhosted.org/packages/24/fe/c3707e426d9bd779dfad9f319b2b431e5e60d563d7547d3780cbd1646028/websockets-17.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b4ee34acefa9ba1333f02b817982f1e4127f6f9b975017a707c4e738d7f9fb9", size = 226098, upload-time = "2026-10-30T15:08:16.581Z" },
2115+ { url = "https://files.pythonhosted.org/packages/c1/24/d203439f909ffbf37260ca28a3432381412d2e79912ebf98be572e63055e/websockets-17.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef03fb4b3685df0ed9dc6a074a090f030345cad2c23c61818da3e08e693a9caa", size = 226449, upload-time = "2026-10-30T15:08:18.29Z" },
2116+ { url = "https://files.pythonhosted.org/packages/4b/17/3ae5d9486a170a222dd0437b4f2bc02ecf324177498a942dcac200e65d05/websockets-17.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5e970d3996c597186c9dbb6e77a8048737a834ac03cb13186a54999e0e1c31e", size = 227695, upload-time = "2026-10-30T15:08:19.692Z" },
2117+ { url = "https://files.pythonhosted.org/packages/34/37/1307a8e30a961726f2350982fc01b0b3498db29b030c78c566e65ba64487/websockets-17.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6afc32a7a1a40b071070419537df3559d26907086556769683fa97b7b6e90fd5", size = 229069, upload-time = "2026-10-30T15:08:21.682Z" },
2118+ { url = "https://files.pythonhosted.org/packages/9c/bd/4c6167744e202d20a3c770bcde8fae5d427317b1885712c2e02718cd8133/websockets-17.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5da7f7f1ecf866186f195f7ecc374c915e1838496b86f2f3b41da27c798a25c3", size = 228296, upload-time = "2026-10-30T15:08:23.371Z" },
2119+ { url = "https://files.pythonhosted.org/packages/14/b3/3438eee41b39afb44c3c6e45dd686b88a7f8d742b96877b77de4fe0e0255/websockets-17.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:043945a43873c7c11a3f75238d475c95f902e967de329eb8763704546cfaa6b6", size = 227045, upload-time = "2026-10-30T15:08:24.749Z" },
2120+ { url = "https://files.pythonhosted.org/packages/46/39/0bac9052d402efaad1b202ff2f1a57b6334aa624d20f509312765601b507/websockets-17.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:927d602fb8fb595a69e6b1ae998a6ec19851d0fb9391759456e6425090996647", size = 224297, upload-time = "2026-10-30T15:08:26.275Z" },
2121+ { url = "https://files.pythonhosted.org/packages/a1/7f/5809f6ba5a7d1065301800daf605bdb661a9db5ef868ed189dbf691122ac/websockets-17.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:769e4f7b090e10cae4c082e3e39b9458ec2444505df84953c16c0468b16539c4", size = 227169, upload-time = "2026-10-30T15:08:27.703Z" },
2122+ { url = "https://files.pythonhosted.org/packages/fc/f7/c8f6771e7cc2a038cf78bb12d04d29dbbbabf2a84d76999bcc94532f3020/websockets-17.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:03ddf4eb76ced8725dcbb000ff60b0fb8d7330ed011f6dc725a1925c5fe02051", size = 225632, upload-time = "2026-10-30T15:08:29.441Z" },
2123+ { url = "https://files.pythonhosted.org/packages/2c/2f/9104786d952978ca2d011321109e35c05be24c3937ef2a62b4d6e25f7515/websockets-17.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:94622cf24e80d03054ceb69a06a86c950e12fe993ed06b933f7895232cacbc3e", size = 226415, upload-time = "2026-10-30T15:08:30.962Z" },
2124+ { url = "https://files.pythonhosted.org/packages/ae/a7/2e1606b3d616b86bb6c70a62d5f87fc9a3dbd81f0bbe24c4706ace76751d/websockets-17.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bc581579885728bb54ef15000295c20e6a64a200b1b73d5cfca71879a3eb3e1f", size = 227594, upload-time = "2026-10-30T15:08:32.443Z" },
2125+ { url = "https://files.pythonhosted.org/packages/67/2d/78ca44e2be6d397aee63a4e68d3a99a33bb14bb3405e94688bc5a2160db5/websockets-17.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42671900e0b602d05a074c52fd3d757c2f8e5c4f73f771794dae1aa4aea5f0e", size = 225146, upload-time = "2026-10-30T15:08:33.888Z" },
2126+ { url = "https://files.pythonhosted.org/packages/d9/b6/6dad684e54fc8e09d70c9a095506db5c836207d8e846e3cb1e79424e3492/websockets-17.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:2b23dacf7a8a88afe92f77bcdbf4cf043a84692bac2f2da02f4011c687e19b48", size = 226095, upload-time = "2026-10-30T15:08:35.285Z" },
2127+ { url = "https://files.pythonhosted.org/packages/d9/e5/7b04bad7c11c1f92238429352defbac1406743338049afe1328c7a7f95c0/websockets-17.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d48795c7d64ca1c42e3d61fcc6478cb15a19b6ad3f9c882bc8d8dee49d4a420f", size = 226395, upload-time = "2026-10-30T15:08:36.8Z" },
2128+ { url = "https://files.pythonhosted.org/packages/b1/16/4eb29c4b12f77b3139470063308ae1e6a0860b9073b1d27d56e8ce19c512/websockets-17.4-cp314-cp314-win32.whl", hash = "sha256:8588edf317fbe4f16a949bc5a6ccdd22b6e6fd5b4745d128bc40bccf31024ce5", size = 218784, upload-time = "2026-10-30T15:08:38.263Z" },
2129+ { url = "https://files.pythonhosted.org/packages/6d/85/2496a24de80c215b55a15f037bc368a2ed0ce7ecb1e909039ff4e76395de/websockets-17.4-cp314-cp314-win_amd64.whl", hash = "sha256:70293c25d79d1f084fdd665894481547575e429d2a75e8d72caec2f6b6b07af8", size = 219137, upload-time = "2026-10-30T15:08:40.112Z" },
2130+ { url = "https://files.pythonhosted.org/packages/9a/a3/cfe95d4eb569035e5a1d2e5cbeb4ccf0eb15a19e1ef2ad819a85960bf375/websockets-17.4-cp314-cp314-win_arm64.whl", hash = "sha256:adc30e5d064c9e33049e825408fa4e952f4e8a0cf4c329dc2ded72e1bb5bae08", size = 219017, upload-time = "2026-10-30T15:08:41.778Z" },
2131+ { url = "https://files.pythonhosted.org/packages/2b/dd/4143048a2b27303e7de8ef53d7d0ee3987734fbf93e09491d5a1b0a55187/websockets-17.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:bbb6bed49f083e2ec8ae0bb21c00c7c5171ab8e08a8b008c61a43fc6c944fdac", size = 219141, upload-time = "2026-10-30T15:08:43.184Z" },
2132+ { url = "https://files.pythonhosted.org/packages/a8/3e/a63b5f3a5c24be2898eb22a3093a56d4ac4cd24ae79e7ab7a1b971ed4611/websockets-17.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c7a56140fcbf4662cb263754a754c651a16fe2196cae1fd232d18f89acd30c83", size = 216675, upload-time = "2026-10-30T15:08:44.682Z" },
2133+ { url = "https://files.pythonhosted.org/packages/d6/80/f238a10f0b23853c665c602a42784023995ac26674e480252c21af39f4f8/websockets-17.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:18d3710becc612ec6f0229425bd6495133dfcbf30f7b6569585acc8ed2f1b452", size = 216880, upload-time = "2026-10-30T15:08:46.304Z" },
2134+ { url = "https://files.pythonhosted.org/packages/d1/b1/899b69aba1e8d65ac5bfe90b97ada22a815f1dcd3840bf188104738aac33/websockets-17.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:00ad98e4bb0df7a6711c645eec16d5a9fdf99f0692bd4f5b6cb85a68677ba8df", size = 226462, upload-time = "2026-10-30T15:08:47.723Z" },
2135+ { url = "https://files.pythonhosted.org/packages/28/a8/598c2b2a22dfe14d352400df804db8b78a68c1498b3f775a658f07f21618/websockets-17.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7849a096d6fdba3f2bdf849eb89f2d233a24400c92faa3efb25fa8dc615b706c", size = 226741, upload-time = "2026-10-30T15:08:49.314Z" },
2136+ { url = "https://files.pythonhosted.org/packages/6e/ad/84a6de3cc3dd4ee336428c31f89a82e002299f83e10d967f0e9a5cbaf591/websockets-17.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e30550965c78223439815dbdf40c93eb8931dfbf12427110dcfcaeebef76feb6", size = 227914, upload-time = "2026-10-30T15:08:50.746Z" },
2137+ { url = "https://files.pythonhosted.org/packages/69/ab/1e191b97d60a934c071b7b73c39c5db39721a925cc53acd929c615f85550/websockets-17.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:725be1aff55f1c873a79cea0f612df9dff21a9d2c2e05783665b824b8aa7c24e", size = 230059, upload-time = "2026-10-30T15:08:52.596Z" },
2138+ { url = "https://files.pythonhosted.org/packages/f2/57/ef8a64732ef4367091ab05f9b22c1ee4c28657d4a0d088a644198eb5ded6/websockets-17.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6c7f91520c29420639925fd1cecceac9ca07366b50c09ea24eac1cd116b4866", size = 228486, upload-time = "2026-10-30T15:08:53.967Z" },
2139+ { url = "https://files.pythonhosted.org/packages/48/1f/dbad3afcacf2c52d1fdf83b611d849598f0a047d7333ff476266532877b8/websockets-17.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6531540aa63e6864cb0615a3a5da70ae03191334507852a5385320bb7916df3f", size = 227216, upload-time = "2026-10-30T15:08:55.687Z" },
2140+ { url = "https://files.pythonhosted.org/packages/5b/47/59e325d6ca3e04ba5cc7a139d891ef83cad3fb460a183c8f359f378b69a9/websockets-17.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a57b99f2e28922b5a2434317580fd23fdf118caff4c5b752f5452836426205a", size = 224755, upload-time = "2026-10-30T15:08:57.203Z" },
2141+ { url = "https://files.pythonhosted.org/packages/b8/b6/28a471318dfb0060b58c47d0a252cb5078528a1aa1fdf5e6e88e2a61cf18/websockets-17.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8fd30d217a4275107667fe1d4f601e2d50c797d0727148cfbb384536d65f04d3", size = 227508, upload-time = "2026-10-30T15:08:58.597Z" },
2142+ { url = "https://files.pythonhosted.org/packages/ee/29/7485ac6fc4b342b43964811381f56276fec967cae6a851ae4b815613b10c/websockets-17.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cc0cd4065a65ac98f3e2742ee9f314da9d224db49b07dc17d3a20cef0d79658d", size = 225961, upload-time = "2026-10-30T15:09:00.414Z" },
2143+ { url = "https://files.pythonhosted.org/packages/95/a6/520e1f7aa9644ddf58f5a51fc37f46e15293a68cd35b70d5b81b7331ca1e/websockets-17.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:71f76bb8e59be847330b8a8d3753f85397677e128f9f9692c215f8cf721cefc7", size = 226851, upload-time = "2026-10-30T15:09:01.979Z" },
2144+ { url = "https://files.pythonhosted.org/packages/e5/0d/444799abe7418f361da8c201c6858b64b4e8d47265f90851e5cb68b4d90f/websockets-17.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c2db7b9e2d726f7e051bedb67abcf460b84b11437b2f9fdacda7e6931dace85b", size = 227869, upload-time = "2026-10-30T15:09:03.405Z" },
2145+ { url = "https://files.pythonhosted.org/packages/cb/86/6da20322a07364a0ab0787a2b0d4367770f59404fdab6e35122413a7f63e/websockets-17.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:332b88022d0acd47c44158bcd43f47f14eaddcd979247f242b1a33291c45cdd1", size = 225562, upload-time = "2026-10-30T15:09:04.891Z" },
2146+ { url = "https://files.pythonhosted.org/packages/2a/da/53e44d96238dc207011ad5d93b0540c58e1c680a86f48dc3fa8e5138b343/websockets-17.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5cec3e7fbc911ff4fcf43b24aaf1b96906381ad36030f42897def3ff4b78729e", size = 226334, upload-time = "2026-10-30T15:09:06.42Z" },
2147+ { url = "https://files.pythonhosted.org/packages/18/08/82cea972a87f229ee1903e9adf74c17d1e6c4371d581ebb11ccbed4e3a7b/websockets-17.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cbdf4b42f4514422051889424fd03bc269be8c5d751bf39ec29ad761590bd10d", size = 226767, upload-time = "2026-10-30T15:09:07.837Z" },
2148+ { url = "https://files.pythonhosted.org/packages/96/f3/ee6afff802e39e8d6ef29ee4bdc30f8272aaa38295c19a911fad91fe95fc/websockets-17.4-cp314-cp314t-win32.whl", hash = "sha256:16550e24ac22c27e0a728100d8de196b5f054bab6748ef9f0b168fb8b3c32e35", size = 218918, upload-time = "2026-10-30T15:09:09.31Z" },
2149+ { url = "https://files.pythonhosted.org/packages/11/56/b0b07917f9c85a4d24a37300f4ae888bfa7c20f08902dd6a5c61a0d1b26f/websockets-17.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5ec906bb21b35e66cfdcc2ea0133f249f52b7d89e3e45ec8d2bd2d84a0255d7d", size = 219260, upload-time = "2026-10-30T15:09:10.832Z" },
2150+ { url = "https://files.pythonhosted.org/packages/19/a6/a63113909ea602d3d22b0f32a7a9950d8d18b725cfdce735eab9b5f5d198/websockets-17.4-cp314-cp314t-win_arm64.whl", hash = "sha256:c00f0c45851a2c61efffd03dde8fb4d594026b5cc18196c94e15460f1225f2ed", size = 219176, upload-time = "2026-10-30T15:09:12.444Z" },
2151+ { url = "https://files.pythonhosted.org/packages/ee/be/4032fde05e62d1c52edd250fda475e910cac0cd180e377690bff24147b5e/websockets-17.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:8c51ac7b31578a7275650f580aac985940985b6d2e57247ac6d5e07f2c936cd6", size = 218740, upload-time = "2026-10-30T15:09:13.833Z" },
2152+ { url = "https://files.pythonhosted.org/packages/74/1c/b47f5786eb45599072411571651606fb22f6e825d522d627daad974ea636/websockets-17.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:023dc13bdc42a55e152acc81c39c90ec6cc4159ed17d23c6d73460fa2a73ab16", size = 216470, upload-time = "2026-10-30T15:09:15.394Z" },
2153+ { url = "https://files.pythonhosted.org/packages/70/98/0021f55de633617b4a8390cca17e700c9ba5d99323103128412dc2c15aca/websockets-17.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6fd8cac4907fec8302726d49e6969acfffaf720bc39b1cee23f473b81f4dce01", size = 216695, upload-time = "2026-10-30T15:09:17.185Z" },
2154+ { url = "https://files.pythonhosted.org/packages/28/56/2c67389b47c4b9e9e9318d9b3e4bb52a2fef3749ff00b0b151a342c4d42d/websockets-17.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4441d655a53185ef1b01b1d42ad9b3a96b905110a9219043e3fdaec855ba3b6e", size = 226134, upload-time = "2026-10-30T15:09:19.156Z" },
2155+ { url = "https://files.pythonhosted.org/packages/75/bd/ee3a679a10741a0cdd7aa8bdd6d03999069672826aa7a6495fdf589ed961/websockets-17.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3acc45494c08a4d27375a2c28474537f68910219f42624a08011221b04623d04", size = 226569, upload-time = "2026-10-30T15:09:20.587Z" },
2156+ { url = "https://files.pythonhosted.org/packages/c4/42/b293a5d07099549797138d09fda5bf878d302b4980e61678981c8dfdc98f/websockets-17.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b78b39377630a70aa2198e5f03be416e690364a046b1d8af80a9263733083d0", size = 227726, upload-time = "2026-10-30T15:09:22.015Z" },
2157+ { url = "https://files.pythonhosted.org/packages/2d/81/d048ff83c4d0fc386f6008529d12690d0dcb410f73570430f13fe4d543f0/websockets-17.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6cf4c002268da5c11cae9083aec71ac943bce9d95e0f83c8bf9a95a3ce421fbb", size = 229426, upload-time = "2026-10-30T15:09:23.444Z" },
2158+ { url = "https://files.pythonhosted.org/packages/e6/21/1df51f94047dfb76c32fd7508036e4b55352d0f04fd0e3bee525a2122225/websockets-17.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed7fe197b8bb66eef4f62c052e33752d456d2fdda74805469d4bd954950ab339", size = 228211, upload-time = "2026-10-30T15:09:24.902Z" },
2159+ { url = "https://files.pythonhosted.org/packages/cf/13/33d6a28471da4c30a98a2313c76f984be2056a703c5c532225dc28f5dba3/websockets-17.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f0494d3d97502e270282cc6a1f8648cd7335b4fe8d5c452006e5b7f9d62b8892", size = 227106, upload-time = "2026-10-30T15:09:26.315Z" },
2160+ { url = "https://files.pythonhosted.org/packages/4a/86/ff8441e56ca677e51f9c826868fccde2a82bafe0ffa337fd38823b64253a/websockets-17.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab9e4bd60796866c578e3afa0a17c98636887827a39c2e238a61989c18fd8e7b", size = 224499, upload-time = "2026-10-30T15:09:28.116Z" },
2161+ { url = "https://files.pythonhosted.org/packages/da/91/470d7a619ce4aa837ff5b6a5836227c43bdfdc42ef837cce48f508992438/websockets-17.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8d148b4b295224d3a19e7bd9f02ebe7344de254b3b2c4d0628ce42ade0bca119", size = 227213, upload-time = "2026-10-30T15:09:29.847Z" },
2162+ { url = "https://files.pythonhosted.org/packages/a4/ba/55ed1422d6c73b5b81996ca195f2a256a339b26c553f5686afc2a926bc2b/websockets-17.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6597a1f7413189b4bc58437babcdf39b79da416e5c477ae98323a88ecc108188", size = 225876, upload-time = "2026-10-30T15:09:31.249Z" },
2163+ { url = "https://files.pythonhosted.org/packages/e9/59/f133d260e7a48d3e231c3a495eeb6114b880c1e96bfc781a949c1f73991c/websockets-17.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:a9b0026538719f7a532998d84640ab2e91ca1a051ce4b8d924fdcb607307e921", size = 226403, upload-time = "2026-10-30T15:09:33.199Z" },
2164+ { url = "https://files.pythonhosted.org/packages/45/16/bb3355453ffdc31d3a14ddc0b50e058f13a97d2ed019ba6c380e03456169/websockets-17.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a703f23ca15a51251fc94f1774683f5aa511588a7ad6bbb83960cdb2a4dcd978", size = 227558, upload-time = "2026-10-30T15:09:34.703Z" },
2165+ { url = "https://files.pythonhosted.org/packages/0c/ab/f3d55a83c2a4daa8bf62594b00a721dc8767518ccb833f932b0178eb6c19/websockets-17.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6b061ea7ca94966af7cd7a87cb189ff15972c103244611a2b1e8408c04f1dbe6", size = 225369, upload-time = "2026-10-30T15:09:36.204Z" },
2166+ { url = "https://files.pythonhosted.org/packages/88/66/3a320bfb718d9ae1eff15ba7790979c0e6fdec74e388430f426e6647d7f4/websockets-17.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:e6d2a185ec4ff0478089305abd833da3a9fda8803eb77325b89ebeae5585a3b7", size = 226268, upload-time = "2026-10-30T15:09:37.565Z" },
2167+ { url = "https://files.pythonhosted.org/packages/da/ee/1f9cdeeaaa2d5e158b7a40f971e74b641b35f977674b46229a5bb14e270a/websockets-17.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9a4e95d56994a45ac76541853796720436ffe462957d0b1b89e87ff3d3b1627e", size = 226530, upload-time = "2026-10-30T15:09:39.171Z" },
2168+ { url = "https://files.pythonhosted.org/packages/da/28/506406ef09e6e168796f0187bdac0767f38a560e6785c4ee26e583aaaf76/websockets-17.4-cp315-cp315-win32.whl", hash = "sha256:b7ea61af293a153f2d67430038cf0efc10140caa623f2bdf5a9f5a457ff20f57", size = 218751, upload-time = "2026-10-30T15:09:40.748Z" },
2169+ { url = "https://files.pythonhosted.org/packages/25/a6/818647a9a9b3ee8bf131e9ab4fa1f2db9c6539f08e7084dd57549e0bd49d/websockets-17.4-cp315-cp315-win_amd64.whl", hash = "sha256:8b45d40f07081b377f0b837ddbfba4a9ab4a89a7b569d3c095436564e767a27b", size = 219045, upload-time = "2026-10-30T15:09:42.251Z" },
2170+ { url = "https://files.pythonhosted.org/packages/3d/b2/0338a9abec3df8556627af987352657ce1c8456f2060b54741570b174122/websockets-17.4-cp315-cp315-win_arm64.whl", hash = "sha256:29b615c2d5dd883f89c107a782681b73a7aa8b2bd894130310e45eb09b143913", size = 218957, upload-time = "2026-10-30T15:09:43.866Z" },
2171+ { url = "https://files.pythonhosted.org/packages/68/92/22e83d003cdbd22be5d35e28c260203e9fbb6c73b17a8e1509f4ad49e74d/websockets-17.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c568e72f5425a74616daeb824afe28da460cbeea036432f41c6af5380b83300d", size = 219056, upload-time = "2026-10-30T15:09:45.528Z" },
2172+ { url = "https://files.pythonhosted.org/packages/2f/ca/071d38e7237f6ada4f577b74f7cbf5bc2ff7e1f9360ebd2837e74a22dfd4/websockets-17.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:bfc86dff091104eec770c29ea1831dbe191968a70aa2d8ae8eda3240d863970a", size = 216630, upload-time = "2026-10-30T15:09:46.951Z" },
2173+ { url = "https://files.pythonhosted.org/packages/fa/76/22d4523f92d88b8f5ebfd3df7ab5e825146af100f7e2caeae7d359ae51ea/websockets-17.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5f06f59e19aa0770aaf651ce91c196fa620b35aed49a7e6c36e7335f3f83db7", size = 216841, upload-time = "2026-10-30T15:09:48.285Z" },
2174+ { url = "https://files.pythonhosted.org/packages/87/29/bc94be4a8bc19ef518e9efecd90a992f2991b7cfc6eb8a7430c4e2dc097c/websockets-17.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8f32a6cb0b101170abc2d8053af1473a2a29ef2a06d7130994ee8455a50f5dbf", size = 226992, upload-time = "2026-10-30T15:09:49.776Z" },
2175+ { url = "https://files.pythonhosted.org/packages/fa/25/58f69b5fec064764ddf5b88045397433cdb028f5b17c82fbe047fd31fff7/websockets-17.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d0a731eccf482d5661c6711abfcd5921f3dbfbbb0789061e1918ef4bc89c70", size = 227258, upload-time = "2026-10-30T15:09:51.172Z" },
2176+ { url = "https://files.pythonhosted.org/packages/21/7c/bc932a54c5f1bd78461574aea539fc562fd2de494738a2c0675c4de860be/websockets-17.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48c83103f82fce36191a9e3a8a80731d4340e2da544eae8c5aaaf6a45280e7d0", size = 228589, upload-time = "2026-10-30T15:09:52.628Z" },
2177+ { url = "https://files.pythonhosted.org/packages/c4/92/57ec83066d0bfa8a9b9f594bc61951073c4a86e34c562a5292604118dab6/websockets-17.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c8052c3058c402d53602583da5ddcf1a15a63ab1efe0222f47c7be59944e6463", size = 230217, upload-time = "2026-10-30T15:09:54.058Z" },
2178+ { url = "https://files.pythonhosted.org/packages/6e/fc/0f2fe533b5b4dc22ac704b3357f5c99fa60a3837218b3c6c6ad63a60120e/websockets-17.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0c22a9c6b2349204f56123dd0ca7779e989d68e336d0dc02ad823485d44925de", size = 229026, upload-time = "2026-10-30T15:09:55.727Z" },
2179+ { url = "https://files.pythonhosted.org/packages/7c/cc/ed28307835106a9a2dbd1a5efc597e8befa3d0a5f2b46a4c1e466b2f4759/websockets-17.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0dcce83837328e6795225ead920d3aa8d867cc9d6922aa558b0e9111599fdbb9", size = 227904, upload-time = "2026-10-30T15:09:57.221Z" },
2180+ { url = "https://files.pythonhosted.org/packages/39/4d/24ad7deddc7b31c3e430acc1c00c7b384ae42f1f973e9cc01aa42d19628f/websockets-17.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b79ea31cf277c72d2307c6cb8bd32a2c151959e80a2f24fa85df620c5f2860c", size = 225253, upload-time = "2026-10-30T15:09:58.624Z" },
2181+ { url = "https://files.pythonhosted.org/packages/93/f6/9c92230ebfab42fee482b236c45c32ed34b24f7360080b400cce6cfcac11/websockets-17.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3d27153a0413696767b98b5d96173a2a7e27cbfca02000a6069cea7b977765a7", size = 228089, upload-time = "2026-10-30T15:10:00.308Z" },
2182+ { url = "https://files.pythonhosted.org/packages/fe/7a/f523830e562da9b1d7a610174752753720c5e47e84b59b5fc47721d416bd/websockets-17.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:e0feb03a338f8f5e819ae23a73aabb7b46fd3b73f15f7df782dd12d99e2b02ad", size = 226743, upload-time = "2026-10-30T15:10:01.744Z" },
2183+ { url = "https://files.pythonhosted.org/packages/2c/e6/d1a56495cb7b1a0a4688145131be38cfd0c1342bde8125e136a6e6ee494e/websockets-17.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9ff89e36451a19775e7556d95ebfa4a0b24b84cc87565d6b5d0ec8ea82bf3f2c", size = 227237, upload-time = "2026-10-30T15:10:03.417Z" },
2184+ { url = "https://files.pythonhosted.org/packages/44/41/c4e53f7ad63838d7eba6e82c5d743827e91dafe5e48c88a58f7e90043945/websockets-17.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:ee6c4e0085bff5f6621bf9c53452584dcb91ebd993ce841e1f4061ac12b6b556", size = 228348, upload-time = "2026-10-30T15:10:05.149Z" },
2185+ { url = "https://files.pythonhosted.org/packages/ca/01/f6f78c3edeb8ad2b3c35a8c4117ec2cf9346215afaed8155af1e7933818b/websockets-17.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1962f3bfde085af18878f1bb1aafb300c9a6ac33da886d0b0d97e33c9ce7a222", size = 226099, upload-time = "2026-10-30T15:10:06.588Z" },
2186+ { url = "https://files.pythonhosted.org/packages/8d/01/d8c11740231b63d1aba6bad6b1d4e72e8384b26c5448801da238ebe82c92/websockets-17.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:42d7a6e818214336cf7af9a3aae5c6923f94f09fa07ac5aab98268ba2a98a6d5", size = 226956, upload-time = "2026-10-30T15:10:08.141Z" },
2187+ { url = "https://files.pythonhosted.org/packages/99/87/dbfd43ae01ea8fdb7478a1b3d58b32aca83bb9c4aa61ebe187d1e188ac8e/websockets-17.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c9f8fdb7bbefc4907c2bb3593f810f2b67a2d5329e1d38724a1aa3d763cf63eb", size = 227253, upload-time = "2026-10-30T15:10:09.533Z" },
2188+ { url = "https://files.pythonhosted.org/packages/bf/8e/fbd968a75428063560ada02d564de8d6c49ca34ec29dfb78e5d900f4b0ef/websockets-17.4-cp315-cp315t-win32.whl", hash = "sha256:506623128d7a55b726a5a46e4c9a8eae4c9dd0f04ff60b041fe7df85b9d034fe", size = 218877, upload-time = "2026-10-30T15:10:10.925Z" },
2189+ { url = "https://files.pythonhosted.org/packages/f9/53/8f37a3943517bba77fc86f7c4162c455018c5b6d760c5e9c9a11de9e37f7/websockets-17.4-cp315-cp315t-win_amd64.whl", hash = "sha256:6e6558efbb256b532c9f06a21ba42bcac729718e365c505819f181f25e5c0eca", size = 219195, upload-time = "2026-10-30T15:10:12.336Z" },
2190+ { url = "https://files.pythonhosted.org/packages/69/35/cf33cba7248fcd80cfe5911ffe67aa2637f50c2bfaada123b1e9d2aeca90/websockets-17.4-cp315-cp315t-win_arm64.whl", hash = "sha256:fcac44d917672ad6257ad64b80b5168306f168a78cab5c0612e4c699caed18cf", size = 219117, upload-time = "2026-10-30T15:10:13.771Z" },
2191+ { url = "https://files.pythonhosted.org/packages/e9/a3/d8fbc72c5c0e78905caf2d6dcf806e532451e3f48e2a09a8ebe1de9ad412/websockets-17.4-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:c7f66a53e9b7ec831abb15e3d9b6cee8f52d6a24cd0a374786bf46c3ec218bc6", size = 216397, upload-time = "2026-10-30T15:10:25.255Z" },
2192+ { url = "https://files.pythonhosted.org/packages/d3/de/1bf313cd0f4662df4884a7602932cd13347090e59dd6047865eee2bb5aa5/websockets-17.4-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:4fb2db1f649b6464711846b5bb9c1611bd906ce939d9460cbd1fa419552cd6c3", size = 216664, upload-time = "2026-10-30T15:10:27.016Z" },
2193+ { url = "https://files.pythonhosted.org/packages/30/e2/7912bd417511b54907853bd495aef2daee111062dbafd43d133a3f7253a7/websockets-17.4-pp312-pypy312_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:32110d954d9d0e51f36edb0396673bf4d35193a0a80ba7013bf7f7d58a33ca07", size = 217603, upload-time = "2026-10-30T15:10:28.475Z" },
2194+ { url = "https://files.pythonhosted.org/packages/56/6f/2e8d5610f6184a2e1720bf8ce256402ed99ad30ea87c461fba5bc4d651d9/websockets-17.4-pp312-pypy312_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88ba8a439b2bad97bb69c38db5bf0ec16a061ee834cf0f244089186ee803e899", size = 217489, upload-time = "2026-10-30T15:10:29.999Z" },
2195+ { url = "https://files.pythonhosted.org/packages/29/3a/a05bc57eadcd7e8e072d917888128e6d22e1203984afb21a8da3198982fe/websockets-17.4-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:868744c68e9342768888c7326604da2228c19bb6946aa46b5f9e1ac32ea8d40b", size = 218356, upload-time = "2026-10-30T15:10:31.432Z" },
2196+ { url = "https://files.pythonhosted.org/packages/b2/49/eed86bfb8863093a82cbaa52c9159be1ce2374d5ace6fe1f8e8031567e7d/websockets-17.4-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:829bcaa90be8737c132d9515d6a466e8528e3b3d050d3f3466f36ea94d3beb48", size = 306838, upload-time = "2026-10-30T15:10:32.954Z" },
2197+ { url = "https://files.pythonhosted.org/packages/eb/27/9db924cb0dbf22eade78d98fbae77790f9bf6bc729e6ab29aef9d68e4156/websockets-17.4-py3-none-any.whl", hash = "sha256:c6bd2c9781915592b209160d0f017d4c7c82ad53075e6b0c24d2a89299e7f9e9", size = 212900, upload-time = "2026-10-30T15:10:34.482Z" },
2198+]
2199
Menu

Theme