FinData OS: phase 0–4 foundation (discovery, lakehouse, ingest, API, SDK, tests)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
135 changed files +17,015 −0
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 | |||||