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