From 7e22552d84263859041a2e39f4157e68bb91be7e Mon Sep 17 00:00:00 2001 From: EmmanuelObaisi <103223837+EmmanuelObaisi@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:27:13 +0100 Subject: [PATCH 1/3] independent reviewer plugin --- .claude-plugin/marketplace.json | 18 +++ .claude/settings.json | 3 +- README.md | 61 ++++----- backend/README.md | 4 +- backend/planning/REVIEW.md | 117 ++++++++++++++++++ .../.claude-plugin/plugin.json | 5 + independent-reviewer/hooks/hooks.json | 15 +++ planning/PLAN.md | 40 ++++++ planning/REVIEW.md | 50 ++++++++ 9 files changed, 270 insertions(+), 43 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 backend/planning/REVIEW.md create mode 100644 independent-reviewer/.claude-plugin/plugin.json create mode 100644 independent-reviewer/hooks/hooks.json create mode 100644 planning/REVIEW.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 000000000..b858fce4a --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,18 @@ +{ + "name": "emmanuel-tools", + "owner": { + "name": "Emmanuel", + "email": "emmanuelobaisi@gmail.com" + }, + "plugins": [ + { + "name": "independent-reviewer", + "source": "./independent-reviewer", + "description": "Carry out an independent review of all changes since last review", + "version": "1.0.0", + "author": { + "name": "Emmanuel" + } + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json index aa06f43dc..25973ca6c 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,6 +2,7 @@ "enabledPlugins": { "frontend-design@claude-plugins-official": true, "context7@claude-plugins-official": true, - "playwright@claude-plugins-official": true + "playwright@claude-plugins-official": true, + "independent-reviewer@emmanuel-tools": true } } diff --git a/README.md b/README.md index 3f2582ae2..4f515be88 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,42 @@ # FinAlly — AI Trading Workstation -A visually stunning AI-powered trading workstation that streams live market data, simulates portfolio trading, and integrates an LLM chat assistant that can analyze positions and execute trades via natural language. +An AI-powered trading workstation: live market data, a simulated $10k portfolio, and an LLM chat assistant that can analyze positions and execute trades. Built by coding agents as the capstone of an agentic AI coding course. -Built entirely by coding agents as a capstone project for an agentic AI coding course. +## Status -## Features +- **Done:** market data subsystem (GBM simulator, Massive API client, price cache, SSE stream) in `backend/app/market/` +- **To do:** portfolio, watchlist and chat APIs, database, Next.js frontend, Docker packaging -- **Live price streaming** via SSE with green/red flash animations -- **Simulated portfolio** — $10k virtual cash, market orders, instant fills -- **Portfolio visualizations** — heatmap (treemap), P&L chart, positions table -- **AI chat assistant** — analyzes holdings, suggests and auto-executes trades -- **Watchlist management** — track tickers manually or via AI -- **Dark terminal aesthetic** — Bloomberg-inspired, data-dense layout +The full specification is in [planning/PLAN.md](planning/PLAN.md). ## Architecture -Single Docker container serving everything on port 8000: +One Docker container on port 8000: -- **Frontend**: Next.js (static export) with TypeScript and Tailwind CSS -- **Backend**: FastAPI (Python/uv) with SSE streaming -- **Database**: SQLite with lazy initialization -- **AI**: LiteLLM → OpenRouter (Cerebras inference) with structured outputs -- **Market data**: Built-in GBM simulator (default) or Massive API (optional) +- **Frontend:** Next.js static export (TypeScript, Tailwind) +- **Backend:** FastAPI managed with `uv`, SSE for live prices +- **Database:** SQLite, lazily initialized +- **AI:** LiteLLM → OpenRouter (Cerebras) with structured outputs +- **Market data:** built-in simulator by default, Massive API if a key is set -## Quick Start +## Development ```bash -# Clone and configure -cp .env.example .env -# Add your OPENROUTER_API_KEY to .env - -# Run with Docker -docker build -t finally . -docker run -v finally-data:/app/db -p 8000:8000 --env-file .env finally - -# Open http://localhost:8000 +cd backend +uv sync --extra dev +uv run pytest # run tests +uv run market_data_demo.py # terminal demo of the simulator ``` ## Environment Variables +Set in `.env` at the project root: + | Variable | Required | Description | |---|---|---| -| `OPENROUTER_API_KEY` | Yes | OpenRouter API key for AI chat | -| `MASSIVE_API_KEY` | No | Massive (Polygon.io) key for real market data; omit to use simulator | -| `LLM_MOCK` | No | Set `true` for deterministic mock LLM responses (testing) | - -## Project Structure - -``` -finally/ -├── frontend/ # Next.js static export -├── backend/ # FastAPI uv project -├── planning/ # Project documentation and agent contracts -├── test/ # Playwright E2E tests -├── db/ # SQLite volume mount (runtime) -└── scripts/ # Start/stop helpers -``` +| `OPENROUTER_API_KEY` | Yes | OpenRouter key for AI chat | +| `MASSIVE_API_KEY` | No | Real market data; omit to use the simulator | +| `LLM_MOCK` | No | `true` for deterministic mock LLM responses | ## License diff --git a/backend/README.md b/backend/README.md index 7cdd84757..863e93f2e 100644 --- a/backend/README.md +++ b/backend/README.md @@ -22,7 +22,7 @@ FastAPI backend for the FinAlly AI Trading Workstation. ```bash # Install dependencies -uv sync --dev +uv sync --extra dev # Run all tests uv run pytest @@ -45,7 +45,7 @@ uv run pytest -v ```bash # Install dependencies -uv sync --dev +uv sync --extra dev # Run linter uv run ruff check . diff --git a/backend/planning/REVIEW.md b/backend/planning/REVIEW.md new file mode 100644 index 000000000..e0763a8ca --- /dev/null +++ b/backend/planning/REVIEW.md @@ -0,0 +1,117 @@ +# Review of FinAlly PLAN.md + +Reviewed: 2026-10-03 +Source: `../planning/PLAN.md` relative to the backend workspace (project-root `planning/PLAN.md`). There is no `backend/planning/PLAN.md`. + +## Assessment + +The architecture and demo scope are coherent, but the plan is not yet a complete implementation contract. Resolve the high-priority findings before building portfolio and chat mutations, then define the API schemas before frontend integration. Section 13 already identifies several useful gaps; those proposals need decisions incorporated into the relevant sections, rather than remaining a separate list of questions. + +This is a specification review checked against the existing market-data code. Missing planned components are expected work, not implementation defects. No code was changed and no tests were run for this review. + +## High-priority findings + +### 1. Trade execution lacks transaction and concurrency guarantees + +**References:** Sections 7, 8, 9 and 12. + +The plan specifies balance and holding validation but does not require validation and mutation to occur in one transaction. Two concurrent purchases can each validate against the same cash balance and overspend; concurrent sales can similarly oversell. A failure between updating cash, updating a position, and writing the trade log can leave inconsistent state. + +**Recommendation:** Define one trade service shared by manual and AI actions. Acquire the SQLite write transaction before reading balances and holdings, validate against that transaction's state, and commit cash, position, trade log and the post-trade snapshot together. Define rollback and lock-contention behavior. Test concurrent buys/sells and injected failures, not only sequential arithmetic. + +### 2. Retry semantics can execute trades twice + +**References:** Sections 8 and 9. + +Neither mutation endpoint has an idempotency contract. If a trade or chat action commits but its HTTP response is lost, retrying can execute it again. A chat retry can also generate a different action list for the same user message. + +**Recommendation:** Accept a client-generated request ID for trade and chat submissions, persist a unique request record and the committed outcome, and return that outcome for duplicates. Reject reuse with a different payload. Define recovery for a chat request interrupted between model inference and action execution. Test retries after a committed mutation with a lost response. + +### 3. Market-price availability is not defined for execution or valuation + +**References:** Sections 6–9; Section 13 items 1, 5 and 6; `app/market/massive_client.py`, `cache.py` and `factory.py`. + +Tracking only watched symbols loses prices for held positions removed from the watchlist. An unwatched symbol added through Massive has no immediate price: `add_ticker()` schedules it for a later poll. Poll failures retain cached prices indefinitely. The plan's instruction to fill at the current price does not specify what happens when that price is absent or stale, including outside trading hours. + +**Recommendation:** Track the union of watchlist and open positions, and define a bounded quote-acquisition path for new trade symbols. Specify quote validity, freshness, market-closed behavior and explicit rejection when a usable quote is unavailable. Distinguish source quote time from fetch time. Valuation should expose unavailable/stale values rather than silently treating a holding as worthless. Rebuild tracked symbols from persisted state on startup. + +### 4. Fractional trading needs a precision and validation policy + +**References:** Sections 7, 8 and 12; Section 13 item 5. + +SQLite `REAL` balances, prices, quantities and average costs leave rounding rules unspecified. Fractional operations can produce residual holdings or tiny negative balances. The plan also omits rejection of non-finite numeric values and maximum input bounds. + +**Recommendation:** Define supported quantity and monetary precision, a fixed-point or decimal storage/calculation strategy, rounding at each boundary and the exact zero-position rule. Validate positive, finite, bounded quantities and prices in the common trade service. Include fractional buys, partial sales, full liquidation and repeated operations in acceptance tests. + +### 5. Chat action outcomes and execution order are ambiguous + +**References:** Section 9 and Section 13 items 4 and 8. + +The model produces its message before execution, so it can claim a trade succeeded even when backend validation fails. The sentence saying the error is returned "so the LLM can inform the user" does not match the one-call sequence. Multiple trades and watchlist changes also lack ordering and partial-failure rules; selling to fund a purchase makes order significant. + +**Recommendation:** Define ordered action execution and choose whether batches are atomic or allow partial success. Return a typed result for every action with its actual status, error and executed quantity/price. Make the UI derive execution confirmations from those results. Either add a second model call to summarize outcomes or explicitly label the first message as commentary generated before execution. Define timeout, malformed-output and missing-key behavior, with no mutations before complete schema validation. + +## Integration findings + +### 6. The frontend contract is incomplete, especially after reload + +**References:** Sections 6, 8 and 10; Section 13 items 2–4; `app/market/models.py` and `stream.py`. + +The SSE implementation sends a map of ticker snapshots when the cache version changes; the main plan instead describes each event as an individual ticker update at a regular cadence. `change_percent` is change from the preceding update, not daily change. These differences can produce incompatible frontend parsing and misleading labels. + +Chat messages are persisted, but the API has no retrieval endpoint for restoring the conversation after a reload. Similarly, the detailed ticker chart needs an explicit decision about whether it contains only prices received since page load. + +**Recommendation:** Publish canonical request/response examples and typed schemas for every endpoint, including timestamps, errors and per-action outcomes. Incorporate the actual SSE map format into Section 6. Rename tick change or add a defined session baseline. Add bounded chat-history retrieval, specify chart-history scope and define how the client refreshes portfolio/watchlist state after actions and on SSE reconnect. Define heartbeats so a healthy but quiet feed is distinguishable from a stalled connection. Cache removal currently does not increment `version`; ensure removal can notify clients, including when the final ticker disappears. + +### 7. Startup and persistence responsibilities are inconsistent + +**References:** Sections 4, 5, 7 and 11; Section 13 items 10–12. + +Initialization is variously described as lazy, on startup, or on the first request. Background price tracking and snapshots need seeded state before requests arrive. The database path is relative and ambiguous in local development. The deployment text also conflates a named Docker volume with a bind mount of the project-root `db/` directory; the shown command uses the former. + +**Recommendation:** Initialize schema and seed data once in FastAPI lifespan before starting background tasks; make initialization transactional and idempotent. Define an absolute resolved `DB_PATH`, local and container defaults, and environment-loading precedence. Explicitly choose named-volume persistence or a bind mount. Document a single application worker while cache and scheduler state remain process-local. Bound history responses and define snapshot retention. Record an initial snapshot so a fresh portfolio has a chart baseline. + +### 8. No-login deployment needs an explicit access boundary + +**References:** Sections 2, 9 and 11. + +The local single-user demo has no authentication, while optional cloud deployment exposes mutation endpoints sharing one portfolio and a server-funded model API key. An accessible deployment would let unrelated callers mutate state and consume model usage. Simulated money does not remove that cost. + +**Recommendation:** Scope the core application to trusted local access and bind the local Docker port to loopback. If cloud hosting is implemented, require an access gate and model request limits. Define missing-key behavior so the simulator and manual trading remain usable without chat credentials, consistent with the first-launch experience. + +### 9. Portfolio P&L and treemap weight need explicit definitions + +**References:** Sections 2, 7, 8 and 10. + +The plan calls the snapshot chart a P&L chart but stores and displays total portfolio value. It does not define total return, realized P&L, the positions-table percentage baseline, or whether treemap weights include cash. After selling a profitable position, unrealized P&L alone no longer describes the portfolio's gain; a cash-only portfolio also has no position rectangles to display. + +**Recommendation:** Define equity as cash plus marked position values, total P&L as equity minus the initial $10,000 (while deposits and withdrawals remain unsupported), and unrealized P&L as quantity times current price minus average cost. Specify the percentage denominators and whether the chart shows equity or profit. Define cash inclusion and an empty-positions state for the treemap. Test a profitable full sale and a cash-only account. + +### 10. Mock chat alone does not make end-to-end tests deterministic + +**References:** Section 12; `app/market/simulator.py`. + +`LLM_MOCK=true` controls model output, but prices still move using both NumPy and Python random generators, and a persisted Docker volume may retain cash, holdings and history from an earlier run. The same mocked buy can therefore have a different fill price or fail against leftover state. + +**Recommendation:** Give each test run an isolated fresh database and an injectable fixed-price market source for arithmetic assertions. Test streaming movement separately, with bounded expectations or controlled randomness. Specify mock message-to-action fixtures and disable external provider calls in the E2E environment. Include container restart persistence as a separate scenario using an intentionally retained volume. + +## Decisions already raised in Section 13 + +- Promote tracked-symbol ownership, daily-change semantics, SSE shape, REST schemas, ticker validation, chat-history depth, model failure behavior, deterministic mocks, retention and configuration paths into normative requirements. +- Keep UUID removal, chart-library consolidation, script reduction and E2E harness simplification as optional design choices. They are not blockers. +- Do not replace periodic snapshots solely with history-request snapshots without accepting the changed behavior: periods with no browser requests would lose portfolio history. +- Correct the blanket `user_id` statement to acknowledge the profile primary key, and acknowledge both market-data and snapshot background tasks. +- Resolve the canvas/SVG chart-library wording and the two different paid polling ranges. Make the polling interval an actual supported configuration; the current factory always uses the default. +- Section 9 depends on a `cerebras-inference` skill that is not among this session's available skills. Provide an accessible implementation reference or make the required integration contract self-contained. This review did not verify external provider capabilities or lifecycle dates asserted in the plan. + +## Suggested implementation sequence + +1. Amend the specification with quote, precision, transaction, idempotency and chat-batch decisions. +2. Define API schemas and examples, including chat history and error outcomes. +3. Implement database lifespan and the common trade service with concurrency and retry tests. +4. Integrate market-data tracking, quote freshness and snapshot recording. +5. Implement chat execution and deterministic mocks, then frontend and container E2E flows. + +## Output-location limitation + +The requested project-root `planning/REVIEW.md` is outside this session's writable root (`backend/`). This review is saved at `backend/planning/REVIEW.md` instead. The source plan was read from the parent project's `planning/` directory and was not modified. diff --git a/independent-reviewer/.claude-plugin/plugin.json b/independent-reviewer/.claude-plugin/plugin.json new file mode 100644 index 000000000..4292cb325 --- /dev/null +++ b/independent-reviewer/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ +"name": "independent-reviewer", +"description": "carry out an independent review of all changes since last commit", +"version": "1.0.0" +} \ No newline at end of file diff --git a/independent-reviewer/hooks/hooks.json b/independent-reviewer/hooks/hooks.json new file mode 100644 index 000000000..7be35f94c --- /dev/null +++ b/independent-reviewer/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "codex exec \"please review the file planning/PLAN.md and write results to a file planning/REVIEW.md\"" + } + ] + } + ] + } +} + diff --git a/planning/PLAN.md b/planning/PLAN.md index bc1811b33..7ca8bf6a4 100644 --- a/planning/PLAN.md +++ b/planning/PLAN.md @@ -454,3 +454,43 @@ The container is designed to deploy to AWS App Runner, Render, or any container - Portfolio visualization: heatmap renders with correct colors, P&L chart has data points - AI chat (mocked): send a message, receive a response, trade execution appears inline - SSE resilience: disconnect and verify reconnection + +--- + +## 13. Review: Questions, Clarifications and Feedback + +### Contract gaps (resolve before backend/frontend work starts) + +1. **Held tickers vs. watchlist.** Section 6 says streamed tickers equal the watchlist, but `SimulatorDataSource.remove_ticker` also evicts the ticker from the price cache. Removing a held ticker from the watchlist (or the AI buying an unwatched ticker) leaves a position with no price. Proposal: tracked tickers = watchlist ∪ open positions; stop tracking only when a ticker is in neither. +2. **"Daily change %" has no source.** The simulator has no session open, and `change_percent` in the SSE payload is tick-over-tick (~500ms). Define it as change since the seed/start price, or drop "daily". +3. **SSE payload shape.** Document the actual format: one event per tick carrying a dict of all tickers, `{"AAPL": {"ticker", "price", "previous_price", "timestamp", "change", "change_percent", "direction"}, ...}`. Events are sent only when the cache version changes, not on a fixed cadence. +4. **REST shapes.** Section 8 lists no request/response bodies, status codes or error format. Define at least: `GET /api/portfolio` response, trade error response (e.g. 400 `{"error": "..."}`), `POST /api/chat` response including per-action success/failure, and `DELETE /api/watchlist/{ticker}` for an unknown ticker. + +### Questions + +5. **Trade validation.** How are quantity <= 0, unknown tickers (no cached price) and fractional precision handled? Is a position row deleted when quantity reaches 0? Does buying an unwatched ticker add it to the watchlist? +6. **Watchlist validation.** The simulator accepts any string and invents a price; Massive never returns one for an invalid symbol. What should happen to an unknown ticker in each mode? +7. **Chat history depth.** How many prior messages are sent to the LLM (e.g. last 20)? +8. **LLM failures.** What does `/api/chat` return on timeout, malformed JSON, or a missing API key with `LLM_MOCK=false`? +9. **Mock LLM behavior.** E2E tests need defined mock responses, e.g. a message containing "buy" returns a buy of 1 AAPL. +10. **Snapshot retention.** 30s snapshots produce ~2,880 rows/day with no retention rule or query limit on `/api/portfolio/history`. Acceptable for a demo? +11. **Local DB path.** Where is `finally.db` when running `uv run` from `backend/` outside Docker? Consider a `DB_PATH` env var. +12. **`.env` source of truth.** Section 5 says the backend reads `.env` from the project root; Section 11 passes `--env-file`. Pick one, and state whether local dev loads `.env` itself. + +### Clarifications and corrections + +13. `users_profile` has no `user_id` column, contradicting "All tables include a `user_id` column". +14. Section 3 lists one background task; the snapshot recorder is a second one. +15. `docker-compose.yml` appears in the directory tree but is never described. +16. Recharts is SVG-based, not canvas-based (Section 10). Lightweight Charts is canvas. +17. Node 20 reached end-of-life in April 2026; use a current LTS (Node 22 or 24). +18. Massive paid-tier polling is "2-15s" here but "2-5s" in `massive_client.py`. + +### Opportunities to simplify + +19. **Drop `id` UUIDs on tables with a natural key.** `watchlist` and `positions` are unique on `(user_id, ticker)`; use that as the primary key. Same for `users_profile` (just `user_id`). +20. **Record snapshots only after trades, plus on each history request.** Removes the second background task; the P&L chart can append live points on the frontend from SSE prices. +21. **One charting library.** Use Lightweight Charts for sparklines, the main chart and the P&L chart; only the treemap needs something else. +22. **Collapse start/stop scripts.** `docker compose up -d` / `down` already are idempotent and cross-platform; four scripts could become two thin wrappers, or just documented commands. +23. **Trim the E2E harness.** Run Playwright on the host against the running container instead of a dedicated `docker-compose.test.yml` with a Playwright container. +24. **Return portfolio state from the trade and chat endpoints.** Avoids a follow-up `GET /api/portfolio` round-trip after every action. diff --git a/planning/REVIEW.md b/planning/REVIEW.md new file mode 100644 index 000000000..d814fb2ca --- /dev/null +++ b/planning/REVIEW.md @@ -0,0 +1,50 @@ +# Review of planning/PLAN.md + +Reviewed 2026-10-03 against the plan, implemented market data code, its tests, and repository configuration. Static review only; no tests or external provider checks were run. Portfolio, chat, frontend, and deployment components remain planned, so findings about them concern design requirements rather than implemented defects. + +The architecture is suitable for the single-user demo, but execution and persistence contracts need resolution before implementation. Section 13 already identifies important gaps, especially held-ticker tracking, API schemas, and validation. Its proposals should not be treated as settled decisions. + +## High priority + +1. **Trade writes need an atomic transaction and a concurrency policy (Sections 7–9).** Validation, balance changes, position changes, trade logging, and snapshots have no defined transaction boundary. Overlapping manual/chat buys could both validate against the same cash balance; failures between writes could leave the ledger inconsistent. Require validation and associated database writes in one serialized write transaction with rollback on failure. Read one quote per fill and retain it throughout the transaction. Test concurrent buys and injected write failures. + +2. **Retries can execute trades twice (Sections 8–9).** A request may commit even if its HTTP response is lost. Retrying it can repeat the trade or every chat action. Define a client request identifier, persisted deduplication, and replay of the original result. For chat, define recovery after some actions commit but before the final response is stored. Test duplicate submissions and response loss. + +3. **Adding a ticker does not guarantee readiness to trade (Sections 6 and 9).** `SimulatorDataSource.add_ticker()` seeds a quote immediately, but `MassiveDataSource.add_ticker()` only includes it in the next poll. Applying watchlist additions before trades therefore does not solve add-and-buy requests in both modes. Define action order, bounded quote readiness, and whether batches permit partial success. Return each action's actual fill or error. Cover unwatched tickers and sell-then-buy batches. + +4. **Polling frequency does not guarantee fresh quotes (Section 6).** `MassiveDataSource._poll_once()` retains old cache entries on failures and uses the last trade timestamp. A 15-second polling interval does not bound quote age to 15 seconds. Specify acceptable quote age, handling of missing/stale quotes, and their effect on valuation and snapshots. Reject or explicitly label stale-price fills. Keep data freshness separate from the header's SSE connection status. + +5. **Financial precision and accounting rules are missing (Section 7).** Money and fractional quantities use REAL. Define quantity precision, monetary rounding, weighted-average cost on buys, cost basis on partial sells, and residual quantities on liquidation. Apply one consistent decimal or scaled-integer policy across validation, persistence, and responses. Test fractional trades and purchases whose rounded cost exactly equals available cash. + +6. **The completed LLM call cannot narrate later execution errors (Section 9).** The plan says a failed trade's error is returned so the LLM can inform the user, but its only model call precedes execution. Generated text can claim success when validation later fails. Use a bounded follow-up call or render authoritative action results separately. Include actual execution outcomes in subsequent conversation history, not only assistant text. + +7. **Initialization and task ordering need one owner (Sections 4 and 7).** Lazy initialization conflicts with background tasks needing seeded state. Define startup order: initialize/seed transactionally, load watchlist plus held tickers, start the data source, then snapshots; stop tasks on shutdown. Preserve an intentionally empty watchlist and existing balances. Specify one application worker: multiple workers otherwise own independent caches and pollers and may duplicate snapshots. + +## Medium priority + +8. **Simulator restart continuity is undefined (Sections 6–7).** SQLite preserves holdings and snapshots, but `GBMSimulator` restarts from seed prices. Restarting or removing and re-adding a ticker can create abrupt valuation jumps. Choose whether simulation prices persist or reset, explain the behavior in the UI, and test restart with open positions. + +9. **Persisted chat history has no retrieval API (Sections 7–10).** Only `POST /api/chat` is listed. Define a read endpoint or bootstrap response, stable ordering, and bounded retrieval so reloads restore messages and action results. + +10. **Ticker normalization differs by provider.** Massive additions strip whitespace and uppercase symbols; its startup path simply copies the list, while the simulator and cache do not normalize. Normalize at a common boundary for manual requests, model actions, persisted state, and startup. Otherwise `aapl` becomes a separate simulated asset with a random seed price. Test lowercase and whitespace inputs in both modes. + +11. **SSE empty-state and idle behavior are incomplete (Section 6).** `PriceCache.remove()` does not increment the version, and `_generate_events()` never emits empty snapshots or periodic heartbeats. Removing the final tracked ticker cannot clear client state through the stream; an idle stream has no regular traffic. Define empty snapshots and heartbeat comments. Test final-ticker removal and reconnecting to an empty cache. Stream connectivity must not imply provider health. + +12. **Docker storage documentation contradicts the command (Section 11).** `-v finally-data:/app/db` mounts a named volume, not the repository's `db/` directory. Choose a named volume or host bind mount, document where data lives, and align scripts and local paths. Ignore the runtime database and SQLite sidecars; `.gitignore` currently excludes only `db.sqlite3`. + +## Additional implementation notes + +- Section 9 provides an example object, not a formal JSON schema. Define allowed sides/actions, finite positive quantities, unknown-field handling, and defaults for omitted arrays. Validate model output before executing it. +- Register API routes before mounting static files at `/`. Define static 404/fallback behavior while preserving API errors for unknown `/api/*` paths. +- Define the positions table percentage, preferably unrealized return against average cost. Section 13 separately covers the missing watchlist daily-change definition. +- `.env.example` and the planned runtime `db/` directory are absent. Include them as implementation deliverables. Mock mode should start without an OpenRouter key. +- `create_stream_router()` adds routes to a module-level router; repeated calls register duplicate routes with different cache closures. Construct the router inside the factory. +- Public deployment would allow anyone with access to spend paid chat credit and mutate the shared portfolio. Define access restrictions before deploying publicly. This exposes use of the server-side key, not necessarily its value. + +## Acceptance checks + +Extend Section 12 with concurrent and duplicate trades; partial chat failure; stale/missing quotes; add-and-buy in both providers; fractional/full-liquidation accounting; empty portfolio/watchlist rendering; reload restoring chat; and restart preserving balances, holdings, and the chosen simulation semantics. + +Use fixed quotes for exact accounting assertions. A random seed alone does not make wall-clock E2E execution deterministic because requests can occur after different numbers of simulator ticks. Keep live-simulator checks focused on streaming and UI updates. + +Resolve findings 1–7 together with Section 13's contract gaps before backend/frontend integration. They determine whether the UI can accurately report what executed and what the portfolio is worth. From fa8c3601d05ce82e63ee84ca536c95323f13e7f5 Mon Sep 17 00:00:00 2001 From: EmmanuelObaisi <103223837+EmmanuelObaisi@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:11:05 +0100 Subject: [PATCH 2/3] Git CLI Install --- .claude-plugin/marketplace.json | 18 - backend/CLAUDE.md | 59 - backend/README.md | 55 - backend/app/__init__.py | 1 - backend/app/market/__init__.py | 23 - backend/app/market/cache.py | 75 - backend/app/market/factory.py | 31 - backend/app/market/interface.py | 57 - backend/app/market/massive_client.py | 128 -- backend/app/market/models.py | 49 - backend/app/market/seed_prices.py | 47 - backend/app/market/simulator.py | 270 --- backend/app/market/stream.py | 87 - backend/market_data_demo.py | 272 --- backend/planning/REVIEW.md | 117 -- backend/pyproject.toml | 58 - backend/tests/__init__.py | 1 - backend/tests/conftest.py | 11 - backend/tests/market/__init__.py | 1 - backend/tests/market/test_cache.py | 103 -- backend/tests/market/test_factory.py | 79 - backend/tests/market/test_massive.py | 201 --- backend/tests/market/test_models.py | 77 - backend/tests/market/test_simulator.py | 131 -- backend/tests/market/test_simulator_source.py | 138 -- backend/uv.lock | 813 --------- .../.claude-plugin/plugin.json | 5 - independent-reviewer/hooks/hooks.json | 15 - planning/MARKET_DATA_SUMMARY.md | 104 -- planning/MARKET_INTERFACE.md | 529 ++++++ planning/MARKET_SIMULATOR.md | 354 ++++ planning/MASSIVE_API.md | 371 ++++ planning/REVIEW.md | 50 - planning/archive/MARKET_DATA_DESIGN.md | 1490 ----------------- planning/archive/MARKET_DATA_REVIEW.md | 173 -- planning/archive/MARKET_INTERFACE.md | 273 --- planning/archive/MARKET_SIMULATOR.md | 245 --- planning/archive/MASSIVE_API.md | 251 --- 38 files changed, 1254 insertions(+), 5508 deletions(-) delete mode 100644 .claude-plugin/marketplace.json delete mode 100644 backend/CLAUDE.md delete mode 100644 backend/README.md delete mode 100644 backend/app/__init__.py delete mode 100644 backend/app/market/__init__.py delete mode 100644 backend/app/market/cache.py delete mode 100644 backend/app/market/factory.py delete mode 100644 backend/app/market/interface.py delete mode 100644 backend/app/market/massive_client.py delete mode 100644 backend/app/market/models.py delete mode 100644 backend/app/market/seed_prices.py delete mode 100644 backend/app/market/simulator.py delete mode 100644 backend/app/market/stream.py delete mode 100644 backend/market_data_demo.py delete mode 100644 backend/planning/REVIEW.md delete mode 100644 backend/pyproject.toml delete mode 100644 backend/tests/__init__.py delete mode 100644 backend/tests/conftest.py delete mode 100644 backend/tests/market/__init__.py delete mode 100644 backend/tests/market/test_cache.py delete mode 100644 backend/tests/market/test_factory.py delete mode 100644 backend/tests/market/test_massive.py delete mode 100644 backend/tests/market/test_models.py delete mode 100644 backend/tests/market/test_simulator.py delete mode 100644 backend/tests/market/test_simulator_source.py delete mode 100644 backend/uv.lock delete mode 100644 independent-reviewer/.claude-plugin/plugin.json delete mode 100644 independent-reviewer/hooks/hooks.json delete mode 100644 planning/MARKET_DATA_SUMMARY.md create mode 100644 planning/MARKET_INTERFACE.md create mode 100644 planning/MARKET_SIMULATOR.md create mode 100644 planning/MASSIVE_API.md delete mode 100644 planning/REVIEW.md delete mode 100644 planning/archive/MARKET_DATA_DESIGN.md delete mode 100644 planning/archive/MARKET_DATA_REVIEW.md delete mode 100644 planning/archive/MARKET_INTERFACE.md delete mode 100644 planning/archive/MARKET_SIMULATOR.md delete mode 100644 planning/archive/MASSIVE_API.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json deleted file mode 100644 index b858fce4a..000000000 --- a/.claude-plugin/marketplace.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "emmanuel-tools", - "owner": { - "name": "Emmanuel", - "email": "emmanuelobaisi@gmail.com" - }, - "plugins": [ - { - "name": "independent-reviewer", - "source": "./independent-reviewer", - "description": "Carry out an independent review of all changes since last review", - "version": "1.0.0", - "author": { - "name": "Emmanuel" - } - } - ] -} diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md deleted file mode 100644 index 612ff18f5..000000000 --- a/backend/CLAUDE.md +++ /dev/null @@ -1,59 +0,0 @@ -# Backend — Developer Guide - -## Project Setup - -```bash -cd backend -uv sync --extra dev # Install all dependencies including test/lint tools -``` - -## Market Data API - -The market data subsystem lives in `app/market/`. Use these imports: - -```python -from app.market import PriceCache, PriceUpdate, MarketDataSource, create_market_data_source -``` - -### Core Types - -- **`PriceUpdate`** — Immutable dataclass: `ticker`, `price`, `previous_price`, `timestamp`, plus properties `change`, `change_percent`, `direction` ("up"/"down"/"flat"), and `to_dict()` for JSON serialization. - -- **`PriceCache`** — Thread-safe in-memory store. Key methods: - - `update(ticker, price, timestamp=None) -> PriceUpdate` - - `get(ticker) -> PriceUpdate | None` - - `get_price(ticker) -> float | None` - - `get_all() -> dict[str, PriceUpdate]` - - `remove(ticker)` - - `version` property — monotonic counter, increments on every update (for SSE change detection) - -- **`MarketDataSource`** — Abstract interface implemented by `SimulatorDataSource` and `MassiveDataSource`. Lifecycle: `start(tickers)` -> `add_ticker()` / `remove_ticker()` -> `stop()`. - -- **`create_market_data_source(cache)`** — Factory. Returns `MassiveDataSource` if `MASSIVE_API_KEY` is set, otherwise `SimulatorDataSource`. - -### SSE Streaming - -```python -from app.market import create_stream_router - -router = create_stream_router(price_cache) # Returns FastAPI APIRouter -# Endpoint: GET /api/stream/prices (text/event-stream) -``` - -### Seed Data - -Default tickers: AAPL, GOOGL, MSFT, AMZN, TSLA, NVDA, META, JPM, V, NFLX. Seed prices and per-ticker volatility/drift params are in `app/market/seed_prices.py`. - -## Running Tests - -```bash -uv run --extra dev pytest -v # All tests -uv run --extra dev pytest --cov=app # With coverage -uv run --extra dev ruff check app/ tests/ # Lint -``` - -## Demo - -```bash -uv run market_data_demo.py # Live terminal dashboard with simulated prices -``` diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index 863e93f2e..000000000 --- a/backend/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# FinAlly Backend - -FastAPI backend for the FinAlly AI Trading Workstation. - -## Structure - -- `app/` - Application code - - `market/` - Market data subsystem - - `models.py` - PriceUpdate dataclass - - `cache.py` - Thread-safe price cache - - `interface.py` - MarketDataSource abstract interface - - `simulator.py` - GBM-based market simulator - - `massive_client.py` - Massive/Polygon.io API client - - `factory.py` - Data source factory - - `stream.py` - SSE streaming endpoint - - `seed_prices.py` - Default ticker prices and parameters - -- `tests/` - Unit and integration tests - - `market/` - Market data tests - -## Running Tests - -```bash -# Install dependencies -uv sync --extra dev - -# Run all tests -uv run pytest - -# Run with coverage -uv run pytest --cov=app --cov-report=html - -# Run specific test file -uv run pytest tests/market/test_simulator.py - -# Run with verbose output -uv run pytest -v -``` - -## Environment Variables - -- `MASSIVE_API_KEY` - Optional. If set, use real market data from Massive API. If not set, use the built-in simulator. - -## Development - -```bash -# Install dependencies -uv sync --extra dev - -# Run linter -uv run ruff check . - -# Format code -uv run ruff format . -``` diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index 4f6b7f6b6..000000000 --- a/backend/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""FinAlly backend application.""" diff --git a/backend/app/market/__init__.py b/backend/app/market/__init__.py deleted file mode 100644 index 57ad0a121..000000000 --- a/backend/app/market/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Market data subsystem for FinAlly. - -Public API: - PriceUpdate - Immutable price snapshot dataclass - PriceCache - Thread-safe in-memory price store - MarketDataSource - Abstract interface for data providers - create_market_data_source - Factory that selects simulator or Massive - create_stream_router - FastAPI router factory for SSE endpoint -""" - -from .cache import PriceCache -from .factory import create_market_data_source -from .interface import MarketDataSource -from .models import PriceUpdate -from .stream import create_stream_router - -__all__ = [ - "PriceUpdate", - "PriceCache", - "MarketDataSource", - "create_market_data_source", - "create_stream_router", -] diff --git a/backend/app/market/cache.py b/backend/app/market/cache.py deleted file mode 100644 index 4d0215778..000000000 --- a/backend/app/market/cache.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Thread-safe in-memory price cache.""" - -from __future__ import annotations - -import time -from threading import Lock - -from .models import PriceUpdate - - -class PriceCache: - """Thread-safe in-memory cache of the latest price for each ticker. - - Writers: SimulatorDataSource or MassiveDataSource (one at a time). - Readers: SSE streaming endpoint, portfolio valuation, trade execution. - """ - - def __init__(self) -> None: - self._prices: dict[str, PriceUpdate] = {} - self._lock = Lock() - self._version: int = 0 # Monotonically increasing; bumped on every update - - def update(self, ticker: str, price: float, timestamp: float | None = None) -> PriceUpdate: - """Record a new price for a ticker. Returns the created PriceUpdate. - - Automatically computes direction and change from the previous price. - If this is the first update for the ticker, previous_price == price (direction='flat'). - """ - with self._lock: - ts = timestamp or time.time() - prev = self._prices.get(ticker) - previous_price = prev.price if prev else price - - update = PriceUpdate( - ticker=ticker, - price=round(price, 2), - previous_price=round(previous_price, 2), - timestamp=ts, - ) - self._prices[ticker] = update - self._version += 1 - return update - - def get(self, ticker: str) -> PriceUpdate | None: - """Get the latest price for a single ticker, or None if unknown.""" - with self._lock: - return self._prices.get(ticker) - - def get_all(self) -> dict[str, PriceUpdate]: - """Snapshot of all current prices. Returns a shallow copy.""" - with self._lock: - return dict(self._prices) - - def get_price(self, ticker: str) -> float | None: - """Convenience: get just the price float, or None.""" - update = self.get(ticker) - return update.price if update else None - - def remove(self, ticker: str) -> None: - """Remove a ticker from the cache (e.g., when removed from watchlist).""" - with self._lock: - self._prices.pop(ticker, None) - - @property - def version(self) -> int: - """Current version counter. Useful for SSE change detection.""" - return self._version - - def __len__(self) -> int: - with self._lock: - return len(self._prices) - - def __contains__(self, ticker: str) -> bool: - with self._lock: - return ticker in self._prices diff --git a/backend/app/market/factory.py b/backend/app/market/factory.py deleted file mode 100644 index 00360e94f..000000000 --- a/backend/app/market/factory.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Factory for creating market data sources.""" - -from __future__ import annotations - -import logging -import os - -from .cache import PriceCache -from .interface import MarketDataSource -from .massive_client import MassiveDataSource -from .simulator import SimulatorDataSource - -logger = logging.getLogger(__name__) - - -def create_market_data_source(price_cache: PriceCache) -> MarketDataSource: - """Create the appropriate market data source based on environment variables. - - - MASSIVE_API_KEY set and non-empty → MassiveDataSource (real market data) - - Otherwise → SimulatorDataSource (GBM simulation) - - Returns an unstarted source. Caller must await source.start(tickers). - """ - api_key = os.environ.get("MASSIVE_API_KEY", "").strip() - - if api_key: - logger.info("Market data source: Massive API (real data)") - return MassiveDataSource(api_key=api_key, price_cache=price_cache) - else: - logger.info("Market data source: GBM Simulator") - return SimulatorDataSource(price_cache=price_cache) diff --git a/backend/app/market/interface.py b/backend/app/market/interface.py deleted file mode 100644 index 0f3b7d8c9..000000000 --- a/backend/app/market/interface.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Abstract interface for market data sources.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class MarketDataSource(ABC): - """Contract for market data providers. - - Implementations push price updates into a shared PriceCache on their own - schedule. Downstream code never calls the data source directly for prices — - it reads from the cache. - - Lifecycle: - source = create_market_data_source(cache) - await source.start(["AAPL", "GOOGL", ...]) - # ... app runs ... - await source.add_ticker("TSLA") - await source.remove_ticker("GOOGL") - # ... app shutting down ... - await source.stop() - """ - - @abstractmethod - async def start(self, tickers: list[str]) -> None: - """Begin producing price updates for the given tickers. - - Starts a background task that periodically writes to the PriceCache. - Must be called exactly once. Calling start() twice is undefined behavior. - """ - - @abstractmethod - async def stop(self) -> None: - """Stop the background task and release resources. - - Safe to call multiple times. After stop(), the source will not write - to the cache again. - """ - - @abstractmethod - async def add_ticker(self, ticker: str) -> None: - """Add a ticker to the active set. No-op if already present. - - The next update cycle will include this ticker. - """ - - @abstractmethod - async def remove_ticker(self, ticker: str) -> None: - """Remove a ticker from the active set. No-op if not present. - - Also removes the ticker from the PriceCache. - """ - - @abstractmethod - def get_tickers(self) -> list[str]: - """Return the current list of actively tracked tickers.""" diff --git a/backend/app/market/massive_client.py b/backend/app/market/massive_client.py deleted file mode 100644 index 00bc7b2aa..000000000 --- a/backend/app/market/massive_client.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Massive (Polygon.io) API client for real market data.""" - -from __future__ import annotations - -import asyncio -import logging - -from massive import RESTClient -from massive.rest.models import SnapshotMarketType - -from .cache import PriceCache -from .interface import MarketDataSource - -logger = logging.getLogger(__name__) - - -class MassiveDataSource(MarketDataSource): - """MarketDataSource backed by the Massive (Polygon.io) REST API. - - Polls GET /v2/snapshot/locale/us/markets/stocks/tickers for all watched - tickers in a single API call, then writes results to the PriceCache. - - Rate limits: - - Free tier: 5 req/min → poll every 15s (default) - - Paid tiers: higher limits → poll every 2-5s - """ - - def __init__( - self, - api_key: str, - price_cache: PriceCache, - poll_interval: float = 15.0, - ) -> None: - self._api_key = api_key - self._cache = price_cache - self._interval = poll_interval - self._tickers: list[str] = [] - self._task: asyncio.Task | None = None - self._client: RESTClient | None = None - - async def start(self, tickers: list[str]) -> None: - self._client = RESTClient(api_key=self._api_key) - self._tickers = list(tickers) - - # Do an immediate first poll so the cache has data right away - await self._poll_once() - - self._task = asyncio.create_task(self._poll_loop(), name="massive-poller") - logger.info( - "Massive poller started: %d tickers, %.1fs interval", - len(tickers), - self._interval, - ) - - async def stop(self) -> None: - if self._task and not self._task.done(): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - self._client = None - logger.info("Massive poller stopped") - - async def add_ticker(self, ticker: str) -> None: - ticker = ticker.upper().strip() - if ticker not in self._tickers: - self._tickers.append(ticker) - logger.info("Massive: added ticker %s (will appear on next poll)", ticker) - - async def remove_ticker(self, ticker: str) -> None: - ticker = ticker.upper().strip() - self._tickers = [t for t in self._tickers if t != ticker] - self._cache.remove(ticker) - logger.info("Massive: removed ticker %s", ticker) - - def get_tickers(self) -> list[str]: - return list(self._tickers) - - # --- Internal --- - - async def _poll_loop(self) -> None: - """Poll on interval. First poll already happened in start().""" - while True: - await asyncio.sleep(self._interval) - await self._poll_once() - - async def _poll_once(self) -> None: - """Execute one poll cycle: fetch snapshots, update cache.""" - if not self._tickers or not self._client: - return - - try: - # The Massive RESTClient is synchronous — run in a thread to - # avoid blocking the event loop. - snapshots = await asyncio.to_thread(self._fetch_snapshots) - processed = 0 - for snap in snapshots: - try: - price = snap.last_trade.price - # Massive timestamps are Unix milliseconds → convert to seconds - timestamp = snap.last_trade.timestamp / 1000.0 - self._cache.update( - ticker=snap.ticker, - price=price, - timestamp=timestamp, - ) - processed += 1 - except (AttributeError, TypeError) as e: - logger.warning( - "Skipping snapshot for %s: %s", - getattr(snap, "ticker", "???"), - e, - ) - logger.debug("Massive poll: updated %d/%d tickers", processed, len(self._tickers)) - - except Exception as e: - logger.error("Massive poll failed: %s", e) - # Don't re-raise — the loop will retry on the next interval. - # Common failures: 401 (bad key), 429 (rate limit), network errors. - - def _fetch_snapshots(self) -> list: - """Synchronous call to the Massive REST API. Runs in a thread.""" - return self._client.get_snapshot_all( - market_type=SnapshotMarketType.STOCKS, - tickers=self._tickers, - ) diff --git a/backend/app/market/models.py b/backend/app/market/models.py deleted file mode 100644 index de81b1dbc..000000000 --- a/backend/app/market/models.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Data models for market data.""" - -from __future__ import annotations - -import time -from dataclasses import dataclass, field - - -@dataclass(frozen=True, slots=True) -class PriceUpdate: - """Immutable snapshot of a single ticker's price at a point in time.""" - - ticker: str - price: float - previous_price: float - timestamp: float = field(default_factory=time.time) # Unix seconds - - @property - def change(self) -> float: - """Absolute price change from previous update.""" - return round(self.price - self.previous_price, 4) - - @property - def change_percent(self) -> float: - """Percentage change from previous update.""" - if self.previous_price == 0: - return 0.0 - return round((self.price - self.previous_price) / self.previous_price * 100, 4) - - @property - def direction(self) -> str: - """'up', 'down', or 'flat'.""" - if self.price > self.previous_price: - return "up" - elif self.price < self.previous_price: - return "down" - return "flat" - - def to_dict(self) -> dict: - """Serialize for JSON / SSE transmission.""" - return { - "ticker": self.ticker, - "price": self.price, - "previous_price": self.previous_price, - "timestamp": self.timestamp, - "change": self.change, - "change_percent": self.change_percent, - "direction": self.direction, - } diff --git a/backend/app/market/seed_prices.py b/backend/app/market/seed_prices.py deleted file mode 100644 index 69586df03..000000000 --- a/backend/app/market/seed_prices.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Seed prices and per-ticker parameters for the market simulator.""" - -# Realistic starting prices for the default watchlist (as of project creation) -SEED_PRICES: dict[str, float] = { - "AAPL": 190.00, - "GOOGL": 175.00, - "MSFT": 420.00, - "AMZN": 185.00, - "TSLA": 250.00, - "NVDA": 800.00, - "META": 500.00, - "JPM": 195.00, - "V": 280.00, - "NFLX": 600.00, -} - -# Per-ticker GBM parameters -# sigma: annualized volatility (higher = more price movement) -# mu: annualized drift / expected return -TICKER_PARAMS: dict[str, dict[str, float]] = { - "AAPL": {"sigma": 0.22, "mu": 0.05}, - "GOOGL": {"sigma": 0.25, "mu": 0.05}, - "MSFT": {"sigma": 0.20, "mu": 0.05}, - "AMZN": {"sigma": 0.28, "mu": 0.05}, - "TSLA": {"sigma": 0.50, "mu": 0.03}, # High volatility - "NVDA": {"sigma": 0.40, "mu": 0.08}, # High volatility, strong drift - "META": {"sigma": 0.30, "mu": 0.05}, - "JPM": {"sigma": 0.18, "mu": 0.04}, # Low volatility (bank) - "V": {"sigma": 0.17, "mu": 0.04}, # Low volatility (payments) - "NFLX": {"sigma": 0.35, "mu": 0.05}, -} - -# Default parameters for tickers not in the list above (dynamically added) -DEFAULT_PARAMS: dict[str, float] = {"sigma": 0.25, "mu": 0.05} - -# Correlation groups for the simulator's Cholesky decomposition -# Tickers in the same group have higher intra-group correlation -CORRELATION_GROUPS: dict[str, set[str]] = { - "tech": {"AAPL", "GOOGL", "MSFT", "AMZN", "META", "NVDA", "NFLX"}, - "finance": {"JPM", "V"}, -} - -# Correlation coefficients -INTRA_TECH_CORR = 0.6 # Tech stocks move together -INTRA_FINANCE_CORR = 0.5 # Finance stocks move together -CROSS_GROUP_CORR = 0.3 # Between sectors / unknown tickers -TSLA_CORR = 0.3 # TSLA does its own thing diff --git a/backend/app/market/simulator.py b/backend/app/market/simulator.py deleted file mode 100644 index b6803f592..000000000 --- a/backend/app/market/simulator.py +++ /dev/null @@ -1,270 +0,0 @@ -"""GBM-based market simulator.""" - -from __future__ import annotations - -import asyncio -import logging -import math -import random - -import numpy as np - -from .cache import PriceCache -from .interface import MarketDataSource -from .seed_prices import ( - CORRELATION_GROUPS, - CROSS_GROUP_CORR, - DEFAULT_PARAMS, - INTRA_FINANCE_CORR, - INTRA_TECH_CORR, - SEED_PRICES, - TICKER_PARAMS, - TSLA_CORR, -) - -logger = logging.getLogger(__name__) - - -class GBMSimulator: - """Geometric Brownian Motion simulator for correlated stock prices. - - Math: - S(t+dt) = S(t) * exp((mu - sigma^2/2) * dt + sigma * sqrt(dt) * Z) - - Where: - S(t) = current price - mu = annualized drift (expected return) - sigma = annualized volatility - dt = time step as fraction of a trading year - Z = correlated standard normal random variable - - The tiny dt (~8.5e-8 for 500ms ticks over 252 trading days * 6.5h/day) - produces sub-cent moves per tick that accumulate naturally over time. - """ - - # 500ms expressed as a fraction of a trading year - # 252 trading days * 6.5 hours/day * 3600 seconds/hour = 5,896,800 seconds - TRADING_SECONDS_PER_YEAR = 252 * 6.5 * 3600 # 5,896,800 - DEFAULT_DT = 0.5 / TRADING_SECONDS_PER_YEAR # ~8.48e-8 - - def __init__( - self, - tickers: list[str], - dt: float = DEFAULT_DT, - event_probability: float = 0.001, - ) -> None: - self._dt = dt - self._event_prob = event_probability - - # Per-ticker state - self._tickers: list[str] = [] - self._prices: dict[str, float] = {} - self._params: dict[str, dict[str, float]] = {} - - # Cholesky decomposition of the correlation matrix (for correlated moves) - self._cholesky: np.ndarray | None = None - - # Initialize all starting tickers - for ticker in tickers: - self._add_ticker_internal(ticker) - self._rebuild_cholesky() - - # --- Public API --- - - def step(self) -> dict[str, float]: - """Advance all tickers by one time step. Returns {ticker: new_price}. - - This is the hot path — called every 500ms. Keep it fast. - """ - n = len(self._tickers) - if n == 0: - return {} - - # Generate n independent standard normal draws - z_independent = np.random.standard_normal(n) - - # Apply Cholesky to get correlated draws - if self._cholesky is not None: - z_correlated = self._cholesky @ z_independent - else: - z_correlated = z_independent - - result: dict[str, float] = {} - for i, ticker in enumerate(self._tickers): - params = self._params[ticker] - mu = params["mu"] - sigma = params["sigma"] - - # GBM: S(t+dt) = S(t) * exp((mu - 0.5*sigma^2)*dt + sigma*sqrt(dt)*Z) - drift = (mu - 0.5 * sigma**2) * self._dt - diffusion = sigma * math.sqrt(self._dt) * z_correlated[i] - self._prices[ticker] *= math.exp(drift + diffusion) - - # Random event: ~0.1% chance per tick per ticker - # With 10 tickers at 2 ticks/sec, expect an event ~every 50 seconds - if random.random() < self._event_prob: - shock_magnitude = random.uniform(0.02, 0.05) - shock_sign = random.choice([-1, 1]) - self._prices[ticker] *= 1 + shock_magnitude * shock_sign - logger.debug( - "Random event on %s: %.1f%% %s", - ticker, - shock_magnitude * 100, - "up" if shock_sign > 0 else "down", - ) - - result[ticker] = round(self._prices[ticker], 2) - - return result - - def add_ticker(self, ticker: str) -> None: - """Add a ticker to the simulation. Rebuilds the correlation matrix.""" - if ticker in self._prices: - return - self._add_ticker_internal(ticker) - self._rebuild_cholesky() - - def remove_ticker(self, ticker: str) -> None: - """Remove a ticker from the simulation. Rebuilds the correlation matrix.""" - if ticker not in self._prices: - return - self._tickers.remove(ticker) - del self._prices[ticker] - del self._params[ticker] - self._rebuild_cholesky() - - def get_price(self, ticker: str) -> float | None: - """Current price for a ticker, or None if not tracked.""" - return self._prices.get(ticker) - - def get_tickers(self) -> list[str]: - """Return the list of currently tracked tickers.""" - return list(self._tickers) - - # --- Internals --- - - def _add_ticker_internal(self, ticker: str) -> None: - """Add a ticker without rebuilding Cholesky (for batch initialization).""" - if ticker in self._prices: - return - self._tickers.append(ticker) - self._prices[ticker] = SEED_PRICES.get(ticker, random.uniform(50.0, 300.0)) - self._params[ticker] = TICKER_PARAMS.get(ticker, dict(DEFAULT_PARAMS)) - - def _rebuild_cholesky(self) -> None: - """Rebuild the Cholesky decomposition of the ticker correlation matrix. - - Called whenever tickers are added or removed. O(n^2) but n < 50. - """ - n = len(self._tickers) - if n <= 1: - self._cholesky = None - return - - # Build the correlation matrix - corr = np.eye(n) - for i in range(n): - for j in range(i + 1, n): - rho = self._pairwise_correlation(self._tickers[i], self._tickers[j]) - corr[i, j] = rho - corr[j, i] = rho - - self._cholesky = np.linalg.cholesky(corr) - - @staticmethod - def _pairwise_correlation(t1: str, t2: str) -> float: - """Determine correlation between two tickers based on sector grouping. - - Correlation structure: - - Same tech sector: 0.6 - - Same finance sector: 0.5 - - TSLA with anything: 0.3 (it does its own thing) - - Cross-sector: 0.3 - - Unknown tickers: 0.3 - """ - tech = CORRELATION_GROUPS["tech"] - finance = CORRELATION_GROUPS["finance"] - - # TSLA is in tech set but behaves independently - if t1 == "TSLA" or t2 == "TSLA": - return TSLA_CORR - - if t1 in tech and t2 in tech: - return INTRA_TECH_CORR - if t1 in finance and t2 in finance: - return INTRA_FINANCE_CORR - - return CROSS_GROUP_CORR - - -class SimulatorDataSource(MarketDataSource): - """MarketDataSource backed by the GBM simulator. - - Runs a background asyncio task that calls GBMSimulator.step() every - `update_interval` seconds and writes results to the PriceCache. - """ - - def __init__( - self, - price_cache: PriceCache, - update_interval: float = 0.5, - event_probability: float = 0.001, - ) -> None: - self._cache = price_cache - self._interval = update_interval - self._event_prob = event_probability - self._sim: GBMSimulator | None = None - self._task: asyncio.Task | None = None - - async def start(self, tickers: list[str]) -> None: - self._sim = GBMSimulator( - tickers=tickers, - event_probability=self._event_prob, - ) - # Seed the cache with initial prices so SSE has data immediately - for ticker in tickers: - price = self._sim.get_price(ticker) - if price is not None: - self._cache.update(ticker=ticker, price=price) - self._task = asyncio.create_task(self._run_loop(), name="simulator-loop") - logger.info("Simulator started with %d tickers", len(tickers)) - - async def stop(self) -> None: - if self._task and not self._task.done(): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - logger.info("Simulator stopped") - - async def add_ticker(self, ticker: str) -> None: - if self._sim: - self._sim.add_ticker(ticker) - # Seed cache immediately so the ticker has a price right away - price = self._sim.get_price(ticker) - if price is not None: - self._cache.update(ticker=ticker, price=price) - logger.info("Simulator: added ticker %s", ticker) - - async def remove_ticker(self, ticker: str) -> None: - if self._sim: - self._sim.remove_ticker(ticker) - self._cache.remove(ticker) - logger.info("Simulator: removed ticker %s", ticker) - - def get_tickers(self) -> list[str]: - return self._sim.get_tickers() if self._sim else [] - - async def _run_loop(self) -> None: - """Core loop: step the simulation, write to cache, sleep.""" - while True: - try: - if self._sim: - prices = self._sim.step() - for ticker, price in prices.items(): - self._cache.update(ticker=ticker, price=price) - except Exception: - logger.exception("Simulator step failed") - await asyncio.sleep(self._interval) diff --git a/backend/app/market/stream.py b/backend/app/market/stream.py deleted file mode 100644 index 7fd974b7c..000000000 --- a/backend/app/market/stream.py +++ /dev/null @@ -1,87 +0,0 @@ -"""SSE streaming endpoint for live price updates.""" - -from __future__ import annotations - -import asyncio -import json -import logging -from collections.abc import AsyncGenerator - -from fastapi import APIRouter, Request -from fastapi.responses import StreamingResponse - -from .cache import PriceCache - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/stream", tags=["streaming"]) - - -def create_stream_router(price_cache: PriceCache) -> APIRouter: - """Create the SSE streaming router with a reference to the price cache. - - This factory pattern lets us inject the PriceCache without globals. - """ - - @router.get("/prices") - async def stream_prices(request: Request) -> StreamingResponse: - """SSE endpoint for live price updates. - - Streams all tracked ticker prices every ~500ms. The client connects - with EventSource and receives events in the format: - - data: {"AAPL": {"ticker": "AAPL", "price": 190.50, ...}, ...} - - Includes a retry directive so the browser auto-reconnects on - disconnection (EventSource built-in behavior). - """ - return StreamingResponse( - _generate_events(price_cache, request), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", # Disable nginx buffering if proxied - }, - ) - - return router - - -async def _generate_events( - price_cache: PriceCache, - request: Request, - interval: float = 0.5, -) -> AsyncGenerator[str, None]: - """Async generator that yields SSE-formatted price events. - - Sends all prices every `interval` seconds. Stops when the client - disconnects (detected via request.is_disconnected()). - """ - # Tell the client to retry after 1 second if the connection drops - yield "retry: 1000\n\n" - - last_version = -1 - client_ip = request.client.host if request.client else "unknown" - logger.info("SSE client connected: %s", client_ip) - - try: - while True: - # Check for client disconnect - if await request.is_disconnected(): - logger.info("SSE client disconnected: %s", client_ip) - break - - current_version = price_cache.version - if current_version != last_version: - last_version = current_version - prices = price_cache.get_all() - - if prices: - data = {ticker: update.to_dict() for ticker, update in prices.items()} - payload = json.dumps(data) - yield f"data: {payload}\n\n" - - await asyncio.sleep(interval) - except asyncio.CancelledError: - logger.info("SSE stream cancelled for: %s", client_ip) diff --git a/backend/market_data_demo.py b/backend/market_data_demo.py deleted file mode 100644 index 7414416c4..000000000 --- a/backend/market_data_demo.py +++ /dev/null @@ -1,272 +0,0 @@ -"""FinAlly Market Data Simulator Demo. - -Run with: uv run market_data_demo.py - -Displays a live-updating terminal dashboard of simulated stock prices -using the GBM simulator and Rich library. -""" - -from __future__ import annotations - -import asyncio -import time -from collections import deque - -from rich.console import Console -from rich.layout import Layout -from rich.live import Live -from rich.panel import Panel -from rich.table import Table -from rich.text import Text - -from app.market.cache import PriceCache -from app.market.seed_prices import SEED_PRICES -from app.market.simulator import SimulatorDataSource - -# Sparkline characters, low to high -SPARK_CHARS = "▁▂▃▄▅▆▇█" - -# Ordered ticker list matching the default watchlist -TICKERS = ["AAPL", "GOOGL", "MSFT", "AMZN", "TSLA", "NVDA", "META", "JPM", "V", "NFLX"] - -DURATION = 60 # seconds - - -def sparkline(values: list[float]) -> str: - """Render a sequence of values as a unicode sparkline.""" - if len(values) < 2: - return "" - lo, hi = min(values), max(values) - spread = hi - lo - if spread == 0: - return SPARK_CHARS[3] * len(values) - n = len(SPARK_CHARS) - 1 - return "".join(SPARK_CHARS[int((v - lo) / spread * n)] for v in values) - - -def format_price(price: float) -> str: - """Format a price with comma separator.""" - if price >= 1000: - return f"{price:,.2f}" - return f"{price:.2f}" - - -def build_table( - cache: PriceCache, - history: dict[str, deque], - elapsed: float, -) -> Table: - """Build the price table.""" - table = Table( - title=None, - expand=True, - border_style="bright_black", - header_style="bold bright_white", - pad_edge=True, - padding=(0, 1), - ) - table.add_column("Ticker", style="bold bright_white", width=8) - table.add_column("Price", justify="right", width=10) - table.add_column("Change", justify="right", width=9) - table.add_column("Chg %", justify="right", width=8) - table.add_column("", width=3) # arrow - table.add_column("Sparkline", width=42, no_wrap=True) - - for ticker in TICKERS: - update = cache.get(ticker) - if update is None: - table.add_row(ticker, "---", "---", "---", "", "") - continue - - # Direction styling - if update.direction == "up": - color = "green" - arrow = "[bold green]\u25b2[/]" - elif update.direction == "down": - color = "red" - arrow = "[bold red]\u25bc[/]" - else: - color = "bright_black" - arrow = "[bright_black]\u2500[/]" - - price_str = f"[{color}]${format_price(update.price)}[/]" - change_str = f"[{color}]{update.change:+.2f}[/]" - pct_str = f"[{color}]{update.change_percent:+.2f}%[/]" - - # Sparkline from history - vals = list(history.get(ticker, [])) - spark_str = f"[bright_cyan]{sparkline(vals)}[/]" if len(vals) > 1 else "" - - table.add_row(ticker, price_str, change_str, pct_str, arrow, spark_str) - - return table - - -def build_event_log(events: deque) -> Panel: - """Build the event log panel.""" - text = Text() - for evt in events: - text.append(evt) - text.append("\n") - if not events: - text.append("Watching for notable moves (>1% change)...", style="bright_black italic") - return Panel( - text, - title="[bold bright_yellow]Recent Events[/]", - border_style="bright_black", - height=8, - ) - - -def build_dashboard( - cache: PriceCache, - history: dict[str, deque], - events: deque, - start_time: float, -) -> Layout: - """Build the full dashboard layout.""" - elapsed = time.time() - start_time - remaining = max(0, DURATION - elapsed) - - layout = Layout() - layout.split_column( - Layout(name="header", size=3), - Layout(name="body"), - Layout(name="footer", size=10), - ) - - # Header - header_text = Text.assemble( - (" FinAlly ", "bold bright_yellow"), - ("Market Data Simulator", "bold bright_white"), - (" | ", "bright_black"), - (f"{elapsed:5.1f}s elapsed", "bright_cyan"), - (" | ", "bright_black"), - (f"{remaining:4.1f}s remaining", "bright_cyan"), - (" | ", "bright_black"), - (f"{len(cache)} tickers", "bright_white"), - (" | ", "bright_black"), - ("Ctrl+C to exit", "bright_black italic"), - ) - layout["header"].update(Panel(header_text, border_style="bright_yellow")) - - # Body: price table - layout["body"].update( - Panel( - build_table(cache, history, elapsed), - title="[bold bright_white]Live Prices[/]", - border_style="bright_black", - ) - ) - - # Footer: event log - layout["footer"].update(build_event_log(events)) - - return layout - - -def print_summary(cache: PriceCache) -> None: - """Print final summary comparing to seed prices.""" - console = Console() - console.print() - console.print("[bold bright_yellow] FinAlly[/] [bold]Session Summary[/]") - console.print() - - table = Table(border_style="bright_black", header_style="bold bright_white", expand=False) - table.add_column("Ticker", style="bold bright_white", width=8) - table.add_column("Seed Price", justify="right", width=12) - table.add_column("Final Price", justify="right", width=12) - table.add_column("Session Change", justify="right", width=14) - - for ticker in TICKERS: - seed = SEED_PRICES.get(ticker, 0) - update = cache.get(ticker) - if update is None: - continue - final = update.price - session_change = ((final - seed) / seed) * 100 if seed else 0 - - if session_change > 0: - color = "green" - elif session_change < 0: - color = "red" - else: - color = "bright_black" - - table.add_row( - ticker, - f"${format_price(seed)}", - f"[{color}]${format_price(final)}[/]", - f"[{color}]{session_change:+.2f}%[/]", - ) - - console.print(table) - console.print() - - -async def run() -> None: - """Main demo loop.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.5) - - # Per-ticker price history for sparklines - history: dict[str, deque] = {t: deque(maxlen=40) for t in TICKERS} - - # Recent event log - events: deque = deque(maxlen=12) - - await source.start(TICKERS) - start_time = time.time() - - # Seed initial history points - for ticker in TICKERS: - update = cache.get(ticker) - if update: - history[ticker].append(update.price) - - try: - with Live( - build_dashboard(cache, history, events, start_time), - refresh_per_second=4, - screen=True, - ) as live: - last_version = cache.version - while time.time() - start_time < DURATION: - await asyncio.sleep(0.25) - - # Check for updates - if cache.version == last_version: - continue - last_version = cache.version - - # Record history & detect events - for ticker in TICKERS: - update = cache.get(ticker) - if update is None: - continue - history[ticker].append(update.price) - - # Log notable moves - if abs(update.change_percent) > 1.0: - direction = "\u25b2" if update.direction == "up" else "\u25bc" - color = "green" if update.direction == "up" else "red" - timestamp = time.strftime("%H:%M:%S") - events.appendleft( - f"[bright_black]{timestamp}[/] " - f"[bold {color}]{direction} {ticker}[/] " - f"[{color}]{update.change_percent:+.2f}%[/] " - f"${format_price(update.price)}" - ) - - live.update(build_dashboard(cache, history, events, start_time)) - - except KeyboardInterrupt: - pass - finally: - await source.stop() - - print_summary(cache) - - -if __name__ == "__main__": - asyncio.run(run()) diff --git a/backend/planning/REVIEW.md b/backend/planning/REVIEW.md deleted file mode 100644 index e0763a8ca..000000000 --- a/backend/planning/REVIEW.md +++ /dev/null @@ -1,117 +0,0 @@ -# Review of FinAlly PLAN.md - -Reviewed: 2026-10-03 -Source: `../planning/PLAN.md` relative to the backend workspace (project-root `planning/PLAN.md`). There is no `backend/planning/PLAN.md`. - -## Assessment - -The architecture and demo scope are coherent, but the plan is not yet a complete implementation contract. Resolve the high-priority findings before building portfolio and chat mutations, then define the API schemas before frontend integration. Section 13 already identifies several useful gaps; those proposals need decisions incorporated into the relevant sections, rather than remaining a separate list of questions. - -This is a specification review checked against the existing market-data code. Missing planned components are expected work, not implementation defects. No code was changed and no tests were run for this review. - -## High-priority findings - -### 1. Trade execution lacks transaction and concurrency guarantees - -**References:** Sections 7, 8, 9 and 12. - -The plan specifies balance and holding validation but does not require validation and mutation to occur in one transaction. Two concurrent purchases can each validate against the same cash balance and overspend; concurrent sales can similarly oversell. A failure between updating cash, updating a position, and writing the trade log can leave inconsistent state. - -**Recommendation:** Define one trade service shared by manual and AI actions. Acquire the SQLite write transaction before reading balances and holdings, validate against that transaction's state, and commit cash, position, trade log and the post-trade snapshot together. Define rollback and lock-contention behavior. Test concurrent buys/sells and injected failures, not only sequential arithmetic. - -### 2. Retry semantics can execute trades twice - -**References:** Sections 8 and 9. - -Neither mutation endpoint has an idempotency contract. If a trade or chat action commits but its HTTP response is lost, retrying can execute it again. A chat retry can also generate a different action list for the same user message. - -**Recommendation:** Accept a client-generated request ID for trade and chat submissions, persist a unique request record and the committed outcome, and return that outcome for duplicates. Reject reuse with a different payload. Define recovery for a chat request interrupted between model inference and action execution. Test retries after a committed mutation with a lost response. - -### 3. Market-price availability is not defined for execution or valuation - -**References:** Sections 6–9; Section 13 items 1, 5 and 6; `app/market/massive_client.py`, `cache.py` and `factory.py`. - -Tracking only watched symbols loses prices for held positions removed from the watchlist. An unwatched symbol added through Massive has no immediate price: `add_ticker()` schedules it for a later poll. Poll failures retain cached prices indefinitely. The plan's instruction to fill at the current price does not specify what happens when that price is absent or stale, including outside trading hours. - -**Recommendation:** Track the union of watchlist and open positions, and define a bounded quote-acquisition path for new trade symbols. Specify quote validity, freshness, market-closed behavior and explicit rejection when a usable quote is unavailable. Distinguish source quote time from fetch time. Valuation should expose unavailable/stale values rather than silently treating a holding as worthless. Rebuild tracked symbols from persisted state on startup. - -### 4. Fractional trading needs a precision and validation policy - -**References:** Sections 7, 8 and 12; Section 13 item 5. - -SQLite `REAL` balances, prices, quantities and average costs leave rounding rules unspecified. Fractional operations can produce residual holdings or tiny negative balances. The plan also omits rejection of non-finite numeric values and maximum input bounds. - -**Recommendation:** Define supported quantity and monetary precision, a fixed-point or decimal storage/calculation strategy, rounding at each boundary and the exact zero-position rule. Validate positive, finite, bounded quantities and prices in the common trade service. Include fractional buys, partial sales, full liquidation and repeated operations in acceptance tests. - -### 5. Chat action outcomes and execution order are ambiguous - -**References:** Section 9 and Section 13 items 4 and 8. - -The model produces its message before execution, so it can claim a trade succeeded even when backend validation fails. The sentence saying the error is returned "so the LLM can inform the user" does not match the one-call sequence. Multiple trades and watchlist changes also lack ordering and partial-failure rules; selling to fund a purchase makes order significant. - -**Recommendation:** Define ordered action execution and choose whether batches are atomic or allow partial success. Return a typed result for every action with its actual status, error and executed quantity/price. Make the UI derive execution confirmations from those results. Either add a second model call to summarize outcomes or explicitly label the first message as commentary generated before execution. Define timeout, malformed-output and missing-key behavior, with no mutations before complete schema validation. - -## Integration findings - -### 6. The frontend contract is incomplete, especially after reload - -**References:** Sections 6, 8 and 10; Section 13 items 2–4; `app/market/models.py` and `stream.py`. - -The SSE implementation sends a map of ticker snapshots when the cache version changes; the main plan instead describes each event as an individual ticker update at a regular cadence. `change_percent` is change from the preceding update, not daily change. These differences can produce incompatible frontend parsing and misleading labels. - -Chat messages are persisted, but the API has no retrieval endpoint for restoring the conversation after a reload. Similarly, the detailed ticker chart needs an explicit decision about whether it contains only prices received since page load. - -**Recommendation:** Publish canonical request/response examples and typed schemas for every endpoint, including timestamps, errors and per-action outcomes. Incorporate the actual SSE map format into Section 6. Rename tick change or add a defined session baseline. Add bounded chat-history retrieval, specify chart-history scope and define how the client refreshes portfolio/watchlist state after actions and on SSE reconnect. Define heartbeats so a healthy but quiet feed is distinguishable from a stalled connection. Cache removal currently does not increment `version`; ensure removal can notify clients, including when the final ticker disappears. - -### 7. Startup and persistence responsibilities are inconsistent - -**References:** Sections 4, 5, 7 and 11; Section 13 items 10–12. - -Initialization is variously described as lazy, on startup, or on the first request. Background price tracking and snapshots need seeded state before requests arrive. The database path is relative and ambiguous in local development. The deployment text also conflates a named Docker volume with a bind mount of the project-root `db/` directory; the shown command uses the former. - -**Recommendation:** Initialize schema and seed data once in FastAPI lifespan before starting background tasks; make initialization transactional and idempotent. Define an absolute resolved `DB_PATH`, local and container defaults, and environment-loading precedence. Explicitly choose named-volume persistence or a bind mount. Document a single application worker while cache and scheduler state remain process-local. Bound history responses and define snapshot retention. Record an initial snapshot so a fresh portfolio has a chart baseline. - -### 8. No-login deployment needs an explicit access boundary - -**References:** Sections 2, 9 and 11. - -The local single-user demo has no authentication, while optional cloud deployment exposes mutation endpoints sharing one portfolio and a server-funded model API key. An accessible deployment would let unrelated callers mutate state and consume model usage. Simulated money does not remove that cost. - -**Recommendation:** Scope the core application to trusted local access and bind the local Docker port to loopback. If cloud hosting is implemented, require an access gate and model request limits. Define missing-key behavior so the simulator and manual trading remain usable without chat credentials, consistent with the first-launch experience. - -### 9. Portfolio P&L and treemap weight need explicit definitions - -**References:** Sections 2, 7, 8 and 10. - -The plan calls the snapshot chart a P&L chart but stores and displays total portfolio value. It does not define total return, realized P&L, the positions-table percentage baseline, or whether treemap weights include cash. After selling a profitable position, unrealized P&L alone no longer describes the portfolio's gain; a cash-only portfolio also has no position rectangles to display. - -**Recommendation:** Define equity as cash plus marked position values, total P&L as equity minus the initial $10,000 (while deposits and withdrawals remain unsupported), and unrealized P&L as quantity times current price minus average cost. Specify the percentage denominators and whether the chart shows equity or profit. Define cash inclusion and an empty-positions state for the treemap. Test a profitable full sale and a cash-only account. - -### 10. Mock chat alone does not make end-to-end tests deterministic - -**References:** Section 12; `app/market/simulator.py`. - -`LLM_MOCK=true` controls model output, but prices still move using both NumPy and Python random generators, and a persisted Docker volume may retain cash, holdings and history from an earlier run. The same mocked buy can therefore have a different fill price or fail against leftover state. - -**Recommendation:** Give each test run an isolated fresh database and an injectable fixed-price market source for arithmetic assertions. Test streaming movement separately, with bounded expectations or controlled randomness. Specify mock message-to-action fixtures and disable external provider calls in the E2E environment. Include container restart persistence as a separate scenario using an intentionally retained volume. - -## Decisions already raised in Section 13 - -- Promote tracked-symbol ownership, daily-change semantics, SSE shape, REST schemas, ticker validation, chat-history depth, model failure behavior, deterministic mocks, retention and configuration paths into normative requirements. -- Keep UUID removal, chart-library consolidation, script reduction and E2E harness simplification as optional design choices. They are not blockers. -- Do not replace periodic snapshots solely with history-request snapshots without accepting the changed behavior: periods with no browser requests would lose portfolio history. -- Correct the blanket `user_id` statement to acknowledge the profile primary key, and acknowledge both market-data and snapshot background tasks. -- Resolve the canvas/SVG chart-library wording and the two different paid polling ranges. Make the polling interval an actual supported configuration; the current factory always uses the default. -- Section 9 depends on a `cerebras-inference` skill that is not among this session's available skills. Provide an accessible implementation reference or make the required integration contract self-contained. This review did not verify external provider capabilities or lifecycle dates asserted in the plan. - -## Suggested implementation sequence - -1. Amend the specification with quote, precision, transaction, idempotency and chat-batch decisions. -2. Define API schemas and examples, including chat history and error outcomes. -3. Implement database lifespan and the common trade service with concurrency and retry tests. -4. Integrate market-data tracking, quote freshness and snapshot recording. -5. Implement chat execution and deterministic mocks, then frontend and container E2E flows. - -## Output-location limitation - -The requested project-root `planning/REVIEW.md` is outside this session's writable root (`backend/`). This review is saved at `backend/planning/REVIEW.md` instead. The source plan was read from the parent project's `planning/` directory and was not modified. diff --git a/backend/pyproject.toml b/backend/pyproject.toml deleted file mode 100644 index e172cca22..000000000 --- a/backend/pyproject.toml +++ /dev/null @@ -1,58 +0,0 @@ -[project] -name = "finally-backend" -version = "0.1.0" -description = "FinAlly backend - AI Trading Workstation" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "fastapi>=0.115.0", - "uvicorn[standard]>=0.32.0", - "numpy>=2.0.0", - "massive>=1.0.0", - "rich>=13.0.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.3.0", - "pytest-asyncio>=0.24.0", - "pytest-cov>=5.0.0", - "ruff>=0.7.0", -] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["app"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" - -[tool.ruff] -line-length = 100 -target-version = "py312" - -[tool.ruff.lint] -select = ["E", "F", "I", "N", "W"] -ignore = ["E501"] # Line too long (handled by formatter) - -[tool.coverage.run] -source = ["app"] -omit = ["tests/*"] - -[tool.coverage.report] -exclude_lines = [ - "pragma: no cover", - "def __repr__", - "raise AssertionError", - "raise NotImplementedError", - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", -] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py deleted file mode 100644 index 6c957488c..000000000 --- a/backend/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for FinAlly backend.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index 14545f124..000000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Pytest configuration and fixtures.""" - -import pytest - - -@pytest.fixture -def event_loop_policy(): - """Use the default event loop policy for all async tests.""" - import asyncio - - return asyncio.DefaultEventLoopPolicy() diff --git a/backend/tests/market/__init__.py b/backend/tests/market/__init__.py deleted file mode 100644 index c614bf5c9..000000000 --- a/backend/tests/market/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for market data subsystem.""" diff --git a/backend/tests/market/test_cache.py b/backend/tests/market/test_cache.py deleted file mode 100644 index b5ab3d55d..000000000 --- a/backend/tests/market/test_cache.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Tests for PriceCache.""" - -from app.market.cache import PriceCache - - -class TestPriceCache: - """Unit tests for the PriceCache.""" - - def test_update_and_get(self): - """Test updating and getting a price.""" - cache = PriceCache() - update = cache.update("AAPL", 190.50) - assert update.ticker == "AAPL" - assert update.price == 190.50 - assert cache.get("AAPL") == update - - def test_first_update_is_flat(self): - """Test that the first update has flat direction.""" - cache = PriceCache() - update = cache.update("AAPL", 190.50) - assert update.direction == "flat" - assert update.previous_price == 190.50 - - def test_direction_up(self): - """Test price update with upward direction.""" - cache = PriceCache() - cache.update("AAPL", 190.00) - update = cache.update("AAPL", 191.00) - assert update.direction == "up" - assert update.change == 1.00 - - def test_direction_down(self): - """Test price update with downward direction.""" - cache = PriceCache() - cache.update("AAPL", 190.00) - update = cache.update("AAPL", 189.00) - assert update.direction == "down" - assert update.change == -1.00 - - def test_remove(self): - """Test removing a ticker from cache.""" - cache = PriceCache() - cache.update("AAPL", 190.00) - cache.remove("AAPL") - assert cache.get("AAPL") is None - - def test_remove_nonexistent(self): - """Test removing a ticker that doesn't exist.""" - cache = PriceCache() - cache.remove("AAPL") # Should not raise - - def test_get_all(self): - """Test getting all prices.""" - cache = PriceCache() - cache.update("AAPL", 190.00) - cache.update("GOOGL", 175.00) - all_prices = cache.get_all() - assert set(all_prices.keys()) == {"AAPL", "GOOGL"} - - def test_version_increments(self): - """Test that version counter increments.""" - cache = PriceCache() - v0 = cache.version - cache.update("AAPL", 190.00) - assert cache.version == v0 + 1 - cache.update("AAPL", 191.00) - assert cache.version == v0 + 2 - - def test_get_price_convenience(self): - """Test the convenience get_price method.""" - cache = PriceCache() - cache.update("AAPL", 190.50) - assert cache.get_price("AAPL") == 190.50 - assert cache.get_price("NOPE") is None - - def test_len(self): - """Test __len__ method.""" - cache = PriceCache() - assert len(cache) == 0 - cache.update("AAPL", 190.00) - assert len(cache) == 1 - cache.update("GOOGL", 175.00) - assert len(cache) == 2 - - def test_contains(self): - """Test __contains__ method.""" - cache = PriceCache() - cache.update("AAPL", 190.00) - assert "AAPL" in cache - assert "GOOGL" not in cache - - def test_custom_timestamp(self): - """Test updating with a custom timestamp.""" - cache = PriceCache() - custom_ts = 1234567890.0 - update = cache.update("AAPL", 190.50, timestamp=custom_ts) - assert update.timestamp == custom_ts - - def test_price_rounding(self): - """Test that prices are rounded to 2 decimal places.""" - cache = PriceCache() - update = cache.update("AAPL", 190.12345) - assert update.price == 190.12 diff --git a/backend/tests/market/test_factory.py b/backend/tests/market/test_factory.py deleted file mode 100644 index 5ff5dd49e..000000000 --- a/backend/tests/market/test_factory.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Tests for market data source factory.""" - -import os -from unittest.mock import patch - -from app.market.cache import PriceCache -from app.market.factory import create_market_data_source -from app.market.massive_client import MassiveDataSource -from app.market.simulator import SimulatorDataSource - - -class TestFactory: - """Tests for create_market_data_source factory.""" - - def test_creates_simulator_when_no_api_key(self): - """Test that simulator is created when MASSIVE_API_KEY is not set.""" - cache = PriceCache() - - with patch.dict(os.environ, {}, clear=True): - source = create_market_data_source(cache) - - assert isinstance(source, SimulatorDataSource) - - def test_creates_simulator_when_api_key_empty(self): - """Test that simulator is created when MASSIVE_API_KEY is empty.""" - cache = PriceCache() - - with patch.dict(os.environ, {"MASSIVE_API_KEY": ""}, clear=True): - source = create_market_data_source(cache) - - assert isinstance(source, SimulatorDataSource) - - def test_creates_simulator_when_api_key_whitespace(self): - """Test that simulator is created when MASSIVE_API_KEY is whitespace.""" - cache = PriceCache() - - with patch.dict(os.environ, {"MASSIVE_API_KEY": " "}, clear=True): - source = create_market_data_source(cache) - - assert isinstance(source, SimulatorDataSource) - - def test_creates_massive_when_api_key_set(self): - """Test that Massive client is created when MASSIVE_API_KEY is set.""" - cache = PriceCache() - - with patch.dict(os.environ, {"MASSIVE_API_KEY": "test-key"}, clear=True): - source = create_market_data_source(cache) - - assert isinstance(source, MassiveDataSource) - - def test_massive_receives_api_key(self): - """Test that Massive client receives the API key.""" - cache = PriceCache() - - with patch.dict(os.environ, {"MASSIVE_API_KEY": "test-key-123"}, clear=True): - source = create_market_data_source(cache) - - assert isinstance(source, MassiveDataSource) - assert source._api_key == "test-key-123" - - def test_simulator_receives_cache(self): - """Test that simulator receives the cache reference.""" - cache = PriceCache() - - with patch.dict(os.environ, {}, clear=True): - source = create_market_data_source(cache) - - assert isinstance(source, SimulatorDataSource) - assert source._cache is cache - - def test_massive_receives_cache(self): - """Test that Massive client receives the cache reference.""" - cache = PriceCache() - - with patch.dict(os.environ, {"MASSIVE_API_KEY": "test-key"}, clear=True): - source = create_market_data_source(cache) - - assert isinstance(source, MassiveDataSource) - assert source._cache is cache diff --git a/backend/tests/market/test_massive.py b/backend/tests/market/test_massive.py deleted file mode 100644 index cdd7dbd24..000000000 --- a/backend/tests/market/test_massive.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Tests for MassiveDataSource (mocked).""" - -from unittest.mock import MagicMock, patch - -import pytest - -from app.market.cache import PriceCache -from app.market.massive_client import MassiveDataSource - - -def _make_snapshot(ticker: str, price: float, timestamp_ms: int) -> MagicMock: - """Create a mock Massive snapshot object.""" - snap = MagicMock() - snap.ticker = ticker - snap.last_trade = MagicMock() - snap.last_trade.price = price - snap.last_trade.timestamp = timestamp_ms - return snap - - -@pytest.mark.asyncio -class TestMassiveDataSource: - """Unit tests for MassiveDataSource with mocked API.""" - - async def test_poll_updates_cache(self): - """Test that polling updates the cache.""" - cache = PriceCache() - source = MassiveDataSource( - api_key="test-key", - price_cache=cache, - poll_interval=60.0, # Long interval so the loop doesn't auto-poll - ) - source._tickers = ["AAPL", "GOOGL"] - source._client = MagicMock() # Satisfy the _poll_once guard - - mock_snapshots = [ - _make_snapshot("AAPL", 190.50, 1707580800000), - _make_snapshot("GOOGL", 175.25, 1707580800000), - ] - - with patch.object(source, "_fetch_snapshots", return_value=mock_snapshots): - await source._poll_once() - - assert cache.get_price("AAPL") == 190.50 - assert cache.get_price("GOOGL") == 175.25 - - async def test_malformed_snapshot_skipped(self): - """Test that malformed snapshots are skipped gracefully.""" - cache = PriceCache() - source = MassiveDataSource( - api_key="test-key", - price_cache=cache, - poll_interval=60.0, - ) - source._tickers = ["AAPL", "BAD"] - source._client = MagicMock() # Satisfy the _poll_once guard - - good_snap = _make_snapshot("AAPL", 190.50, 1707580800000) - bad_snap = MagicMock() - bad_snap.ticker = "BAD" - bad_snap.last_trade = None # Will cause AttributeError - - with patch.object(source, "_fetch_snapshots", return_value=[good_snap, bad_snap]): - await source._poll_once() - - # Good ticker processed, bad one skipped - assert cache.get_price("AAPL") == 190.50 - assert cache.get_price("BAD") is None - - async def test_api_error_does_not_crash(self): - """Test that API errors don't crash the poller.""" - cache = PriceCache() - source = MassiveDataSource( - api_key="test-key", - price_cache=cache, - poll_interval=60.0, - ) - source._tickers = ["AAPL"] - source._client = MagicMock() # Satisfy the _poll_once guard - - with patch.object(source, "_fetch_snapshots", side_effect=Exception("network error")): - await source._poll_once() # Should not raise - - assert cache.get_price("AAPL") is None # No update happened - - async def test_timestamp_conversion(self): - """Test that timestamps are converted from milliseconds to seconds.""" - cache = PriceCache() - source = MassiveDataSource( - api_key="test-key", - price_cache=cache, - poll_interval=60.0, - ) - source._tickers = ["AAPL"] - source._client = MagicMock() # Satisfy the _poll_once guard - - mock_snapshots = [_make_snapshot("AAPL", 190.50, 1707580800000)] - - with patch.object(source, "_fetch_snapshots", return_value=mock_snapshots): - await source._poll_once() - - update = cache.get("AAPL") - assert update is not None - assert update.timestamp == 1707580800.0 # Converted to seconds - - async def test_add_ticker(self): - """Test adding a ticker.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache) - - await source.add_ticker("AAPL") - assert "AAPL" in source.get_tickers() - - async def test_add_ticker_uppercase_normalization(self): - """Test that tickers are normalized to uppercase.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache) - - await source.add_ticker("aapl") - assert "AAPL" in source.get_tickers() - - async def test_add_ticker_strips_whitespace(self): - """Test that ticker whitespace is stripped.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache) - - await source.add_ticker(" AAPL ") - assert "AAPL" in source.get_tickers() - - async def test_remove_ticker(self): - """Test removing a ticker.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache) - source._tickers = ["AAPL", "GOOGL"] - cache.update("AAPL", 190.00) - - await source.remove_ticker("AAPL") - assert "AAPL" not in source.get_tickers() - assert cache.get("AAPL") is None - - async def test_get_tickers(self): - """Test getting the list of active tickers.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache) - source._tickers = ["AAPL", "GOOGL"] - - tickers = source.get_tickers() - assert tickers == ["AAPL", "GOOGL"] - - async def test_empty_tickers_skips_poll(self): - """Test that polling is skipped when there are no tickers.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache) - source._tickers = [] - - # Should not call _fetch_snapshots - with patch.object(source, "_fetch_snapshots") as mock_fetch: - await source._poll_once() - mock_fetch.assert_not_called() - - async def test_stop_is_idempotent(self): - """Test that stop() can be called multiple times.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache) - - await source.stop() - await source.stop() # Should not raise - - async def test_stop_cancels_task(self): - """Test that stop() cancels the polling task.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache, poll_interval=10.0) - - # Mock the client and start - with patch("app.market.massive_client.RESTClient"): - with patch.object(source, "_fetch_snapshots", return_value=[]): - await source.start(["AAPL"]) - - # Verify task is running - assert source._task is not None - assert not source._task.done() - - # Stop and verify task is cancelled - await source.stop() - assert source._task is None - - async def test_start_immediate_poll(self): - """Test that start() does an immediate poll before starting the loop.""" - cache = PriceCache() - source = MassiveDataSource(api_key="test-key", price_cache=cache, poll_interval=60.0) - - mock_snapshots = [_make_snapshot("AAPL", 190.50, 1707580800000)] - - with patch("app.market.massive_client.RESTClient"): - with patch.object(source, "_fetch_snapshots", return_value=mock_snapshots): - await source.start(["AAPL"]) - - # Cache should have data immediately from the first poll - assert cache.get_price("AAPL") == 190.50 - - await source.stop() diff --git a/backend/tests/market/test_models.py b/backend/tests/market/test_models.py deleted file mode 100644 index 21600dfd6..000000000 --- a/backend/tests/market/test_models.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Tests for PriceUpdate dataclass.""" - -import pytest - -from app.market.models import PriceUpdate - - -class TestPriceUpdate: - """Unit tests for the PriceUpdate model.""" - - def test_price_update_creation(self): - """Test basic PriceUpdate creation.""" - update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0) - assert update.ticker == "AAPL" - assert update.price == 190.50 - assert update.previous_price == 190.00 - assert update.timestamp == 1234567890.0 - - def test_change_calculation(self): - """Test price change calculation.""" - update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0) - assert update.change == 0.50 - - def test_change_negative(self): - """Test negative price change.""" - update = PriceUpdate(ticker="AAPL", price=189.50, previous_price=190.00, timestamp=1234567890.0) - assert update.change == -0.50 - - def test_change_percent_up(self): - """Test percentage change calculation (up).""" - update = PriceUpdate(ticker="AAPL", price=190.00, previous_price=100.00, timestamp=1234567890.0) - assert update.change_percent == 90.0 - - def test_change_percent_down(self): - """Test percentage change calculation (down).""" - update = PriceUpdate(ticker="AAPL", price=100.00, previous_price=200.00, timestamp=1234567890.0) - assert update.change_percent == -50.0 - - def test_change_percent_zero_previous(self): - """Test percentage change with zero previous price.""" - update = PriceUpdate(ticker="AAPL", price=100.00, previous_price=0.00, timestamp=1234567890.0) - assert update.change_percent == 0.0 - - def test_direction_up(self): - """Test direction calculation (up).""" - update = PriceUpdate(ticker="AAPL", price=191.00, previous_price=190.00, timestamp=1234567890.0) - assert update.direction == "up" - - def test_direction_down(self): - """Test direction calculation (down).""" - update = PriceUpdate(ticker="AAPL", price=189.00, previous_price=190.00, timestamp=1234567890.0) - assert update.direction == "down" - - def test_direction_flat(self): - """Test direction calculation (flat).""" - update = PriceUpdate(ticker="AAPL", price=190.00, previous_price=190.00, timestamp=1234567890.0) - assert update.direction == "flat" - - def test_to_dict(self): - """Test serialization to dictionary.""" - update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0) - result = update.to_dict() - - assert result["ticker"] == "AAPL" - assert result["price"] == 190.50 - assert result["previous_price"] == 190.00 - assert result["timestamp"] == 1234567890.0 - assert result["change"] == 0.50 - assert result["change_percent"] == 0.2632 # (0.50 / 190.00) * 100 - assert result["direction"] == "up" - - def test_immutability(self): - """Test that PriceUpdate is immutable.""" - update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0) - - with pytest.raises(AttributeError): - update.price = 200.00 # Should raise error diff --git a/backend/tests/market/test_simulator.py b/backend/tests/market/test_simulator.py deleted file mode 100644 index 1845ec16b..000000000 --- a/backend/tests/market/test_simulator.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Tests for GBMSimulator.""" - -from app.market.seed_prices import SEED_PRICES -from app.market.simulator import GBMSimulator - - -class TestGBMSimulator: - """Unit tests for the GBM price simulator.""" - - def test_step_returns_all_tickers(self): - """Test that step() returns prices for all tickers.""" - sim = GBMSimulator(tickers=["AAPL", "GOOGL"]) - result = sim.step() - assert set(result.keys()) == {"AAPL", "GOOGL"} - - def test_prices_are_positive(self): - """GBM prices can never go negative (exp() is always positive).""" - sim = GBMSimulator(tickers=["AAPL"]) - for _ in range(10_000): - prices = sim.step() - assert prices["AAPL"] > 0 - - def test_initial_prices_match_seeds(self): - """Test that initial prices match seed prices.""" - sim = GBMSimulator(tickers=["AAPL"]) - # Before any step, price should be the seed price - assert sim.get_price("AAPL") == SEED_PRICES["AAPL"] - - def test_add_ticker(self): - """Test adding a ticker dynamically.""" - sim = GBMSimulator(tickers=["AAPL"]) - sim.add_ticker("TSLA") - result = sim.step() - assert "TSLA" in result - - def test_remove_ticker(self): - """Test removing a ticker.""" - sim = GBMSimulator(tickers=["AAPL", "GOOGL"]) - sim.remove_ticker("GOOGL") - result = sim.step() - assert "GOOGL" not in result - assert "AAPL" in result - - def test_add_duplicate_is_noop(self): - """Test that adding a duplicate ticker is a no-op.""" - sim = GBMSimulator(tickers=["AAPL"]) - sim.add_ticker("AAPL") - assert len(sim._tickers) == 1 - - def test_remove_nonexistent_is_noop(self): - """Test that removing a non-existent ticker is a no-op.""" - sim = GBMSimulator(tickers=["AAPL"]) - sim.remove_ticker("NOPE") # Should not raise - - def test_unknown_ticker_gets_random_seed_price(self): - """Test that unknown tickers get random seed prices.""" - sim = GBMSimulator(tickers=["ZZZZ"]) - price = sim.get_price("ZZZZ") - assert price is not None - assert 50.0 <= price <= 300.0 - - def test_empty_step(self): - """Test stepping with no tickers.""" - sim = GBMSimulator(tickers=[]) - result = sim.step() - assert result == {} - - def test_prices_change_over_time(self): - """After many steps, prices should have drifted from their seeds.""" - sim = GBMSimulator(tickers=["AAPL"]) - initial_price = sim.get_price("AAPL") - - for _ in range(1000): - sim.step() - - final_price = sim.get_price("AAPL") - # Price should have changed (extremely unlikely to be exactly the seed) - assert final_price != initial_price - - def test_cholesky_rebuilds_on_add(self): - """Test that Cholesky matrix is rebuilt when tickers are added.""" - sim = GBMSimulator(tickers=["AAPL"]) - assert sim._cholesky is None # Only 1 ticker, no correlation matrix - sim.add_ticker("GOOGL") - assert sim._cholesky is not None # Now 2 tickers, matrix exists - - def test_cholesky_none_with_one_ticker(self): - """Test that Cholesky is None with only one ticker.""" - sim = GBMSimulator(tickers=["AAPL"]) - assert sim._cholesky is None - - def test_get_price_returns_none_for_unknown(self): - """Test that get_price returns None for unknown ticker.""" - sim = GBMSimulator(tickers=["AAPL"]) - assert sim.get_price("UNKNOWN") is None - - def test_pairwise_correlation_tech_stocks(self): - """Test that tech stocks have high correlation.""" - corr = GBMSimulator._pairwise_correlation("AAPL", "GOOGL") - assert corr == 0.6 - - def test_pairwise_correlation_finance_stocks(self): - """Test that finance stocks have moderate correlation.""" - corr = GBMSimulator._pairwise_correlation("JPM", "V") - assert corr == 0.5 - - def test_pairwise_correlation_tsla(self): - """Test that TSLA has lower correlation with everything.""" - corr = GBMSimulator._pairwise_correlation("TSLA", "AAPL") - assert corr == 0.3 - corr = GBMSimulator._pairwise_correlation("TSLA", "JPM") - assert corr == 0.3 - - def test_pairwise_correlation_cross_sector(self): - """Test cross-sector correlation.""" - corr = GBMSimulator._pairwise_correlation("AAPL", "JPM") - assert corr == 0.3 - - def test_default_dt_is_reasonable(self): - """Test that default dt is a reasonable small value.""" - assert 0 < GBMSimulator.DEFAULT_DT < 0.0001 - - def test_prices_rounded_to_two_decimals(self): - """Test that prices are rounded to 2 decimal places.""" - sim = GBMSimulator(tickers=["AAPL"]) - result = sim.step() - price_str = str(result["AAPL"]) - # Check that we have at most 2 decimal places - if '.' in price_str: - decimal_part = price_str.split('.')[1] - assert len(decimal_part) <= 2 diff --git a/backend/tests/market/test_simulator_source.py b/backend/tests/market/test_simulator_source.py deleted file mode 100644 index 515ce7290..000000000 --- a/backend/tests/market/test_simulator_source.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Integration tests for SimulatorDataSource.""" - -import asyncio - -import pytest - -from app.market.cache import PriceCache -from app.market.simulator import SimulatorDataSource - - -@pytest.mark.asyncio -class TestSimulatorDataSource: - """Integration tests for the SimulatorDataSource.""" - - async def test_start_populates_cache(self): - """Test that start() immediately populates the cache.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start(["AAPL", "GOOGL"]) - - # Cache should have seed prices immediately (before first loop tick) - assert cache.get("AAPL") is not None - assert cache.get("GOOGL") is not None - - await source.stop() - - async def test_prices_update_over_time(self): - """Test that prices are updated periodically.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.05) - await source.start(["AAPL"]) - - initial_version = cache.version - await asyncio.sleep(0.3) # Several update cycles - - # Version should have incremented (prices updated) - assert cache.version > initial_version - - await source.stop() - - async def test_stop_is_clean(self): - """Test that stop() is clean and idempotent.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start(["AAPL"]) - await source.stop() - # Double stop should not raise - await source.stop() - - async def test_add_ticker(self): - """Test adding a ticker dynamically.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start(["AAPL"]) - - await source.add_ticker("TSLA") - assert "TSLA" in source.get_tickers() - assert cache.get("TSLA") is not None - - await source.stop() - - async def test_remove_ticker(self): - """Test removing a ticker.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start(["AAPL", "TSLA"]) - - await source.remove_ticker("TSLA") - assert "TSLA" not in source.get_tickers() - assert cache.get("TSLA") is None - - await source.stop() - - async def test_get_tickers(self): - """Test getting the list of active tickers.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start(["AAPL", "GOOGL"]) - - tickers = source.get_tickers() - assert set(tickers) == {"AAPL", "GOOGL"} - - await source.stop() - - async def test_empty_start(self): - """Test starting with no tickers.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start([]) - - assert len(cache) == 0 - assert source.get_tickers() == [] - - await source.stop() - - async def test_exception_resilience(self): - """Test that simulator continues running after errors.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.05) - - # Start with a valid ticker - await source.start(["AAPL"]) - - # Wait for some updates - await asyncio.sleep(0.15) - - # Task should still be running - assert source._task is not None - assert not source._task.done() - - await source.stop() - - async def test_custom_update_interval(self): - """Test using a custom update interval.""" - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.01) - await source.start(["AAPL"]) - - initial_version = cache.version - await asyncio.sleep(0.05) # Should get ~5 updates - - # Should have multiple updates with fast interval - assert cache.version > initial_version + 2 - - await source.stop() - - async def test_custom_event_probability(self): - """Test creating source with custom event probability.""" - cache = PriceCache() - # Very high event probability for testing - source = SimulatorDataSource( - price_cache=cache, update_interval=0.1, event_probability=1.0 - ) - await source.start(["AAPL"]) - - # Just verify it starts and stops cleanly - await asyncio.sleep(0.2) - await source.stop() diff --git a/backend/uv.lock b/backend/uv.lock deleted file mode 100644 index 67d471b2d..000000000 --- a/backend/uv.lock +++ /dev/null @@ -1,813 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, -] - -[[package]] -name = "certifi" -version = "2026.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -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" } -wheels = [ - { 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" }, -] - -[[package]] -name = "coverage" -version = "7.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, -] - -[[package]] -name = "fastapi" -version = "0.128.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, -] - -[[package]] -name = "finally-backend" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "fastapi" }, - { name = "massive" }, - { name = "numpy" }, - { name = "rich" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "massive", specifier = ">=1.0.0" }, - { name = "numpy", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, - { name = "rich", specifier = ">=13.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, -] -provides-extras = ["dev"] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -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" } -wheels = [ - { 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" }, -] - -[[package]] -name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "massive" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "urllib3" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/fe/eec0d88e20269d837a0e319963d944f2c62cb275a8cd664863e2174d6b4f/massive-2.2.0.tar.gz", hash = "sha256:5a5c7b73fc1bbd3754c985ff20bc3c1db3fd9b2c64ddd5145a837a2e2f4bd5fc", size = 46463, upload-time = "2026-02-05T19:02:48.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/45/700942c1114c654d185f3e467b536f958c92ca3eb186bf0cb8a0c9db393a/massive-2.2.0-py3-none-any.whl", hash = "sha256:009e63b709b063bd9633a033608fb3aca6368510df909d8728a23e60bdb21c89", size = 64035, upload-time = "2026-02-05T19:02:49.807Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -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" } -wheels = [ - { 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" }, -] - -[[package]] -name = "numpy" -version = "2.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, - { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, - { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, - { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, - { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, - { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, - { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, - { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, - { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, - { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, - { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, - { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, - { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, - { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, - { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, - { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, - { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, - { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, - { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, - { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, - { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, - { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, - { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, - { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, - { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, - { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, - { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, - { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, - { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, - { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, - { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, - { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, - { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, - { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, - { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, - { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, - { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, - { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, - { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, -] - -[[package]] -name = "packaging" -version = "26.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage" }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -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" } -wheels = [ - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, - { 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" }, -] - -[[package]] -name = "rich" -version = "14.3.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, -] - -[[package]] -name = "ruff" -version = "0.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893, upload-time = "2026-02-03T17:53:35.357Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332, upload-time = "2026-02-03T17:52:54.892Z" }, - { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189, upload-time = "2026-02-03T17:53:19.778Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384, upload-time = "2026-02-03T17:53:29.241Z" }, - { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363, upload-time = "2026-02-03T17:52:43.332Z" }, - { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736, upload-time = "2026-02-03T17:53:00.522Z" }, - { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415, upload-time = "2026-02-03T17:53:15.705Z" }, - { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643, upload-time = "2026-02-03T17:53:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787, upload-time = "2026-02-03T17:52:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797, upload-time = "2026-02-03T17:52:49.274Z" }, - { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133, upload-time = "2026-02-03T17:53:33.105Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646, upload-time = "2026-02-03T17:53:06.278Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750, upload-time = "2026-02-03T17:53:26.084Z" }, - { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120, upload-time = "2026-02-03T17:53:09.363Z" }, - { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636, upload-time = "2026-02-03T17:52:57.281Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945, upload-time = "2026-02-03T17:53:12.591Z" }, - { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657, upload-time = "2026-02-03T17:52:51.893Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753, upload-time = "2026-02-03T17:53:03.014Z" }, -] - -[[package]] -name = "starlette" -version = "0.52.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, -] - -[[package]] -name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, -] diff --git a/independent-reviewer/.claude-plugin/plugin.json b/independent-reviewer/.claude-plugin/plugin.json deleted file mode 100644 index 4292cb325..000000000 --- a/independent-reviewer/.claude-plugin/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ -"name": "independent-reviewer", -"description": "carry out an independent review of all changes since last commit", -"version": "1.0.0" -} \ No newline at end of file diff --git a/independent-reviewer/hooks/hooks.json b/independent-reviewer/hooks/hooks.json deleted file mode 100644 index 7be35f94c..000000000 --- a/independent-reviewer/hooks/hooks.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "codex exec \"please review the file planning/PLAN.md and write results to a file planning/REVIEW.md\"" - } - ] - } - ] - } -} - diff --git a/planning/MARKET_DATA_SUMMARY.md b/planning/MARKET_DATA_SUMMARY.md deleted file mode 100644 index ae518283a..000000000 --- a/planning/MARKET_DATA_SUMMARY.md +++ /dev/null @@ -1,104 +0,0 @@ -# Market Data Backend — Summary - -**Status:** Complete, tested, reviewed, all issues resolved. - -## What Was Built - -A complete market data subsystem in `backend/app/market/` (8 modules, ~500 lines) providing live price simulation and real market data via a unified interface. - -### Architecture - -``` -MarketDataSource (ABC) -├── SimulatorDataSource → GBM simulator (default, no API key needed) -└── MassiveDataSource → Polygon.io REST poller (when MASSIVE_API_KEY set) - │ - ▼ - PriceCache (thread-safe, in-memory) - │ - ├──→ SSE stream endpoint (/api/stream/prices) - ├──→ Portfolio valuation - └──→ Trade execution -``` - -### Modules - -| File | Purpose | -|------|---------| -| `models.py` | `PriceUpdate` — immutable frozen dataclass (ticker, price, previous_price, timestamp, change, direction) | -| `interface.py` | `MarketDataSource` — abstract base class defining `start/stop/add_ticker/remove_ticker/get_tickers` | -| `cache.py` | `PriceCache` — thread-safe price store with version counter for SSE change detection | -| `seed_prices.py` | Realistic seed prices, per-ticker GBM params (drift/volatility), correlation groups | -| `simulator.py` | `GBMSimulator` (Geometric Brownian Motion with Cholesky-correlated moves) + `SimulatorDataSource` | -| `massive_client.py` | `MassiveDataSource` — REST polling client for Polygon.io via the `massive` package | -| `factory.py` | `create_market_data_source()` — selects simulator or Massive based on `MASSIVE_API_KEY` env var | -| `stream.py` | `create_stream_router()` — FastAPI SSE endpoint factory using version-based change detection | - -### Key Design Decisions - -- **Strategy pattern** — both data sources implement the same ABC; downstream code is source-agnostic -- **PriceCache as single point of truth** — producers write, consumers read; no direct coupling -- **GBM with correlated moves** — Cholesky decomposition of sector-based correlation matrix; tech stocks correlate at 0.6, finance at 0.5, cross-sector at 0.3 -- **Random shock events** — ~0.1% chance per tick per ticker of a 2-5% move for visual drama -- **SSE over WebSockets** — simpler, one-way push, universal browser support - -## Test Suite - -**73 tests, all passing.** 6 test modules in `backend/tests/market/`. - -| Module | Tests | Coverage | -|--------|-------|----------| -| test_models.py | 11 | models.py: 100% | -| test_cache.py | 13 | cache.py: 100% | -| test_simulator.py | 17 | simulator.py: 98% | -| test_simulator_source.py | 10 | (integration tests) | -| test_factory.py | 7 | factory.py: 100% | -| test_massive.py | 13 | massive_client.py: 56% (expected — API methods mocked) | - -Overall coverage: 84%. - -## Code Review & Fixes Applied - -A comprehensive code review identified 7 issues. All were resolved: - -1. **pyproject.toml build config** — added `[tool.hatch.build.targets.wheel] packages = ["app"]` -2. **Lazy imports removed** — `massive` is a core dependency; imports moved to top level -3. **SSE return type fixed** — `_generate_events` annotated as `AsyncGenerator[str, None]` -4. **Public `get_tickers()`** — added to `GBMSimulator` to avoid private attribute access -5. **Correlation constants cleaned up** — removed unused `DEFAULT_CORR`, consolidated into `CROSS_GROUP_CORR` -6. **Unused test imports removed** — `pytest`, `math`, `asyncio` cleaned from 4 test files -7. **Massive test mocks fixed** — `source._client` set in tests, patches target correct names - -## Demo - -A Rich terminal demo is available at `backend/market_data_demo.py`: - -```bash -cd backend -uv run market_data_demo.py -``` - -Displays a live-updating dashboard with all 10 tickers, sparklines, color-coded direction arrows, and an event log for notable price moves. Runs 60 seconds or until Ctrl+C. - -## Usage for Downstream Code - -```python -from app.market import PriceCache, create_market_data_source - -# Startup -cache = PriceCache() -source = create_market_data_source(cache) # Reads MASSIVE_API_KEY -await source.start(["AAPL", "GOOGL", "MSFT", ...]) - -# Read prices -update = cache.get("AAPL") # PriceUpdate or None -price = cache.get_price("AAPL") # float or None -all_prices = cache.get_all() # dict[str, PriceUpdate] - -# Dynamic watchlist -await source.add_ticker("TSLA") -await source.remove_ticker("GOOGL") - -# Shutdown -await source.stop() -``` diff --git a/planning/MARKET_INTERFACE.md b/planning/MARKET_INTERFACE.md new file mode 100644 index 000000000..162482b62 --- /dev/null +++ b/planning/MARKET_INTERFACE.md @@ -0,0 +1,529 @@ +# Market Data Interface + +The unified Python API every part of the FinAlly backend uses to get stock prices. +One interface, two implementations: the Massive REST poller when `MASSIVE_API_KEY` +is set, otherwise the built-in simulator (see `MARKET_SIMULATOR.md`). Endpoint +details for Massive are in `MASSIVE_API.md`. + +All code below has been run end to end (simulator, cache, SSE generator, and the +Massive source against a stubbed client for both paid and free plans). + +## 1. Design + +``` + MASSIVE_API_KEY set? + / \ + yes no + | | + MassiveDataSource SimulatorDataSource + (poll REST, 5s/15m) (GBM step, 500ms) + \ / + v v + PriceCache (latest price per ticker, version counter) + | | + SSE /api/stream/prices trades, portfolio valuation, chat context +``` + +Principles: + +- **Producers write, everyone else reads.** A single background task (simulator or + poller) writes to `PriceCache`. API routes never call Massive or the simulator for + a price; they read the cache. This keeps trade execution instant and rate limits + safe. +- **Push, not pull, for prices.** Sources run their own loop; downstream code is + unaware of which source is active. +- **The source tracks whatever it is told to.** Deciding *which* tickers to track is + the caller's job (see section 6), not the source's. + +## 2. Module layout + +``` +backend/app/market/ +├── __init__.py +├── models.py # PriceUpdate dataclass +├── cache.py # PriceCache +├── interface.py # MarketDataSource ABC +├── seed_prices.py # simulator seed prices and parameters +├── simulator.py # GBMSimulator + SimulatorDataSource +├── massive_client.py # MassiveDataSource +├── factory.py # create_market_data_source() +└── stream.py # SSE router +``` + +Dependencies: `uv add fastapi numpy massive`. + +## 3. Data model + +`PriceUpdate` is immutable and carries everything the SSE stream and REST API need. + +- `previous_price` is the price at the previous tick, used for flash direction. +- `reference_price` answers PLAN.md review item 2 ("daily change %"): it is the + previous session close for Massive, and the first price seen since startup for the + simulator (the seed price). `day_change_percent` is computed against it. +- Prices are rounded to cents when cached, so `direction` reflects visible changes. + +```python +# backend/app/market/models.py +"""Price data model shared by every market data source.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class PriceUpdate: + """Latest price for one ticker, as stored in the cache and sent over SSE.""" + + ticker: str + price: float + previous_price: float + reference_price: float + timestamp: float + + @property + def change(self) -> float: + """Tick-over-tick price change.""" + return round(self.price - self.previous_price, 4) + + @property + def direction(self) -> str: + """'up', 'down' or 'flat' versus the previous tick.""" + if self.price > self.previous_price: + return "up" + if self.price < self.previous_price: + return "down" + return "flat" + + @property + def day_change_percent(self) -> float: + """Percent change versus the reference (previous close or session start).""" + return round((self.price / self.reference_price - 1) * 100, 4) + + def to_dict(self) -> dict: + """JSON-ready representation used by the SSE stream and REST API.""" + return { + "ticker": self.ticker, + "price": self.price, + "previous_price": self.previous_price, + "timestamp": self.timestamp, + "change": self.change, + "direction": self.direction, + "day_change_percent": self.day_change_percent, + } +``` + +## 4. Price cache + +Thread-safe because the Massive client is synchronous and runs in a worker thread +via `asyncio.to_thread`. `version` increments on every write so the SSE stream can +send only when something changed. + +```python +# backend/app/market/cache.py +"""In-memory store of the latest price per ticker.""" + +import time +from threading import Lock + +from .models import PriceUpdate + + +class PriceCache: + """Thread-safe latest-price store. Written by one data source, read by many.""" + + def __init__(self) -> None: + self._prices: dict[str, PriceUpdate] = {} + self._lock = Lock() + self.version = 0 + + def update( + self, + ticker: str, + price: float, + timestamp: float | None = None, + reference_price: float | None = None, + ) -> PriceUpdate: + """Record a new price. The first price seen becomes the reference unless one is given.""" + price = round(price, 2) + with self._lock: + prev = self._prices.get(ticker) + update = PriceUpdate( + ticker=ticker, + price=price, + previous_price=prev.price if prev else price, + reference_price=reference_price or (prev.reference_price if prev else price), + timestamp=timestamp or time.time(), + ) + self._prices[ticker] = update + self.version += 1 + return update + + def get(self, ticker: str) -> PriceUpdate | None: + """Latest update for one ticker, or None if it has no price yet.""" + return self._prices.get(ticker) + + def get_price(self, ticker: str) -> float | None: + """Latest price for one ticker, or None.""" + update = self._prices.get(ticker) + return update.price if update else None + + def get_all(self) -> dict[str, PriceUpdate]: + """Snapshot copy of every cached price.""" + with self._lock: + return dict(self._prices) + + def remove(self, ticker: str) -> None: + """Forget a ticker.""" + with self._lock: + if self._prices.pop(ticker, None): + self.version += 1 +``` + +## 5. The interface + +All methods that may perform I/O are `async`. `start()` returns only after the +first prices are in the cache, so the first SSE event and the first trade always +have prices. + +```python +# backend/app/market/interface.py +"""Abstract interface implemented by the simulator and the Massive poller.""" + +from abc import ABC, abstractmethod + + +class MarketDataSource(ABC): + """Background producer that keeps a PriceCache up to date for a set of tickers.""" + + @abstractmethod + async def start(self, tickers: list[str]) -> None: + """Begin producing prices for the given tickers. Returns once the first prices are cached.""" + + @abstractmethod + async def stop(self) -> None: + """Stop the background task. Safe to call more than once.""" + + @abstractmethod + async def add_ticker(self, ticker: str) -> None: + """Start tracking a ticker. No-op if already tracked.""" + + @abstractmethod + async def remove_ticker(self, ticker: str) -> None: + """Stop tracking a ticker and drop it from the cache. No-op if not tracked.""" + + @abstractmethod + def get_tickers(self) -> list[str]: + """Tickers currently tracked.""" +``` + +## 6. Which tickers are tracked + +PLAN.md review item 1: removing a ticker from the watchlist must not strand an open +position without a price. Rule: + +> tracked tickers = watchlist ∪ tickers with an open position + +The portfolio/watchlist service (not the market package) enforces it: + +| Event | Action | +|---|---| +| Startup | `await source.start(watchlist ∪ positions)` | +| Ticker added to watchlist | `await source.add_ticker(t)` | +| Buy of an untracked ticker | `await source.add_ticker(t)` before reading the price | +| Ticker removed from watchlist | `remove_ticker(t)` only if no open position | +| Position closed (qty -> 0) | `remove_ticker(t)` only if not on the watchlist | + +```python +async def sync_ticker(source: MarketDataSource, ticker: str, watched: bool, held: bool) -> None: + """Track a ticker exactly when it is watched or held.""" + if watched or held: + await source.add_ticker(ticker) + else: + await source.remove_ticker(ticker) +``` + +## 7. Factory + +The only place that reads `MASSIVE_API_KEY`. An empty or whitespace-only value +means "use the simulator", matching PLAN.md section 5. + +```python +# backend/app/market/factory.py +"""Chooses the market data source from the environment.""" + +import os + +from .cache import PriceCache +from .interface import MarketDataSource +from .massive_client import MassiveDataSource +from .simulator import SimulatorDataSource + + +def create_market_data_source(cache: PriceCache) -> MarketDataSource: + """Massive if MASSIVE_API_KEY is set and non-empty, otherwise the simulator.""" + api_key = os.environ.get("MASSIVE_API_KEY", "").strip() + if api_key: + return MassiveDataSource(cache, api_key) + return SimulatorDataSource(cache) +``` + +## 8. Massive implementation + +Behavior (endpoints explained in `MASSIVE_API.md`): + +- **Paid plan**: one `get_snapshot_all` call for all tracked tickers every 5 s. + Price = last trade, falling back to today's close, then the previous close. + `reference_price` = previous close, so `day_change_percent` is a true daily change. +- **Free plan**: the first snapshot call fails with `NOT_AUTHORIZED`; the source + switches permanently to end-of-day mode. It loads all closes from one Grouped Daily + call (walking back over weekends/holidays), refreshes every 15 min, and serves + newly added tickers from that in-memory result without another API call. Prices + do not move intraday on the free plan. +- **Errors** in the background loop (network, 5xx after the client's own retries) + are logged and retried on the next poll; they never kill the task. Errors during + `start()` propagate, so a bad key fails loudly at startup. +- **Unknown tickers** are absent from Massive responses, so they never get a price. + +```python +# backend/app/market/massive_client.py +"""Market data source backed by the Massive (formerly Polygon.io) REST API.""" + +import asyncio +import logging +from datetime import date, timedelta + +from massive import RESTClient +from massive.exceptions import BadResponse + +from .cache import PriceCache +from .interface import MarketDataSource + +logger = logging.getLogger(__name__) + + +class MassiveDataSource(MarketDataSource): + """Polls Massive for the tracked tickers and writes prices to the PriceCache. + + Paid plans use the multi-ticker Snapshot endpoint every `interval` seconds. + Free plans are not entitled to snapshots; the source detects this on the first + poll and switches to end-of-day closes from Grouped Daily, refreshed every + `eod_interval` seconds. + """ + + def __init__( + self, + cache: PriceCache, + api_key: str, + interval: float = 5.0, + eod_interval: float = 900.0, + ) -> None: + self.cache = cache + self.client = RESTClient(api_key=api_key) + self.interval = interval + self.eod_interval = eod_interval + self.eod_mode = False + self._eod_closes: dict[str, float] = {} + self._tickers: set[str] = set() + self._task: asyncio.Task | None = None + + async def start(self, tickers: list[str]) -> None: + self._tickers = {t.upper() for t in tickers} + await self._poll() + self._task = asyncio.create_task(self._run(), name="massive-poller") + + async def stop(self) -> None: + if self._task: + self._task.cancel() + self._task = None + + async def add_ticker(self, ticker: str) -> None: + ticker = ticker.upper() + if ticker in self._tickers: + return + self._tickers.add(ticker) + if self.eod_mode and self._eod_closes: + self._write_eod({ticker}) + else: + await self._poll() + + async def remove_ticker(self, ticker: str) -> None: + self._tickers.discard(ticker.upper()) + self.cache.remove(ticker.upper()) + + def get_tickers(self) -> list[str]: + return sorted(self._tickers) + + async def _run(self) -> None: + """Poll until cancelled. Errors are logged and the next poll retries.""" + while True: + await asyncio.sleep(self.eod_interval if self.eod_mode else self.interval) + try: + await self._poll() + except Exception: + logger.exception("Massive poll failed") + + async def _poll(self) -> None: + """Fetch prices for all tracked tickers, falling back to EOD on a free plan.""" + if not self._tickers: + return + if not self.eod_mode: + try: + await asyncio.to_thread(self._fetch_snapshot) + return + except BadResponse as e: + if "NOT_AUTHORIZED" not in str(e): + raise + logger.warning("Massive key has no snapshot access; using end-of-day prices") + self.eod_mode = True + self._eod_closes = await asyncio.to_thread(self._fetch_latest_closes) + self._write_eod(self._tickers) + + def _fetch_snapshot(self) -> None: + """Write current prices for all tracked tickers from one Snapshot call.""" + snapshots = self.client.get_snapshot_all("stocks", tickers=sorted(self._tickers)) + for snap in snapshots: + prev_close = snap.prev_day.close if snap.prev_day else None + trade = snap.last_trade + price = (trade.price if trade else None) or (snap.day.close if snap.day else None) or prev_close + if not price: + continue + ts = trade.sip_timestamp / 1e9 if trade and trade.sip_timestamp else None + self.cache.update(snap.ticker, price, ts, reference_price=prev_close or None) + + def _fetch_latest_closes(self, max_days_back: int = 7) -> dict[str, float]: + """All closes from the most recent trading day that has Grouped Daily data.""" + day = date.today() - timedelta(days=1) + for _ in range(max_days_back): + bars = self.client.get_grouped_daily_aggs(day.isoformat()) + if bars: + return {b.ticker: b.close for b in bars} + day -= timedelta(days=1) + return {} + + def _write_eod(self, tickers: set[str]) -> None: + """Copy cached end-of-day closes for the given tickers into the PriceCache.""" + for ticker in tickers: + if ticker in self._eod_closes: + self.cache.update(ticker, self._eod_closes[ticker]) +``` + +## 9. Simulator implementation + +`SimulatorDataSource` wraps `GBMSimulator` in an asyncio task that steps every +500 ms. Full code and the math are in `MARKET_SIMULATOR.md`. Any ticker string gets +a price (unknown symbols start at a random $50-$300). + +## 10. SSE stream + +Answers PLAN.md review item 3. One event per change of the cache, carrying every +tracked ticker: + +``` +retry: 1000 + +data: {"AAPL": {"ticker": "AAPL", "price": 190.12, "previous_price": 190.1, "timestamp": 1759507199.12, "change": 0.02, "direction": "up", "day_change_percent": 0.0632}, "GOOGL": {...}} +``` + +The server checks the cache every 500 ms and sends only if `version` changed, so on +the Massive free plan the stream is nearly silent after the first event (the +browser's `EventSource` keeps the connection open regardless). `retry: 1000` makes +the browser reconnect after 1 s. + +```python +# backend/app/market/stream.py +"""SSE endpoint that streams the PriceCache to browsers.""" + +import asyncio +import json +from collections.abc import AsyncIterator + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from .cache import PriceCache + + +def create_stream_router(cache: PriceCache, poll_seconds: float = 0.5) -> APIRouter: + """Router exposing GET /api/stream/prices.""" + router = APIRouter() + + @router.get("/api/stream/prices") + async def stream_prices(request: Request) -> StreamingResponse: + return StreamingResponse( + price_events(cache, request, poll_seconds), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + return router + + +async def price_events(cache: PriceCache, request: Request, poll_seconds: float) -> AsyncIterator[str]: + """Yield one SSE event with every ticker whenever the cache changes.""" + yield "retry: 1000\n\n" + last_version = -1 + while not await request.is_disconnected(): + if cache.version != last_version: + last_version = cache.version + payload = {t: u.to_dict() for t, u in cache.get_all().items()} + yield f"data: {json.dumps(payload)}\n\n" + await asyncio.sleep(poll_seconds) +``` + +## 11. Wiring into FastAPI + +```python +# backend/app/main.py (market data parts only) +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.market.cache import PriceCache +from app.market.factory import create_market_data_source +from app.market.stream import create_stream_router + +price_cache = PriceCache() +market_source = create_market_data_source(price_cache) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await market_source.start(load_tracked_tickers()) # watchlist ∪ positions from SQLite + yield + await market_source.stop() + + +app = FastAPI(lifespan=lifespan) +app.include_router(create_stream_router(price_cache)) +``` + +Reading a price elsewhere, for example in trade execution: + +```python +price = price_cache.get_price(ticker) +if price is None: + raise HTTPException(400, f"No price available for {ticker}") +``` + +## 12. Validating new tickers + +Answers PLAN.md review item 6. When the user (or the LLM) adds a ticker: + +1. Upper-case it and check the format: `re.fullmatch(r"[A-Z][A-Z.]{0,9}", ticker)`. +2. `await source.add_ticker(ticker)`. +3. If `price_cache.get_price(ticker)` is still `None`, call `remove_ticker` and + return 400 "Unknown ticker". With Massive this rejects symbols that do not exist. + The simulator accepts any well-formed symbol. + +## 13. Testing + +- `PriceCache`: first update has `previous_price == price` and `direction == "flat"`; + a second update sets direction and change; `reference_price` sticks; `remove` + bumps `version`. +- Factory: unset, empty and whitespace key -> simulator; any other value -> Massive. +- `MassiveDataSource`: replace `source.client` with a stub object exposing + `get_snapshot_all` / `get_grouped_daily_aggs`. Cover the paid path, the + `NOT_AUTHORIZED` fallback, a missing (invalid) ticker, and the weekend walk-back. + No network or API key needed. +- `price_events`: pass a stub request whose `is_disconnected` returns `True` after + N calls and assert the first event is `retry:` and the next is `data:` JSON. +- Simulator tests: see `MARKET_SIMULATOR.md`. diff --git a/planning/MARKET_SIMULATOR.md b/planning/MARKET_SIMULATOR.md new file mode 100644 index 000000000..a3f58bae8 --- /dev/null +++ b/planning/MARKET_SIMULATOR.md @@ -0,0 +1,354 @@ +# Market Simulator + +The default market data source, used whenever `MASSIVE_API_KEY` is not set. It +produces realistic-looking, correlated, live prices with no network or API key. +It implements the `MarketDataSource` interface in `MARKET_INTERFACE.md`. + +Requirements from PLAN.md section 6: + +- Geometric Brownian motion (GBM) with per-ticker drift and volatility +- Updates about every 500 ms +- Correlated moves (tech stocks move together) +- Occasional random 2-5% "events" for drama +- Realistic seed prices +- In-process background task, no external dependencies + +## 1. The model + +### 1.1 Geometric Brownian motion + +GBM is the standard model behind Black-Scholes. A price `S` evolves as + +``` +dS = mu * S * dt + sigma * S * dW +``` + +with `mu` the annualized drift, `sigma` the annualized volatility and `W` a Wiener +process. Its exact discrete solution over a step `dt` is + +``` +S(t + dt) = S(t) * exp((mu - sigma^2 / 2) * dt + sigma * sqrt(dt) * Z), Z ~ N(0, 1) +``` + +Why this form: + +- Prices stay strictly positive (exponential of a real number). +- Returns are log-normal, the textbook assumption for stocks. +- It is exact for any `dt`, so there is no discretization drift. +- The `-sigma^2/2` term makes the *expected* price grow at `mu`. + +### 1.2 Time step + +Volatilities are annual, so a 500 ms tick is expressed as a fraction of a trading +year (252 days x 6.5 h): + +``` +dt = 0.5 / (252 * 6.5 * 3600) = 0.5 / 5,896,800 ≈ 8.48e-8 +``` + +At this scale AAPL (sigma 22%, ~$190) moves about 1.2 cents per tick (one standard +deviation) and about 0.07% per minute: realistic, and enough to make most ticks flash +on a 2-decimal display. Passing a larger `dt` speeds the market up for demos. + +### 1.3 Correlated moves + +Independent normals `Z` would make every stock move on its own. To correlate them: + +1. Build a correlation matrix `C` from sector membership. +2. Factor it once: `C = L L^T` (Cholesky, `numpy.linalg.cholesky`). +3. Each tick draw independent `z ~ N(0, I)` and use `L @ z`, which has covariance `C`. + +Correlation rules: + +| Pair | Correlation | +|---|---| +| Same ticker | 1.0 | +| Both in tech (AAPL, GOOGL, MSFT, AMZN, META, NVDA, NFLX) | 0.6 | +| Both in finance (JPM, V) | 0.5 | +| Any pair involving TSLA | 0.3 (it marches to its own beat) | +| Everything else, including unknown tickers | 0.3 | + +This block structure is positive definite for any number of tickers, so Cholesky +always succeeds. `L` is recomputed only when tickers are added or removed (cheap for +tens of tickers). + +### 1.4 Random events + +Each tick, each ticker has a probability of `0.001` of a jump: an extra move of +uniform 2-5%, up or down with equal chance, applied multiplicatively on top of the +GBM step. With 10 tickers at 2 ticks/s that is one event roughly every 50 seconds +somewhere on the watchlist: enough drama for a demo, rare enough to stay believable. + +### 1.5 Seed prices and parameters + +| Ticker | Seed $ | mu | sigma | Character | +|---|---|---|---|---| +| AAPL | 190 | 0.05 | 0.22 | Large-cap, steady | +| GOOGL | 175 | 0.05 | 0.25 | | +| MSFT | 420 | 0.05 | 0.20 | Lowest-vol tech | +| AMZN | 185 | 0.05 | 0.28 | | +| TSLA | 250 | 0.03 | 0.50 | Very volatile | +| NVDA | 800 | 0.08 | 0.40 | Volatile, strong drift | +| META | 500 | 0.05 | 0.30 | | +| JPM | 195 | 0.04 | 0.18 | Bank, low vol | +| V | 280 | 0.04 | 0.17 | Payments, lowest vol | +| NFLX | 600 | 0.05 | 0.35 | | +| *other* | random 50-300 | 0.05 | 0.25 | Any ticker the user adds | + +Any symbol is accepted; an unknown one starts at a random price between $50 and +$300 with default parameters, so watchlist adds always work in simulator mode. + +## 2. Code + +### 2.1 Seed data + +```python +# backend/app/market/seed_prices.py +"""Seed prices, per-ticker GBM parameters and sector groups for the simulator.""" + +SEED_PRICES: dict[str, float] = { + "AAPL": 190.0, + "GOOGL": 175.0, + "MSFT": 420.0, + "AMZN": 185.0, + "TSLA": 250.0, + "NVDA": 800.0, + "META": 500.0, + "JPM": 195.0, + "V": 280.0, + "NFLX": 600.0, +} + +# Annualized (drift mu, volatility sigma). +TICKER_PARAMS: dict[str, tuple[float, float]] = { + "AAPL": (0.05, 0.22), + "GOOGL": (0.05, 0.25), + "MSFT": (0.05, 0.20), + "AMZN": (0.05, 0.28), + "TSLA": (0.03, 0.50), + "NVDA": (0.08, 0.40), + "META": (0.05, 0.30), + "JPM": (0.04, 0.18), + "V": (0.04, 0.17), + "NFLX": (0.05, 0.35), +} + +DEFAULT_PARAMS: tuple[float, float] = (0.05, 0.25) + +SECTORS: dict[str, set[str]] = { + "tech": {"AAPL", "GOOGL", "MSFT", "AMZN", "META", "NVDA", "NFLX"}, + "finance": {"JPM", "V"}, +} + +INTRA_SECTOR_CORR: dict[str, float] = {"tech": 0.6, "finance": 0.5} +CROSS_SECTOR_CORR = 0.3 +TSLA_CORR = 0.3 +``` + +### 2.2 Simulator and data source + +`GBMSimulator` is pure and synchronous: it holds prices and steps them, which makes +it trivial to unit-test with a fixed `seed`. `SimulatorDataSource` is the thin async +adapter that runs it every 500 ms and writes to the `PriceCache`. + +Notes on the code: + +- The whole step is vectorized: one `L @ z` matrix-vector product, one `exp`, one + random draw for events. +- `add_ticker` writes the seed price to the cache immediately, so a new watchlist + entry shows a price before the next tick. +- The simulator task and the API handlers share one event loop, so adding/removing + tickers never races with `step()`. No locks are needed in the simulator itself. +- The first cached price for each ticker becomes its `reference_price`, so + `day_change_percent` is "change since startup", which is the closest honest + analogue of a daily change for a simulated market. + +```python +# backend/app/market/simulator.py +"""Correlated geometric Brownian motion price simulator.""" + +import asyncio +import time + +import numpy as np + +from .cache import PriceCache +from .interface import MarketDataSource +from .seed_prices import ( + CROSS_SECTOR_CORR, + DEFAULT_PARAMS, + INTRA_SECTOR_CORR, + SECTORS, + SEED_PRICES, + TICKER_PARAMS, + TSLA_CORR, +) + +TRADING_SECONDS_PER_YEAR = 252 * 6.5 * 3600 +TICK_SECONDS = 0.5 + + +def sector_of(ticker: str) -> str | None: + """Sector name for a ticker, or None if unclassified.""" + return next((name for name, members in SECTORS.items() if ticker in members), None) + + +def pair_correlation(a: str, b: str) -> float: + """Correlation between two tickers' daily moves.""" + if a == b: + return 1.0 + if "TSLA" in (a, b): + return TSLA_CORR + sector = sector_of(a) + if sector and sector == sector_of(b): + return INTRA_SECTOR_CORR[sector] + return CROSS_SECTOR_CORR + + +class GBMSimulator: + """Steps a set of correlated GBM price paths, with occasional jump events.""" + + def __init__( + self, + tickers: list[str], + dt: float = TICK_SECONDS / TRADING_SECONDS_PER_YEAR, + event_probability: float = 0.001, + seed: int | None = None, + ) -> None: + self.dt = dt + self.event_probability = event_probability + self.rng = np.random.default_rng(seed) + self.prices: dict[str, float] = {} + self._tickers: list[str] = [] + for ticker in tickers: + self.add_ticker(ticker) + + @property + def tickers(self) -> list[str]: + """Tickers being simulated.""" + return list(self._tickers) + + def add_ticker(self, ticker: str) -> None: + """Add a ticker at its seed price, or a random price if unknown.""" + if ticker in self.prices: + return + self.prices[ticker] = SEED_PRICES.get(ticker) or float(self.rng.uniform(50, 300)) + self._tickers.append(ticker) + self._rebuild() + + def remove_ticker(self, ticker: str) -> None: + """Stop simulating a ticker.""" + if ticker not in self.prices: + return + del self.prices[ticker] + self._tickers.remove(ticker) + self._rebuild() + + def _rebuild(self) -> None: + """Recompute parameter vectors and the Cholesky factor of the correlation matrix.""" + params = [TICKER_PARAMS.get(t, DEFAULT_PARAMS) for t in self._tickers] + self._mu = np.array([p[0] for p in params]) + self._sigma = np.array([p[1] for p in params]) + corr = np.array([[pair_correlation(a, b) for b in self._tickers] for a in self._tickers]) + self._chol = np.linalg.cholesky(corr) if self._tickers else corr + + def step(self) -> dict[str, float]: + """Advance every price by one tick and return the new prices.""" + n = len(self._tickers) + if n == 0: + return {} + z = self._chol @ self.rng.standard_normal(n) + log_returns = (self._mu - 0.5 * self._sigma**2) * self.dt + self._sigma * np.sqrt(self.dt) * z + shocks = self._event_shocks(n) + current = np.array([self.prices[t] for t in self._tickers]) + new = current * np.exp(log_returns) * (1 + shocks) + self.prices = dict(zip(self._tickers, new.tolist())) + return dict(self.prices) + + def _event_shocks(self, n: int) -> np.ndarray: + """Random 2-5% up or down jumps on a small fraction of tickers.""" + hit = self.rng.random(n) < self.event_probability + size = self.rng.uniform(0.02, 0.05, n) + sign = self.rng.choice([-1.0, 1.0], n) + return np.where(hit, size * sign, 0.0) + + +class SimulatorDataSource(MarketDataSource): + """Runs GBMSimulator in a background asyncio task and writes to the PriceCache.""" + + def __init__(self, cache: PriceCache, interval: float = TICK_SECONDS) -> None: + self.cache = cache + self.interval = interval + self.sim = GBMSimulator([]) + self._task: asyncio.Task | None = None + + async def start(self, tickers: list[str]) -> None: + for ticker in tickers: + await self.add_ticker(ticker) + self._task = asyncio.create_task(self._run(), name="market-simulator") + + async def stop(self) -> None: + if self._task: + self._task.cancel() + self._task = None + + async def add_ticker(self, ticker: str) -> None: + self.sim.add_ticker(ticker) + self.cache.update(ticker, self.sim.prices[ticker]) + + async def remove_ticker(self, ticker: str) -> None: + self.sim.remove_ticker(ticker) + self.cache.remove(ticker) + + def get_tickers(self) -> list[str]: + return self.sim.tickers + + async def _run(self) -> None: + """Step the simulator every interval until cancelled.""" + while True: + await asyncio.sleep(self.interval) + now = time.time() + for ticker, price in self.sim.step().items(): + self.cache.update(ticker, price, now) +``` + +## 3. Verified behavior + +The code above was run with a fixed seed. Results: + +| Check | Expected | Measured | +|---|---|---| +| Annualized vol, 20,000 daily steps (AAPL, MSFT, JPM, TSLA) | 0.22, 0.20, 0.18, 0.50 | 0.220, 0.201, 0.179, 0.498 | +| Correlation AAPL-MSFT (same sector) | 0.6 | 0.61 | +| Correlation AAPL-JPM (cross sector) | 0.3 | 0.30 | +| Correlation AAPL-TSLA | 0.3 | 0.31 | +| Forced event (`event_probability=1`) | 2-5% move | 4.42% | +| Unknown ticker `ZZZZ` | random 50-300 | 285.76 | +| Add/remove tickers then step | no error | ok | + +## 4. Unit tests to write + +In `backend/tests/market/test_simulator.py`, using `GBMSimulator(..., seed=...)` +for determinism: + +- **Positivity**: 10,000 steps, all prices stay > 0. +- **Volatility**: with `dt=1/252`, `event_probability=0`, the standard deviation of + log returns times `sqrt(252)` is within ~5% of `sigma`. +- **Drift**: with `sigma` near zero (patch `TICKER_PARAMS`), one step of + `dt=1` multiplies the price by about `exp(mu)`. +- **Correlation**: same-sector pairs come out near 0.6, cross-sector near 0.3. +- **Events**: `event_probability=1` gives a 2-5% move on every ticker; + `event_probability=0` never moves more than a few sigma. +- **Ticker management**: add is idempotent, remove of an unknown ticker is a no-op, + stepping with zero tickers returns `{}`, unknown tickers get a 50-300 price. +- **`pair_correlation`**: table in section 1.3, including symmetry. +- **`SimulatorDataSource`**: `start()` puts seed prices in the cache immediately; + after a few intervals (`interval=0.01`) prices change and `version` increases; + `remove_ticker` drops it from the cache; `stop()` is safe to call twice. + +## 5. Possible extensions (not in scope) + +- Market hours: pause outside 9:30-16:00 ET for realism. +- Mean-reverting volatility (Heston-style) or volatility clustering. +- A market-wide factor so occasional events hit all stocks at once. +- Seed from Massive's previous close when a key is available but intraday data is not. diff --git a/planning/MASSIVE_API.md b/planning/MASSIVE_API.md new file mode 100644 index 000000000..121b063c2 --- /dev/null +++ b/planning/MASSIVE_API.md @@ -0,0 +1,371 @@ +# Massive API Reference (formerly Polygon.io) + +Reference for the market data endpoints FinAlly uses to get current and end-of-day +stock prices for several tickers at once. Researched October 2026 against the live +docs at https://massive.com/docs and the official `massive` Python client (v2.8.0). + +## 1. Background + +- Polygon.io rebranded as **Massive.com** on 30 October 2025. Existing API keys keep working. +- REST base URL: `https://api.massive.com` (`https://api.polygon.io` still works for now). +- Official Python client: package `massive` on PyPI (formerly `polygon-api-client`), + Python >= 3.9, import `from massive import RESTClient`. +- The client is **synchronous** (built on `urllib3`). In FastAPI, call it through + `asyncio.to_thread(...)` so it does not block the event loop. + +## 2. Authentication + +Pass the key in one of two ways: + +| Method | Example | +|---|---| +| Header (preferred, what the client does) | `Authorization: Bearer ` | +| Query string | `?apiKey=` | + +`RESTClient()` with no arguments reads the `MASSIVE_API_KEY` environment variable, +which is the same name FinAlly uses. It raises `massive.exceptions.AuthError` if no +key is available. + +## 3. Plans and what they allow + +The plan decides which endpoint FinAlly can use. Stocks plans (individual, Oct 2026): + +| Plan | Price | Rate limit | Data recency | Snapshots | Last trade | +|---|---|---|---|---|---| +| Basic | Free | **5 calls/min** | End of day | **No** | No | +| Starter | $29/mo | Unlimited (stay < 100 req/s) | 15-min delayed | Yes | No | +| Developer | $79/mo | Unlimited | 15-min delayed | Yes | Yes | +| Advanced | $199/mo | Unlimited | Real-time | Yes | Yes | + +The key consequence for FinAlly: + +- **Paid plans**: one Snapshot call returns current prices for all watched tickers. +- **Free plan**: Snapshot returns `403 NOT_AUTHORIZED`. The best available data is + the previous trading day's close, and one Grouped Daily call returns it for every + US stock. Prices are therefore static during the day. + +Calling an endpoint outside your plan returns: + +```json +{"status": "NOT_AUTHORIZED", "request_id": "...", "message": "You are not entitled to this data. Please upgrade your plan at https://massive.com/pricing"} +``` + +The client raises this as `massive.exceptions.BadResponse` with the JSON body as the +message. Exceeding 5 calls/min on the free plan returns HTTP 429; the client retries +429/5xx automatically (3 retries, exponential backoff starting at 0.1s). + +## 4. Endpoints + +### 4.1 Full Market Snapshot (multiple tickers, current price) - paid plans + +The main endpoint for FinAlly. One request, any number of tickers. + +``` +GET /v2/snapshot/locale/us/markets/stocks/tickers?tickers=AAPL,MSFT,TSLA +``` + +| Param | Type | Notes | +|---|---|---| +| `tickers` | comma-separated string | Case-sensitive. Omit to get all ~10,000 tickers. | +| `include_otc` | bool | Default `false`. | + +Response (abridged): + +```json +{ + "status": "OK", + "count": 1, + "tickers": [ + { + "ticker": "AAPL", + "todaysChange": 1.32, + "todaysChangePerc": 0.69, + "updated": 1759507200000000000, + "day": {"o": 190.1, "h": 192.4, "l": 189.7, "c": 191.9, "v": 41230000, "vw": 191.2}, + "prevDay": {"o": 188.0, "h": 190.9, "l": 187.5, "c": 190.58, "v": 52000000, "vw": 189.6}, + "min": {"o": 191.8, "h": 191.95, "l": 191.8, "c": 191.9, "v": 120000, "vw": 191.88, "t": 1759507140000, "n": 900, "av": 41230000}, + "lastTrade": {"p": 191.9, "s": 100, "t": 1759507199123456789, "x": 4, "i": "52983525029461", "c": [14, 41]}, + "lastQuote": {"P": 191.91, "S": 2, "p": 191.89, "s": 3, "t": 1759507199200000000} + } + ] +} +``` + +Field meanings: + +| Field | Meaning | +|---|---| +| `lastTrade.p` | Price of the latest trade - **use this as the current price** | +| `lastTrade.t` | SIP timestamp of that trade, **Unix nanoseconds** | +| `prevDay.c` | Previous session close - **reference for daily change %** | +| `day.o/h/l/c/v` | Today's session OHLCV so far (zeros before the open) | +| `todaysChange`, `todaysChangePerc` | Change vs `prevDay.c` | +| `lastQuote.p` / `lastQuote.P` | Bid / ask price | +| `min` | Latest one-minute bar (`t` in ms) | +| `updated` | Last update of this snapshot, Unix nanoseconds | + +Notes: +- Snapshot data is cleared at 3:30 AM ET and refills from about 4:00 AM ET as + pre-market trading starts. Fields can be missing or zero early in the day, so fall + back from `lastTrade.p` to `day.c` to `prevDay.c`. +- Tickers that do not exist are simply absent from `tickers`; no error. + +Python client: + +```python +from massive import RESTClient + +client = RESTClient() # reads MASSIVE_API_KEY + +snapshots = client.get_snapshot_all("stocks", tickers=["AAPL", "MSFT", "TSLA"]) +for snap in snapshots: + price = snap.last_trade.price if snap.last_trade else None + print( + snap.ticker, + price, + snap.prev_day.close if snap.prev_day else None, + snap.todays_change_percent, + snap.last_trade.sip_timestamp if snap.last_trade else None, # ns + ) +``` + +`get_snapshot_all` accepts a list or a comma-separated string and returns a list of +`TickerSnapshot` objects with attributes `ticker`, `day`, `prev_day`, `min`, +`last_trade`, `last_quote`, `todays_change`, `todays_change_percent`, `updated`. +`day`/`prev_day` are `Agg` objects (`open`, `high`, `low`, `close`, `volume`, `vwap`); +`last_trade` has `price`, `size`, `sip_timestamp`; `last_quote` has `bid_price`, +`ask_price`. + +### 4.2 Single Ticker Snapshot - paid plans + +``` +GET /v2/snapshot/locale/us/markets/stocks/tickers/{ticker} +``` + +Same object as one entry above, under the key `ticker`. Not needed by FinAlly: the +multi-ticker call covers it with one request. + +```python +snap = client.get_snapshot_ticker("stocks", "AAPL") +``` + +### 4.3 Grouped Daily (all tickers, end of day) - all plans, including free + +One request returns the daily OHLCV bar for **every** US stock on a given date. This +is the only efficient multi-ticker endpoint on the free plan. + +``` +GET /v2/aggs/grouped/locale/us/market/stocks/{date}?adjusted=true +``` + +| Param | Type | Notes | +|---|---|---| +| `date` | `YYYY-MM-DD` | A trading day. Weekends/holidays return `resultsCount: 0`. | +| `adjusted` | bool | Split-adjusted, default `true`. | +| `include_otc` | bool | Default `false`. | + +Response (abridged): + +```json +{ + "status": "OK", + "adjusted": true, + "resultsCount": 11532, + "results": [ + {"T": "AAPL", "o": 188.0, "h": 190.9, "l": 187.5, "c": 190.58, "v": 52000000, "vw": 189.6, "n": 610000, "t": 1759435200000} + ] +} +``` + +`T` is the ticker, `t` is the bar start in Unix **milliseconds**. Filter the ~11k +results down to the tickers you want on the client side. + +On the free plan the current day is not available until after the close, so request +the most recent completed trading day. Walk back day by day until `results` is +non-empty (covers weekends and holidays): + +```python +from datetime import date, timedelta +from massive import RESTClient + +client = RESTClient() + + +def latest_closes(tickers: set[str], max_days_back: int = 7) -> dict[str, float]: + """Return {ticker: close} from the most recent trading day with data.""" + day = date.today() - timedelta(days=1) + for _ in range(max_days_back): + bars = client.get_grouped_daily_aggs(day.isoformat(), adjusted=True) + if bars: + return {b.ticker: b.close for b in bars if b.ticker in tickers} + day -= timedelta(days=1) + return {} +``` + +Each skipped non-trading day costs one call, so keep `max_days_back` small on the +free plan (5 calls/min). + +### 4.4 Previous Close (one ticker, end of day) - all plans + +``` +GET /v2/aggs/ticker/{ticker}/prev?adjusted=true +``` + +```json +{ + "ticker": "AAPL", + "status": "OK", + "resultsCount": 1, + "results": [{"T": "AAPL", "o": 188.0, "h": 190.9, "l": 187.5, "c": 190.58, "v": 52000000, "vw": 189.6, "t": 1759435200000}] +} +``` + +```python +prev = client.get_previous_close_agg("AAPL") # list of PreviousCloseAgg +close = prev[0].close +``` + +One call per ticker, so 10 tickers would take 2 minutes on the free plan. Use +Grouped Daily instead for multiple tickers. + +### 4.5 Daily Open/Close (one ticker, specific date) - all plans + +``` +GET /v1/open-close/{ticker}/{date} +``` + +Returns `open`, `high`, `low`, `close`, `volume`, `preMarket`, `afterHours` for one +ticker on one date. Useful for history lookups; not needed for live prices. + +```python +oc = client.get_daily_open_close_agg("AAPL", "2026-10-02") +print(oc.open, oc.close, oc.after_hours) +``` + +### 4.6 Last Trade (one ticker, latest trade) - Developer and above + +``` +GET /v2/last/trade/{ticker} +``` + +Returns `results.p` (price), `results.s` (size), `results.t` (SIP ns timestamp). +One ticker per call; the Snapshot endpoint is strictly better for FinAlly. + +```python +trade = client.get_last_trade("AAPL") +print(trade.price, trade.sip_timestamp) +``` + +### 4.7 Aggregates / bars (history for charts) - all plans + +``` +GET /v2/aggs/ticker/{ticker}/range/{multiplier}/{timespan}/{from}/{to} +``` + +```python +bars = list(client.list_aggs("AAPL", 1, "day", "2026-09-01", "2026-10-02", limit=50000)) +for b in bars: + print(b.timestamp, b.open, b.high, b.low, b.close, b.volume) # timestamp in ms +``` + +Not part of the core plan (sparklines are built from the SSE stream), but available if +a historical main chart is added later. + +## 5. Endpoint choice for FinAlly + +| Need | Paid plan | Free plan | +|---|---|---| +| Current price, many tickers | Snapshot (4.1), one call per poll | Not available | +| Previous close, many tickers | `prevDay.c` from Snapshot | Grouped Daily (4.3), one call | +| Poll interval | 2-15 s (5 s default) | Prices only change once a day; refresh every 15-60 min | + +Detection is simple: call Snapshot once. `BadResponse` containing `NOT_AUTHORIZED` +means a free key, so switch to Grouped Daily. See `MARKET_INTERFACE.md`. + +## 6. Complete polling example + +A minimal standalone poller that works on any plan: + +```python +import asyncio +from datetime import date, timedelta + +from massive import RESTClient +from massive.exceptions import BadResponse + +TICKERS = ["AAPL", "GOOGL", "MSFT", "AMZN", "TSLA"] + + +def fetch_snapshot(client: RESTClient, tickers: list[str]) -> dict[str, float]: + """Current prices from the Snapshot endpoint (paid plans).""" + prices = {} + for snap in client.get_snapshot_all("stocks", tickers=tickers): + if snap.last_trade and snap.last_trade.price: + prices[snap.ticker] = snap.last_trade.price + elif snap.prev_day and snap.prev_day.close: + prices[snap.ticker] = snap.prev_day.close + return prices + + +def fetch_eod(client: RESTClient, tickers: list[str]) -> dict[str, float]: + """Latest daily closes from Grouped Daily (any plan).""" + wanted = set(tickers) + day = date.today() - timedelta(days=1) + for _ in range(7): + bars = client.get_grouped_daily_aggs(day.isoformat()) + if bars: + return {b.ticker: b.close for b in bars if b.ticker in wanted} + day -= timedelta(days=1) + return {} + + +async def main() -> None: + client = RESTClient() + fetch, interval = fetch_snapshot, 5.0 + try: + await asyncio.to_thread(fetch_snapshot, client, TICKERS[:1]) + except BadResponse as e: + if "NOT_AUTHORIZED" not in str(e): + raise + fetch, interval = fetch_eod, 900.0 + while True: + prices = await asyncio.to_thread(fetch, client, TICKERS) + print(prices) + await asyncio.sleep(interval) + + +asyncio.run(main()) +``` + +Run it with: + +```bash +cd backend +uv add massive +MASSIVE_API_KEY=... uv run python poll_demo.py +``` + +## 7. Gotchas + +- **Timestamps differ by endpoint**: snapshot `lastTrade.t` and `updated` are + nanoseconds; aggregate `t` values are milliseconds. Convert to seconds before use. +- **Tickers are case-sensitive**: upper-case them before calling. +- **Unknown tickers are silently dropped** from Snapshot and Grouped Daily responses. + A ticker missing from the response is the signal that it is invalid (or not traded). +- **Market hours**: outside 9:30-16:00 ET the snapshot price only moves with + pre/after-market trades, so prices may sit still for long periods. That is expected. +- **Free plan is end-of-day only**: no intraday movement at all. The simulator gives + a much better demo; Massive on the free plan is mainly useful to seed realistic prices. +- **TLS on this machine**: the `massive` client uses `urllib3` with `certifi`. If it + fails with `CERTIFICATE_VERIFY_FAILED`, call `truststore.inject_into_ssl()` at + startup (see user-level CLAUDE.md). Never disable verification. + +## Sources + +- Full Market Snapshot: https://massive.com/docs/rest/stocks/snapshots/full-market-snapshot +- Single Ticker Snapshot: https://massive.com/docs/rest/stocks/snapshots/single-ticker-snapshot +- Daily Market Summary (Grouped Daily): https://massive.com/docs/rest/stocks/aggregates/daily-market-summary +- Previous Day Bar: https://massive.com/docs/rest/stocks/aggregates/previous-day-bar +- Last Trade: https://massive.com/docs/rest/stocks/trades-quotes/last-trade +- Pricing: https://massive.com/pricing +- Python client: https://github.com/massive-com/client-python diff --git a/planning/REVIEW.md b/planning/REVIEW.md deleted file mode 100644 index d814fb2ca..000000000 --- a/planning/REVIEW.md +++ /dev/null @@ -1,50 +0,0 @@ -# Review of planning/PLAN.md - -Reviewed 2026-10-03 against the plan, implemented market data code, its tests, and repository configuration. Static review only; no tests or external provider checks were run. Portfolio, chat, frontend, and deployment components remain planned, so findings about them concern design requirements rather than implemented defects. - -The architecture is suitable for the single-user demo, but execution and persistence contracts need resolution before implementation. Section 13 already identifies important gaps, especially held-ticker tracking, API schemas, and validation. Its proposals should not be treated as settled decisions. - -## High priority - -1. **Trade writes need an atomic transaction and a concurrency policy (Sections 7–9).** Validation, balance changes, position changes, trade logging, and snapshots have no defined transaction boundary. Overlapping manual/chat buys could both validate against the same cash balance; failures between writes could leave the ledger inconsistent. Require validation and associated database writes in one serialized write transaction with rollback on failure. Read one quote per fill and retain it throughout the transaction. Test concurrent buys and injected write failures. - -2. **Retries can execute trades twice (Sections 8–9).** A request may commit even if its HTTP response is lost. Retrying it can repeat the trade or every chat action. Define a client request identifier, persisted deduplication, and replay of the original result. For chat, define recovery after some actions commit but before the final response is stored. Test duplicate submissions and response loss. - -3. **Adding a ticker does not guarantee readiness to trade (Sections 6 and 9).** `SimulatorDataSource.add_ticker()` seeds a quote immediately, but `MassiveDataSource.add_ticker()` only includes it in the next poll. Applying watchlist additions before trades therefore does not solve add-and-buy requests in both modes. Define action order, bounded quote readiness, and whether batches permit partial success. Return each action's actual fill or error. Cover unwatched tickers and sell-then-buy batches. - -4. **Polling frequency does not guarantee fresh quotes (Section 6).** `MassiveDataSource._poll_once()` retains old cache entries on failures and uses the last trade timestamp. A 15-second polling interval does not bound quote age to 15 seconds. Specify acceptable quote age, handling of missing/stale quotes, and their effect on valuation and snapshots. Reject or explicitly label stale-price fills. Keep data freshness separate from the header's SSE connection status. - -5. **Financial precision and accounting rules are missing (Section 7).** Money and fractional quantities use REAL. Define quantity precision, monetary rounding, weighted-average cost on buys, cost basis on partial sells, and residual quantities on liquidation. Apply one consistent decimal or scaled-integer policy across validation, persistence, and responses. Test fractional trades and purchases whose rounded cost exactly equals available cash. - -6. **The completed LLM call cannot narrate later execution errors (Section 9).** The plan says a failed trade's error is returned so the LLM can inform the user, but its only model call precedes execution. Generated text can claim success when validation later fails. Use a bounded follow-up call or render authoritative action results separately. Include actual execution outcomes in subsequent conversation history, not only assistant text. - -7. **Initialization and task ordering need one owner (Sections 4 and 7).** Lazy initialization conflicts with background tasks needing seeded state. Define startup order: initialize/seed transactionally, load watchlist plus held tickers, start the data source, then snapshots; stop tasks on shutdown. Preserve an intentionally empty watchlist and existing balances. Specify one application worker: multiple workers otherwise own independent caches and pollers and may duplicate snapshots. - -## Medium priority - -8. **Simulator restart continuity is undefined (Sections 6–7).** SQLite preserves holdings and snapshots, but `GBMSimulator` restarts from seed prices. Restarting or removing and re-adding a ticker can create abrupt valuation jumps. Choose whether simulation prices persist or reset, explain the behavior in the UI, and test restart with open positions. - -9. **Persisted chat history has no retrieval API (Sections 7–10).** Only `POST /api/chat` is listed. Define a read endpoint or bootstrap response, stable ordering, and bounded retrieval so reloads restore messages and action results. - -10. **Ticker normalization differs by provider.** Massive additions strip whitespace and uppercase symbols; its startup path simply copies the list, while the simulator and cache do not normalize. Normalize at a common boundary for manual requests, model actions, persisted state, and startup. Otherwise `aapl` becomes a separate simulated asset with a random seed price. Test lowercase and whitespace inputs in both modes. - -11. **SSE empty-state and idle behavior are incomplete (Section 6).** `PriceCache.remove()` does not increment the version, and `_generate_events()` never emits empty snapshots or periodic heartbeats. Removing the final tracked ticker cannot clear client state through the stream; an idle stream has no regular traffic. Define empty snapshots and heartbeat comments. Test final-ticker removal and reconnecting to an empty cache. Stream connectivity must not imply provider health. - -12. **Docker storage documentation contradicts the command (Section 11).** `-v finally-data:/app/db` mounts a named volume, not the repository's `db/` directory. Choose a named volume or host bind mount, document where data lives, and align scripts and local paths. Ignore the runtime database and SQLite sidecars; `.gitignore` currently excludes only `db.sqlite3`. - -## Additional implementation notes - -- Section 9 provides an example object, not a formal JSON schema. Define allowed sides/actions, finite positive quantities, unknown-field handling, and defaults for omitted arrays. Validate model output before executing it. -- Register API routes before mounting static files at `/`. Define static 404/fallback behavior while preserving API errors for unknown `/api/*` paths. -- Define the positions table percentage, preferably unrealized return against average cost. Section 13 separately covers the missing watchlist daily-change definition. -- `.env.example` and the planned runtime `db/` directory are absent. Include them as implementation deliverables. Mock mode should start without an OpenRouter key. -- `create_stream_router()` adds routes to a module-level router; repeated calls register duplicate routes with different cache closures. Construct the router inside the factory. -- Public deployment would allow anyone with access to spend paid chat credit and mutate the shared portfolio. Define access restrictions before deploying publicly. This exposes use of the server-side key, not necessarily its value. - -## Acceptance checks - -Extend Section 12 with concurrent and duplicate trades; partial chat failure; stale/missing quotes; add-and-buy in both providers; fractional/full-liquidation accounting; empty portfolio/watchlist rendering; reload restoring chat; and restart preserving balances, holdings, and the chosen simulation semantics. - -Use fixed quotes for exact accounting assertions. A random seed alone does not make wall-clock E2E execution deterministic because requests can occur after different numbers of simulator ticks. Keep live-simulator checks focused on streaming and UI updates. - -Resolve findings 1–7 together with Section 13's contract gaps before backend/frontend integration. They determine whether the UI can accurately report what executed and what the portfolio is worth. diff --git a/planning/archive/MARKET_DATA_DESIGN.md b/planning/archive/MARKET_DATA_DESIGN.md deleted file mode 100644 index 0d2cfd5fd..000000000 --- a/planning/archive/MARKET_DATA_DESIGN.md +++ /dev/null @@ -1,1490 +0,0 @@ -# Market Data Backend — Detailed Design - -Implementation-ready design for the FinAlly market data subsystem. Covers the unified interface, in-memory price cache, GBM simulator, Massive API client, SSE streaming endpoint, and FastAPI lifecycle integration. - -Everything in this document lives under `backend/app/market/`. - ---- - -## Table of Contents - -1. [File Structure](#1-file-structure) -2. [Data Model — `models.py`](#2-data-model) -3. [Price Cache — `cache.py`](#3-price-cache) -4. [Abstract Interface — `interface.py`](#4-abstract-interface) -5. [Seed Prices & Ticker Parameters — `seed_prices.py`](#5-seed-prices--ticker-parameters) -6. [GBM Simulator — `simulator.py`](#6-gbm-simulator) -7. [Massive API Client — `massive_client.py`](#7-massive-api-client) -8. [Factory — `factory.py`](#8-factory) -9. [SSE Streaming Endpoint — `stream.py`](#9-sse-streaming-endpoint) -10. [FastAPI Lifecycle Integration](#10-fastapi-lifecycle-integration) -11. [Watchlist Coordination](#11-watchlist-coordination) -12. [Testing Strategy](#12-testing-strategy) -13. [Error Handling & Edge Cases](#13-error-handling--edge-cases) -14. [Configuration Summary](#14-configuration-summary) - ---- - -## 1. File Structure - -``` -backend/ - app/ - market/ - __init__.py # Re-exports: PriceUpdate, PriceCache, MarketDataSource, create_market_data_source - models.py # PriceUpdate dataclass - cache.py # PriceCache (thread-safe in-memory store) - interface.py # MarketDataSource ABC - seed_prices.py # SEED_PRICES, TICKER_PARAMS, DEFAULT_PARAMS, CORRELATION_GROUPS - simulator.py # GBMSimulator + SimulatorDataSource - massive_client.py # MassiveDataSource - factory.py # create_market_data_source() - stream.py # SSE endpoint (FastAPI router) -``` - -Each file has a single responsibility. The `__init__.py` re-exports the public API so that the rest of the backend imports from `app.market` without reaching into submodules. - ---- - -## 2. Data Model - -**File: `backend/app/market/models.py`** - -`PriceUpdate` is the only data structure that leaves the market data layer. Every downstream consumer — SSE streaming, portfolio valuation, trade execution — works exclusively with this type. - -```python -from __future__ import annotations - -import time -from dataclasses import dataclass, field - - -@dataclass(frozen=True, slots=True) -class PriceUpdate: - """Immutable snapshot of a single ticker's price at a point in time.""" - - ticker: str - price: float - previous_price: float - timestamp: float = field(default_factory=time.time) # Unix seconds - - @property - def change(self) -> float: - """Absolute price change from previous update.""" - return round(self.price - self.previous_price, 4) - - @property - def change_percent(self) -> float: - """Percentage change from previous update.""" - if self.previous_price == 0: - return 0.0 - return round((self.price - self.previous_price) / self.previous_price * 100, 4) - - @property - def direction(self) -> str: - """'up', 'down', or 'flat'.""" - if self.price > self.previous_price: - return "up" - elif self.price < self.previous_price: - return "down" - return "flat" - - def to_dict(self) -> dict: - """Serialize for JSON / SSE transmission.""" - return { - "ticker": self.ticker, - "price": self.price, - "previous_price": self.previous_price, - "timestamp": self.timestamp, - "change": self.change, - "change_percent": self.change_percent, - "direction": self.direction, - } -``` - -### Design decisions - -- **`frozen=True`**: Price updates are immutable value objects. Once created they never change, which makes them safe to share across async tasks without copying. -- **`slots=True`**: Minor memory optimization — we create many of these per second. -- **Computed properties** (`change`, `direction`, `change_percent`): Derived from `price` and `previous_price` so they can never be inconsistent. No risk of a stale `direction` field. -- **`to_dict()`**: Single serialization point used by both the SSE endpoint and REST API responses. - ---- - -## 3. Price Cache - -**File: `backend/app/market/cache.py`** - -The price cache is the central data hub. Data sources write to it; SSE streaming and portfolio valuation read from it. It must be thread-safe because the simulator/poller may run in a thread pool executor while SSE reads happen on the async event loop. - -```python -from __future__ import annotations - -import asyncio -import time -from threading import Lock -from typing import Callable - -from .models import PriceUpdate - - -class PriceCache: - """Thread-safe in-memory cache of the latest price for each ticker. - - Writers: SimulatorDataSource or MassiveDataSource (one at a time). - Readers: SSE streaming endpoint, portfolio valuation, trade execution. - """ - - def __init__(self) -> None: - self._prices: dict[str, PriceUpdate] = {} - self._lock = Lock() - self._version: int = 0 # Monotonically increasing; bumped on every update - - def update(self, ticker: str, price: float, timestamp: float | None = None) -> PriceUpdate: - """Record a new price for a ticker. Returns the created PriceUpdate. - - Automatically computes direction and change from the previous price. - If this is the first update for the ticker, previous_price == price (direction='flat'). - """ - with self._lock: - ts = timestamp or time.time() - prev = self._prices.get(ticker) - previous_price = prev.price if prev else price - - update = PriceUpdate( - ticker=ticker, - price=round(price, 2), - previous_price=round(previous_price, 2), - timestamp=ts, - ) - self._prices[ticker] = update - self._version += 1 - return update - - def get(self, ticker: str) -> PriceUpdate | None: - """Get the latest price for a single ticker, or None if unknown.""" - with self._lock: - return self._prices.get(ticker) - - def get_all(self) -> dict[str, PriceUpdate]: - """Snapshot of all current prices. Returns a shallow copy.""" - with self._lock: - return dict(self._prices) - - def get_price(self, ticker: str) -> float | None: - """Convenience: get just the price float, or None.""" - update = self.get(ticker) - return update.price if update else None - - def remove(self, ticker: str) -> None: - """Remove a ticker from the cache (e.g., when removed from watchlist).""" - with self._lock: - self._prices.pop(ticker, None) - - @property - def version(self) -> int: - """Current version counter. Useful for SSE change detection.""" - return self._version - - def __len__(self) -> int: - with self._lock: - return len(self._prices) - - def __contains__(self, ticker: str) -> bool: - with self._lock: - return ticker in self._prices -``` - -### Why a version counter? - -The SSE streaming loop polls the cache every ~500ms. Without a version counter, it would serialize and send all prices every tick even if nothing changed (e.g., Massive API only updates every 15s). The version counter lets the SSE loop skip sends when nothing is new: - -```python -last_version = -1 -while True: - if price_cache.version != last_version: - last_version = price_cache.version - yield format_sse(price_cache.get_all()) - await asyncio.sleep(0.5) -``` - -### Thread safety rationale - -The `threading.Lock` is used instead of `asyncio.Lock` because: -- The Massive client's synchronous `get_snapshot_all()` runs in `asyncio.to_thread()`, which operates in a real OS thread — `asyncio.Lock` would not protect against that. -- The GBM simulator's `step()` is CPU-bound and could also be offloaded to a thread for fairness. -- `threading.Lock` works correctly from both sync threads and the async event loop. - ---- - -## 4. Abstract Interface - -**File: `backend/app/market/interface.py`** - -```python -from __future__ import annotations - -from abc import ABC, abstractmethod - - -class MarketDataSource(ABC): - """Contract for market data providers. - - Implementations push price updates into a shared PriceCache on their own - schedule. Downstream code never calls the data source directly for prices — - it reads from the cache. - - Lifecycle: - source = create_market_data_source(cache) - await source.start(["AAPL", "GOOGL", ...]) - # ... app runs ... - await source.add_ticker("TSLA") - await source.remove_ticker("GOOGL") - # ... app shutting down ... - await source.stop() - """ - - @abstractmethod - async def start(self, tickers: list[str]) -> None: - """Begin producing price updates for the given tickers. - - Starts a background task that periodically writes to the PriceCache. - Must be called exactly once. Calling start() twice is undefined behavior. - """ - - @abstractmethod - async def stop(self) -> None: - """Stop the background task and release resources. - - Safe to call multiple times. After stop(), the source will not write - to the cache again. - """ - - @abstractmethod - async def add_ticker(self, ticker: str) -> None: - """Add a ticker to the active set. No-op if already present. - - The next update cycle will include this ticker. - """ - - @abstractmethod - async def remove_ticker(self, ticker: str) -> None: - """Remove a ticker from the active set. No-op if not present. - - Also removes the ticker from the PriceCache. - """ - - @abstractmethod - def get_tickers(self) -> list[str]: - """Return the current list of actively tracked tickers.""" -``` - -### Why the source writes to the cache instead of returning prices - -This push model decouples timing. The simulator ticks at 500ms, Massive polls at 15s, but SSE always reads from the cache at its own 500ms cadence. There is no need for the SSE layer to know which data source is active or what its update interval is. - ---- - -## 5. Seed Prices & Ticker Parameters - -**File: `backend/app/market/seed_prices.py`** - -Constants only — no logic, no imports beyond stdlib. This file is shared by both the simulator (for initial prices and GBM parameters) and potentially by the Massive client (as fallback prices if the API hasn't responded yet). - -```python -"""Seed prices and per-ticker parameters for the market simulator.""" - -# Realistic starting prices for the default watchlist (as of project creation) -SEED_PRICES: dict[str, float] = { - "AAPL": 190.00, - "GOOGL": 175.00, - "MSFT": 420.00, - "AMZN": 185.00, - "TSLA": 250.00, - "NVDA": 800.00, - "META": 500.00, - "JPM": 195.00, - "V": 280.00, - "NFLX": 600.00, -} - -# Per-ticker GBM parameters -# sigma: annualized volatility (higher = more price movement) -# mu: annualized drift / expected return -TICKER_PARAMS: dict[str, dict[str, float]] = { - "AAPL": {"sigma": 0.22, "mu": 0.05}, - "GOOGL": {"sigma": 0.25, "mu": 0.05}, - "MSFT": {"sigma": 0.20, "mu": 0.05}, - "AMZN": {"sigma": 0.28, "mu": 0.05}, - "TSLA": {"sigma": 0.50, "mu": 0.03}, # High volatility - "NVDA": {"sigma": 0.40, "mu": 0.08}, # High volatility, strong drift - "META": {"sigma": 0.30, "mu": 0.05}, - "JPM": {"sigma": 0.18, "mu": 0.04}, # Low volatility (bank) - "V": {"sigma": 0.17, "mu": 0.04}, # Low volatility (payments) - "NFLX": {"sigma": 0.35, "mu": 0.05}, -} - -# Default parameters for tickers not in the list above (dynamically added) -DEFAULT_PARAMS: dict[str, float] = {"sigma": 0.25, "mu": 0.05} - -# Correlation groups for the simulator's Cholesky decomposition -# Tickers in the same group have higher intra-group correlation -CORRELATION_GROUPS: dict[str, set[str]] = { - "tech": {"AAPL", "GOOGL", "MSFT", "AMZN", "META", "NVDA", "NFLX"}, - "finance": {"JPM", "V"}, -} - -# Correlation coefficients -INTRA_TECH_CORR = 0.6 # Tech stocks move together -INTRA_FINANCE_CORR = 0.5 # Finance stocks move together -CROSS_GROUP_CORR = 0.3 # Between sectors -TSLA_CORR = 0.3 # TSLA does its own thing -DEFAULT_CORR = 0.3 # Unknown tickers -``` - ---- - -## 6. GBM Simulator - -**File: `backend/app/market/simulator.py`** - -This file contains two classes: -- `GBMSimulator`: Pure math engine. Stateful — holds current prices and advances them one step at a time. -- `SimulatorDataSource`: The `MarketDataSource` implementation that wraps `GBMSimulator` in an async loop and writes to the `PriceCache`. - -### 6.1 GBMSimulator — The Math Engine - -```python -from __future__ import annotations - -import asyncio -import logging -import math -import random - -import numpy as np - -from .cache import PriceCache -from .interface import MarketDataSource -from .seed_prices import ( - CORRELATION_GROUPS, - CROSS_GROUP_CORR, - DEFAULT_CORR, - DEFAULT_PARAMS, - INTRA_FINANCE_CORR, - INTRA_TECH_CORR, - SEED_PRICES, - TICKER_PARAMS, - TSLA_CORR, -) - -logger = logging.getLogger(__name__) - - -class GBMSimulator: - """Geometric Brownian Motion simulator for correlated stock prices. - - Math: - S(t+dt) = S(t) * exp((mu - sigma^2/2) * dt + sigma * sqrt(dt) * Z) - - Where: - S(t) = current price - mu = annualized drift (expected return) - sigma = annualized volatility - dt = time step as fraction of a trading year - Z = correlated standard normal random variable - - The tiny dt (~8.5e-8 for 500ms ticks over 252 trading days * 6.5h/day) - produces sub-cent moves per tick that accumulate naturally over time. - """ - - # 500ms expressed as a fraction of a trading year - # 252 trading days * 6.5 hours/day * 3600 seconds/hour = 5,896,800 seconds - TRADING_SECONDS_PER_YEAR = 252 * 6.5 * 3600 # 5,896,800 - DEFAULT_DT = 0.5 / TRADING_SECONDS_PER_YEAR # ~8.48e-8 - - def __init__( - self, - tickers: list[str], - dt: float = DEFAULT_DT, - event_probability: float = 0.001, - ) -> None: - self._dt = dt - self._event_prob = event_probability - - # Per-ticker state - self._tickers: list[str] = [] - self._prices: dict[str, float] = {} - self._params: dict[str, dict[str, float]] = {} - - # Cholesky decomposition of the correlation matrix (for correlated moves) - self._cholesky: np.ndarray | None = None - - # Initialize all starting tickers - for ticker in tickers: - self._add_ticker_internal(ticker) - self._rebuild_cholesky() - - # --- Public API --- - - def step(self) -> dict[str, float]: - """Advance all tickers by one time step. Returns {ticker: new_price}. - - This is the hot path — called every 500ms. Keep it fast. - """ - n = len(self._tickers) - if n == 0: - return {} - - # Generate n independent standard normal draws - z_independent = np.random.standard_normal(n) - - # Apply Cholesky to get correlated draws - if self._cholesky is not None: - z_correlated = self._cholesky @ z_independent - else: - z_correlated = z_independent - - result: dict[str, float] = {} - for i, ticker in enumerate(self._tickers): - params = self._params[ticker] - mu = params["mu"] - sigma = params["sigma"] - - # GBM: S(t+dt) = S(t) * exp((mu - 0.5*sigma^2)*dt + sigma*sqrt(dt)*Z) - drift = (mu - 0.5 * sigma ** 2) * self._dt - diffusion = sigma * math.sqrt(self._dt) * z_correlated[i] - self._prices[ticker] *= math.exp(drift + diffusion) - - # Random event: ~0.1% chance per tick per ticker - # With 10 tickers at 2 ticks/sec, expect an event ~every 50 seconds - if random.random() < self._event_prob: - shock_magnitude = random.uniform(0.02, 0.05) - shock_sign = random.choice([-1, 1]) - self._prices[ticker] *= 1 + shock_magnitude * shock_sign - logger.debug( - "Random event on %s: %.1f%% %s", - ticker, - shock_magnitude * 100, - "up" if shock_sign > 0 else "down", - ) - - result[ticker] = round(self._prices[ticker], 2) - - return result - - def add_ticker(self, ticker: str) -> None: - """Add a ticker to the simulation. Rebuilds the correlation matrix.""" - if ticker in self._prices: - return - self._add_ticker_internal(ticker) - self._rebuild_cholesky() - - def remove_ticker(self, ticker: str) -> None: - """Remove a ticker from the simulation. Rebuilds the correlation matrix.""" - if ticker not in self._prices: - return - self._tickers.remove(ticker) - del self._prices[ticker] - del self._params[ticker] - self._rebuild_cholesky() - - def get_price(self, ticker: str) -> float | None: - """Current price for a ticker, or None if not tracked.""" - return self._prices.get(ticker) - - # --- Internals --- - - def _add_ticker_internal(self, ticker: str) -> None: - """Add a ticker without rebuilding Cholesky (for batch initialization).""" - if ticker in self._prices: - return - self._tickers.append(ticker) - self._prices[ticker] = SEED_PRICES.get(ticker, random.uniform(50.0, 300.0)) - self._params[ticker] = TICKER_PARAMS.get(ticker, dict(DEFAULT_PARAMS)) - - def _rebuild_cholesky(self) -> None: - """Rebuild the Cholesky decomposition of the ticker correlation matrix. - - Called whenever tickers are added or removed. O(n^2) but n < 50. - """ - n = len(self._tickers) - if n <= 1: - self._cholesky = None - return - - # Build the correlation matrix - corr = np.eye(n) - for i in range(n): - for j in range(i + 1, n): - rho = self._pairwise_correlation(self._tickers[i], self._tickers[j]) - corr[i, j] = rho - corr[j, i] = rho - - self._cholesky = np.linalg.cholesky(corr) - - @staticmethod - def _pairwise_correlation(t1: str, t2: str) -> float: - """Determine correlation between two tickers based on sector grouping. - - Correlation structure: - - Same tech sector: 0.6 - - Same finance sector: 0.5 - - TSLA with anything: 0.3 (it does its own thing) - - Cross-sector: 0.3 - - Unknown tickers: 0.3 - """ - tech = CORRELATION_GROUPS["tech"] - finance = CORRELATION_GROUPS["finance"] - - # TSLA is in tech set but behaves independently - if t1 == "TSLA" or t2 == "TSLA": - return TSLA_CORR - - if t1 in tech and t2 in tech: - return INTRA_TECH_CORR - if t1 in finance and t2 in finance: - return INTRA_FINANCE_CORR - - return CROSS_GROUP_CORR -``` - -### 6.2 SimulatorDataSource — Async Wrapper - -```python -class SimulatorDataSource(MarketDataSource): - """MarketDataSource backed by the GBM simulator. - - Runs a background asyncio task that calls GBMSimulator.step() every - `update_interval` seconds and writes results to the PriceCache. - """ - - def __init__( - self, - price_cache: PriceCache, - update_interval: float = 0.5, - event_probability: float = 0.001, - ) -> None: - self._cache = price_cache - self._interval = update_interval - self._event_prob = event_probability - self._sim: GBMSimulator | None = None - self._task: asyncio.Task | None = None - - async def start(self, tickers: list[str]) -> None: - self._sim = GBMSimulator( - tickers=tickers, - event_probability=self._event_prob, - ) - # Seed the cache with initial prices so SSE has data immediately - for ticker in tickers: - price = self._sim.get_price(ticker) - if price is not None: - self._cache.update(ticker=ticker, price=price) - self._task = asyncio.create_task(self._run_loop(), name="simulator-loop") - logger.info("Simulator started with %d tickers", len(tickers)) - - async def stop(self) -> None: - if self._task and not self._task.done(): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - logger.info("Simulator stopped") - - async def add_ticker(self, ticker: str) -> None: - if self._sim: - self._sim.add_ticker(ticker) - # Seed cache immediately so the ticker has a price right away - price = self._sim.get_price(ticker) - if price is not None: - self._cache.update(ticker=ticker, price=price) - logger.info("Simulator: added ticker %s", ticker) - - async def remove_ticker(self, ticker: str) -> None: - if self._sim: - self._sim.remove_ticker(ticker) - self._cache.remove(ticker) - logger.info("Simulator: removed ticker %s", ticker) - - def get_tickers(self) -> list[str]: - return list(self._sim._tickers) if self._sim else [] - - async def _run_loop(self) -> None: - """Core loop: step the simulation, write to cache, sleep.""" - while True: - try: - if self._sim: - prices = self._sim.step() - for ticker, price in prices.items(): - self._cache.update(ticker=ticker, price=price) - except Exception: - logger.exception("Simulator step failed") - await asyncio.sleep(self._interval) -``` - -### Key behaviors - -- **Immediate seeding**: When `start()` is called, the cache is populated with seed prices *before* the loop begins. This means the SSE endpoint has data to send on its very first tick, with no blank-screen delay. -- **Graceful cancellation**: `stop()` cancels the task and awaits it, catching `CancelledError`. This ensures clean shutdown during FastAPI lifespan teardown. -- **Exception resilience**: The loop catches exceptions per-step so a single bad tick doesn't kill the entire data feed. - ---- - -## 7. Massive API Client - -**File: `backend/app/market/massive_client.py`** - -Polls the Massive (formerly Polygon.io) REST API snapshot endpoint on a configurable interval. The synchronous Massive client runs in `asyncio.to_thread()` to avoid blocking the event loop. - -```python -from __future__ import annotations - -import asyncio -import logging -from typing import Any - -from .cache import PriceCache -from .interface import MarketDataSource - -logger = logging.getLogger(__name__) - - -class MassiveDataSource(MarketDataSource): - """MarketDataSource backed by the Massive (Polygon.io) REST API. - - Polls GET /v2/snapshot/locale/us/markets/stocks/tickers for all watched - tickers in a single API call, then writes results to the PriceCache. - - Rate limits: - - Free tier: 5 req/min → poll every 15s (default) - - Paid tiers: higher limits → poll every 2-5s - """ - - def __init__( - self, - api_key: str, - price_cache: PriceCache, - poll_interval: float = 15.0, - ) -> None: - self._api_key = api_key - self._cache = price_cache - self._interval = poll_interval - self._tickers: list[str] = [] - self._task: asyncio.Task | None = None - self._client: Any = None # Lazy import to avoid hard dependency - - async def start(self, tickers: list[str]) -> None: - # Lazy import: only import massive when actually using real market data. - # This means the massive package is not required when using the simulator. - from massive import RESTClient - - self._client = RESTClient(api_key=self._api_key) - self._tickers = list(tickers) - - # Do an immediate first poll so the cache has data right away - await self._poll_once() - - self._task = asyncio.create_task(self._poll_loop(), name="massive-poller") - logger.info( - "Massive poller started: %d tickers, %.1fs interval", - len(tickers), - self._interval, - ) - - async def stop(self) -> None: - if self._task and not self._task.done(): - self._task.cancel() - try: - await self._task - except asyncio.CancelledError: - pass - self._task = None - self._client = None - logger.info("Massive poller stopped") - - async def add_ticker(self, ticker: str) -> None: - ticker = ticker.upper().strip() - if ticker not in self._tickers: - self._tickers.append(ticker) - logger.info("Massive: added ticker %s (will appear on next poll)", ticker) - - async def remove_ticker(self, ticker: str) -> None: - ticker = ticker.upper().strip() - self._tickers = [t for t in self._tickers if t != ticker] - self._cache.remove(ticker) - logger.info("Massive: removed ticker %s", ticker) - - def get_tickers(self) -> list[str]: - return list(self._tickers) - - # --- Internal --- - - async def _poll_loop(self) -> None: - """Poll on interval. First poll already happened in start().""" - while True: - await asyncio.sleep(self._interval) - await self._poll_once() - - async def _poll_once(self) -> None: - """Execute one poll cycle: fetch snapshots, update cache.""" - if not self._tickers or not self._client: - return - - try: - # The Massive RESTClient is synchronous — run in a thread to - # avoid blocking the event loop. - snapshots = await asyncio.to_thread(self._fetch_snapshots) - processed = 0 - for snap in snapshots: - try: - price = snap.last_trade.price - # Massive timestamps are Unix milliseconds → convert to seconds - timestamp = snap.last_trade.timestamp / 1000.0 - self._cache.update( - ticker=snap.ticker, - price=price, - timestamp=timestamp, - ) - processed += 1 - except (AttributeError, TypeError) as e: - logger.warning( - "Skipping snapshot for %s: %s", - getattr(snap, "ticker", "???"), - e, - ) - logger.debug("Massive poll: updated %d/%d tickers", processed, len(self._tickers)) - - except Exception as e: - logger.error("Massive poll failed: %s", e) - # Don't re-raise — the loop will retry on the next interval. - # Common failures: 401 (bad key), 429 (rate limit), network errors. - - def _fetch_snapshots(self) -> list: - """Synchronous call to the Massive REST API. Runs in a thread.""" - from massive.rest.models import SnapshotMarketType - - return self._client.get_snapshot_all( - market_type=SnapshotMarketType.STOCKS, - tickers=self._tickers, - ) -``` - -### Error handling philosophy - -The Massive poller is intentionally resilient: - -| Error | Behavior | -|-------|----------| -| **401 Unauthorized** | Logged as error. Poller keeps running (user might fix `.env` and restart). | -| **429 Rate Limited** | Logged as error. Next poll retries after `poll_interval` seconds. | -| **Network timeout** | Logged as error. Retries automatically on next cycle. | -| **Malformed snapshot** | Individual ticker skipped with warning. Other tickers still processed. | -| **All tickers fail** | Cache retains last-known prices. SSE keeps streaming stale data (better than no data). | - -### Lazy import strategy - -`from massive import RESTClient` happens inside `start()`, not at module import time. This means: -- The `massive` package is only required when `MASSIVE_API_KEY` is set. -- Students who don't have a Massive API key don't need the package installed at all. -- The simulator path has zero external dependencies beyond `numpy`. - ---- - -## 8. Factory - -**File: `backend/app/market/factory.py`** - -```python -from __future__ import annotations - -import logging -import os - -from .cache import PriceCache -from .interface import MarketDataSource - -logger = logging.getLogger(__name__) - - -def create_market_data_source(price_cache: PriceCache) -> MarketDataSource: - """Create the appropriate market data source based on environment variables. - - - MASSIVE_API_KEY set and non-empty → MassiveDataSource (real market data) - - Otherwise → SimulatorDataSource (GBM simulation) - - Returns an unstarted source. Caller must await source.start(tickers). - """ - api_key = os.environ.get("MASSIVE_API_KEY", "").strip() - - if api_key: - from .massive_client import MassiveDataSource - - logger.info("Market data source: Massive API (real data)") - return MassiveDataSource(api_key=api_key, price_cache=price_cache) - else: - from .simulator import SimulatorDataSource - - logger.info("Market data source: GBM Simulator") - return SimulatorDataSource(price_cache=price_cache) -``` - -### Usage at app startup - -```python -price_cache = PriceCache() -source = create_market_data_source(price_cache) -await source.start(initial_tickers) # e.g., ["AAPL", "GOOGL", ...] -``` - ---- - -## 9. SSE Streaming Endpoint - -**File: `backend/app/market/stream.py`** - -The SSE endpoint is a FastAPI route that holds open a long-lived HTTP connection and pushes price updates to the client as `text/event-stream`. - -```python -from __future__ import annotations - -import asyncio -import json -import logging -import time - -from fastapi import APIRouter, Request -from fastapi.responses import StreamingResponse - -from .cache import PriceCache - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/stream", tags=["streaming"]) - - -def create_stream_router(price_cache: PriceCache) -> APIRouter: - """Create the SSE streaming router with a reference to the price cache. - - This factory pattern lets us inject the PriceCache without globals. - """ - - @router.get("/prices") - async def stream_prices(request: Request) -> StreamingResponse: - """SSE endpoint for live price updates. - - Streams all tracked ticker prices every ~500ms. The client connects - with EventSource and receives events in the format: - - data: {"AAPL": {"ticker": "AAPL", "price": 190.50, ...}, ...} - - Includes a retry directive so the browser auto-reconnects on - disconnection (EventSource built-in behavior). - """ - return StreamingResponse( - _generate_events(price_cache, request), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", # Disable nginx buffering if proxied - }, - ) - - return router - - -async def _generate_events( - price_cache: PriceCache, - request: Request, - interval: float = 0.5, -) -> None: - """Async generator that yields SSE-formatted price events. - - Sends all prices every `interval` seconds. Stops when the client - disconnects (detected via request.is_disconnected()). - """ - # Tell the client to retry after 1 second if the connection drops - yield "retry: 1000\n\n" - - last_version = -1 - client_ip = request.client.host if request.client else "unknown" - logger.info("SSE client connected: %s", client_ip) - - try: - while True: - # Check for client disconnect - if await request.is_disconnected(): - logger.info("SSE client disconnected: %s", client_ip) - break - - current_version = price_cache.version - if current_version != last_version: - last_version = current_version - prices = price_cache.get_all() - - if prices: - data = { - ticker: update.to_dict() - for ticker, update in prices.items() - } - payload = json.dumps(data) - yield f"data: {payload}\n\n" - - await asyncio.sleep(interval) - except asyncio.CancelledError: - logger.info("SSE stream cancelled for: %s", client_ip) -``` - -### SSE wire format - -Each event the client receives looks like this: - -``` -data: {"AAPL":{"ticker":"AAPL","price":190.50,"previous_price":190.42,"timestamp":1707580800.5,"change":0.08,"change_percent":0.042,"direction":"up"},"GOOGL":{"ticker":"GOOGL","price":175.12,...}} - -``` - -The client parses this with: - -```javascript -const eventSource = new EventSource('/api/stream/prices'); -eventSource.onmessage = (event) => { - const prices = JSON.parse(event.data); - // prices is { "AAPL": { ticker, price, previous_price, ... }, ... } -}; -``` - -### Why poll-and-push instead of event-driven? - -The SSE endpoint polls the cache on a fixed interval rather than being notified by the data source. This is simpler and produces predictable, evenly-spaced updates for the frontend. The frontend accumulates these into sparkline charts — regular spacing is important for clean visualization. - ---- - -## 10. FastAPI Lifecycle Integration - -The market data system starts and stops with the FastAPI application using the `lifespan` context manager pattern. - -**In `backend/app/main.py`:** - -```python -from contextlib import asynccontextmanager - -from fastapi import FastAPI - -from app.market.cache import PriceCache -from app.market.factory import create_market_data_source -from app.market.interface import MarketDataSource -from app.market.stream import create_stream_router - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Manage startup and shutdown of background services.""" - - # --- STARTUP --- - - # 1. Create the shared price cache - price_cache = PriceCache() - app.state.price_cache = price_cache - - # 2. Create and start the market data source - source = create_market_data_source(price_cache) - app.state.market_source = source - - # 3. Load initial tickers from the database watchlist - initial_tickers = await load_watchlist_tickers() # reads from SQLite - await source.start(initial_tickers) - - # 4. Register the SSE streaming router - stream_router = create_stream_router(price_cache) - app.include_router(stream_router) - - yield # App is running - - # --- SHUTDOWN --- - await source.stop() - - -app = FastAPI(title="FinAlly", lifespan=lifespan) - - -# Dependency for injecting the price cache into route handlers -def get_price_cache() -> PriceCache: - return app.state.price_cache - - -def get_market_source() -> MarketDataSource: - return app.state.market_source -``` - -### Accessing market data from other routes - -Other parts of the backend (trade execution, portfolio valuation, watchlist management) access the price cache and data source via FastAPI's dependency injection: - -```python -from fastapi import APIRouter, Depends - -router = APIRouter(prefix="/api") - -@router.post("/portfolio/trade") -async def execute_trade( - trade: TradeRequest, - price_cache: PriceCache = Depends(get_price_cache), -): - current_price = price_cache.get_price(trade.ticker) - if current_price is None: - raise HTTPException(404, f"No price available for {trade.ticker}") - # ... execute trade at current_price ... - - -@router.post("/watchlist") -async def add_to_watchlist( - payload: WatchlistAdd, - source: MarketDataSource = Depends(get_market_source), - price_cache: PriceCache = Depends(get_price_cache), -): - # Add to database ... - # Then tell the data source to start tracking it - await source.add_ticker(payload.ticker) - # ... - - -@router.delete("/watchlist/{ticker}") -async def remove_from_watchlist( - ticker: str, - source: MarketDataSource = Depends(get_market_source), -): - # Remove from database ... - # Then stop tracking - await source.remove_ticker(ticker) - # ... -``` - ---- - -## 11. Watchlist Coordination - -When the watchlist changes (via REST API or LLM chat), the market data source must be notified so it tracks the right set of tickers. - -### Flow: Adding a Ticker - -``` -User (or LLM) → POST /api/watchlist {ticker: "PYPL"} - → Insert into watchlist table (SQLite) - → await source.add_ticker("PYPL") - Simulator: adds to GBMSimulator, rebuilds Cholesky, seeds cache - Massive: appends to ticker list, appears on next poll - → Return success (ticker + current price if available) -``` - -### Flow: Removing a Ticker - -``` -User (or LLM) → DELETE /api/watchlist/PYPL - → Delete from watchlist table (SQLite) - → await source.remove_ticker("PYPL") - Simulator: removes from GBMSimulator, rebuilds Cholesky, removes from cache - Massive: removes from ticker list, removes from cache - → Return success -``` - -### Edge case: Ticker has an open position - -If the user removes a ticker from the watchlist but still holds shares, the ticker should remain in the data source so portfolio valuation stays accurate. The watchlist route should check for this: - -```python -@router.delete("/watchlist/{ticker}") -async def remove_from_watchlist( - ticker: str, - source: MarketDataSource = Depends(get_market_source), -): - # Remove from watchlist table - await db.delete_watchlist_entry(ticker) - - # Only stop tracking if no open position - position = await db.get_position(ticker) - if position is None or position.quantity == 0: - await source.remove_ticker(ticker) - - return {"status": "ok"} -``` - ---- - -## 12. Testing Strategy - -### 12.1 Unit Tests for GBMSimulator - -**File: `backend/tests/market/test_simulator.py`** - -```python -import math -import pytest -from app.market.simulator import GBMSimulator -from app.market.seed_prices import SEED_PRICES - - -class TestGBMSimulator: - """Unit tests for the GBM price simulator.""" - - def test_step_returns_all_tickers(self): - sim = GBMSimulator(tickers=["AAPL", "GOOGL"]) - result = sim.step() - assert set(result.keys()) == {"AAPL", "GOOGL"} - - def test_prices_are_positive(self): - """GBM prices can never go negative (exp() is always positive).""" - sim = GBMSimulator(tickers=["AAPL"]) - for _ in range(10_000): - prices = sim.step() - assert prices["AAPL"] > 0 - - def test_initial_prices_match_seeds(self): - sim = GBMSimulator(tickers=["AAPL"]) - # Before any step, price should be the seed price - assert sim.get_price("AAPL") == SEED_PRICES["AAPL"] - - def test_add_ticker(self): - sim = GBMSimulator(tickers=["AAPL"]) - sim.add_ticker("TSLA") - result = sim.step() - assert "TSLA" in result - - def test_remove_ticker(self): - sim = GBMSimulator(tickers=["AAPL", "GOOGL"]) - sim.remove_ticker("GOOGL") - result = sim.step() - assert "GOOGL" not in result - assert "AAPL" in result - - def test_add_duplicate_is_noop(self): - sim = GBMSimulator(tickers=["AAPL"]) - sim.add_ticker("AAPL") - assert len(sim._tickers) == 1 - - def test_remove_nonexistent_is_noop(self): - sim = GBMSimulator(tickers=["AAPL"]) - sim.remove_ticker("NOPE") # Should not raise - - def test_unknown_ticker_gets_random_seed_price(self): - sim = GBMSimulator(tickers=["ZZZZ"]) - price = sim.get_price("ZZZZ") - assert 50.0 <= price <= 300.0 - - def test_empty_step(self): - sim = GBMSimulator(tickers=[]) - result = sim.step() - assert result == {} - - def test_prices_change_over_time(self): - """After many steps, prices should have drifted from their seeds.""" - sim = GBMSimulator(tickers=["AAPL"]) - for _ in range(1000): - sim.step() - # Price should have changed (extremely unlikely to be exactly the seed) - assert sim.get_price("AAPL") != SEED_PRICES["AAPL"] - - def test_cholesky_rebuilds_on_add(self): - sim = GBMSimulator(tickers=["AAPL"]) - assert sim._cholesky is None # Only 1 ticker, no correlation matrix - sim.add_ticker("GOOGL") - assert sim._cholesky is not None # Now 2 tickers, matrix exists -``` - -### 12.2 Unit Tests for PriceCache - -**File: `backend/tests/market/test_cache.py`** - -```python -import pytest -from app.market.cache import PriceCache - - -class TestPriceCache: - - def test_update_and_get(self): - cache = PriceCache() - update = cache.update("AAPL", 190.50) - assert update.ticker == "AAPL" - assert update.price == 190.50 - assert cache.get("AAPL") == update - - def test_first_update_is_flat(self): - cache = PriceCache() - update = cache.update("AAPL", 190.50) - assert update.direction == "flat" - assert update.previous_price == 190.50 - - def test_direction_up(self): - cache = PriceCache() - cache.update("AAPL", 190.00) - update = cache.update("AAPL", 191.00) - assert update.direction == "up" - assert update.change == 1.00 - - def test_direction_down(self): - cache = PriceCache() - cache.update("AAPL", 190.00) - update = cache.update("AAPL", 189.00) - assert update.direction == "down" - assert update.change == -1.00 - - def test_remove(self): - cache = PriceCache() - cache.update("AAPL", 190.00) - cache.remove("AAPL") - assert cache.get("AAPL") is None - - def test_get_all(self): - cache = PriceCache() - cache.update("AAPL", 190.00) - cache.update("GOOGL", 175.00) - all_prices = cache.get_all() - assert set(all_prices.keys()) == {"AAPL", "GOOGL"} - - def test_version_increments(self): - cache = PriceCache() - v0 = cache.version - cache.update("AAPL", 190.00) - assert cache.version == v0 + 1 - cache.update("AAPL", 191.00) - assert cache.version == v0 + 2 - - def test_get_price_convenience(self): - cache = PriceCache() - cache.update("AAPL", 190.50) - assert cache.get_price("AAPL") == 190.50 - assert cache.get_price("NOPE") is None -``` - -### 12.3 Integration Test: SimulatorDataSource - -**File: `backend/tests/market/test_simulator_source.py`** - -```python -import asyncio -import pytest -from app.market.cache import PriceCache -from app.market.simulator import SimulatorDataSource - - -@pytest.mark.asyncio -class TestSimulatorDataSource: - - async def test_start_populates_cache(self): - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start(["AAPL", "GOOGL"]) - - # Cache should have seed prices immediately (before first loop tick) - assert cache.get("AAPL") is not None - assert cache.get("GOOGL") is not None - - await source.stop() - - async def test_prices_update_over_time(self): - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.05) - await source.start(["AAPL"]) - - initial = cache.get("AAPL").price - await asyncio.sleep(0.3) # Several update cycles - current = cache.get("AAPL").price - - # Extremely unlikely to be identical after many steps - # (but not impossible, so this is a probabilistic test) - assert current != initial or True # Soft assertion - - await source.stop() - - async def test_stop_is_clean(self): - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start(["AAPL"]) - await source.stop() - # Double stop should not raise - await source.stop() - - async def test_add_and_remove_ticker(self): - cache = PriceCache() - source = SimulatorDataSource(price_cache=cache, update_interval=0.1) - await source.start(["AAPL"]) - - await source.add_ticker("TSLA") - assert "TSLA" in source.get_tickers() - assert cache.get("TSLA") is not None - - await source.remove_ticker("TSLA") - assert "TSLA" not in source.get_tickers() - assert cache.get("TSLA") is None - - await source.stop() -``` - -### 12.4 Unit Test: MassiveDataSource (Mocked) - -**File: `backend/tests/market/test_massive.py`** - -```python -import asyncio -from unittest.mock import MagicMock, patch -import pytest -from app.market.cache import PriceCache -from app.market.massive_client import MassiveDataSource - - -def _make_snapshot(ticker: str, price: float, timestamp_ms: int) -> MagicMock: - """Create a mock Massive snapshot object.""" - snap = MagicMock() - snap.ticker = ticker - snap.last_trade.price = price - snap.last_trade.timestamp = timestamp_ms - return snap - - -@pytest.mark.asyncio -class TestMassiveDataSource: - - async def test_poll_updates_cache(self): - cache = PriceCache() - source = MassiveDataSource( - api_key="test-key", - price_cache=cache, - poll_interval=60.0, # Long interval so the loop doesn't auto-poll - ) - - mock_snapshots = [ - _make_snapshot("AAPL", 190.50, 1707580800000), - _make_snapshot("GOOGL", 175.25, 1707580800000), - ] - - with patch.object(source, "_fetch_snapshots", return_value=mock_snapshots): - await source._poll_once() - - assert cache.get_price("AAPL") == 190.50 - assert cache.get_price("GOOGL") == 175.25 - - async def test_malformed_snapshot_skipped(self): - cache = PriceCache() - source = MassiveDataSource( - api_key="test-key", - price_cache=cache, - poll_interval=60.0, - ) - source._tickers = ["AAPL", "BAD"] - - good_snap = _make_snapshot("AAPL", 190.50, 1707580800000) - bad_snap = MagicMock() - bad_snap.ticker = "BAD" - bad_snap.last_trade = None # Will cause AttributeError - - with patch.object(source, "_fetch_snapshots", return_value=[good_snap, bad_snap]): - await source._poll_once() - - # Good ticker processed, bad one skipped - assert cache.get_price("AAPL") == 190.50 - assert cache.get_price("BAD") is None - - async def test_api_error_does_not_crash(self): - cache = PriceCache() - source = MassiveDataSource( - api_key="test-key", - price_cache=cache, - poll_interval=60.0, - ) - source._tickers = ["AAPL"] - - with patch.object(source, "_fetch_snapshots", side_effect=Exception("network error")): - await source._poll_once() # Should not raise - - assert cache.get_price("AAPL") is None # No update happened -``` - ---- - -## 13. Error Handling & Edge Cases - -### 13.1 Startup: Empty Watchlist - -If the database has no watchlist entries (user deleted everything), `start()` receives an empty list. Both data sources handle this gracefully — the simulator produces no prices, the Massive poller skips its API call. The SSE endpoint sends empty events. When the user adds a ticker, the source starts tracking it immediately. - -### 13.2 Price Cache Miss During Trade - -If a user tries to trade a ticker that has no cached price (e.g., just added to watchlist, Massive hasn't polled yet): - -```python -price = price_cache.get_price(ticker) -if price is None: - raise HTTPException( - status_code=400, - detail=f"Price not yet available for {ticker}. Please wait a moment and try again.", - ) -``` - -The simulator avoids this by seeding the cache in `add_ticker()`. The Massive client may have a brief gap — the HTTP 400 with a clear message is the correct response. - -### 13.3 Massive API Key Invalid - -If the API key is set but invalid, the first poll will fail with a 401. The poller logs the error and keeps retrying. The SSE endpoint streams empty data. The user sees no prices and a connection status indicator showing "connected" (SSE is working, just no data). The fix is to correct the API key and restart. - -### 13.4 Thread Safety Under Load - -The `PriceCache` uses `threading.Lock` which is a mutex — only one thread can hold it at a time. Under normal load (10 tickers, 2 updates/sec), lock contention is negligible. The critical section is tiny (dict lookup + assignment). - -If this ever became a bottleneck (hundreds of tickers, many concurrent SSE readers), the fix would be a `ReadWriteLock` — but that level of optimization is unnecessary for this project. - -### 13.5 Simulator Precision - -GBM with tiny `dt` produces very small per-tick moves. Floating-point precision is not a concern because: -- Prices are `round()`ed to 2 decimal places in `GBMSimulator.step()` -- The exponential formulation (`exp(drift + diffusion)`) is numerically stable -- Prices are always positive (exponential function) - ---- - -## 14. Configuration Summary - -All tunable parameters and their defaults: - -| Parameter | Location | Default | Description | -|-----------|----------|---------|-------------| -| `MASSIVE_API_KEY` | Environment variable | `""` (empty) | If set, use Massive API; otherwise use simulator | -| `update_interval` | `SimulatorDataSource.__init__` | `0.5` (seconds) | Time between simulator ticks | -| `poll_interval` | `MassiveDataSource.__init__` | `15.0` (seconds) | Time between Massive API polls | -| `event_probability` | `GBMSimulator.__init__` | `0.001` | Chance of a random shock event per ticker per tick | -| `dt` | `GBMSimulator.__init__` | `~8.5e-8` | GBM time step (fraction of a trading year) | -| SSE push interval | `_generate_events()` | `0.5` (seconds) | Time between SSE pushes to the client | -| SSE retry directive | `_generate_events()` | `1000` (ms) | Browser EventSource reconnection delay | - -### Package `__init__.py` - -**File: `backend/app/market/__init__.py`** - -```python -"""Market data subsystem for FinAlly. - -Public API: - PriceUpdate - Immutable price snapshot dataclass - PriceCache - Thread-safe in-memory price store - MarketDataSource - Abstract interface for data providers - create_market_data_source - Factory that selects simulator or Massive - create_stream_router - FastAPI router factory for SSE endpoint -""" - -from .cache import PriceCache -from .factory import create_market_data_source -from .interface import MarketDataSource -from .models import PriceUpdate -from .stream import create_stream_router - -__all__ = [ - "PriceUpdate", - "PriceCache", - "MarketDataSource", - "create_market_data_source", - "create_stream_router", -] -``` diff --git a/planning/archive/MARKET_DATA_REVIEW.md b/planning/archive/MARKET_DATA_REVIEW.md deleted file mode 100644 index 61b4d6bf4..000000000 --- a/planning/archive/MARKET_DATA_REVIEW.md +++ /dev/null @@ -1,173 +0,0 @@ -# Market Data Backend — Code Review - -**Date:** 2026-02-10 -**Scope:** `backend/app/market/` (8 source files) and `backend/tests/market/` (6 test files) - ---- - -## 1. Test Results Summary - -**73 tests collected, 68 passed, 5 failed.** - -All failures are in `test_massive.py` and stem from the same root cause: the `massive` package is not installed in the test environment, so `patch("app.market.massive_client.RESTClient")` fails with `AttributeError` because the module-level name `RESTClient` was never imported (it is lazy-imported inside methods). This is an environment issue, not a logic bug — the tests are correctly structured but require the `massive` package to be available (or `create=True` on the patch) so that the mock target exists. - -Failing tests: -- `test_poll_updates_cache` — `asyncio.to_thread` fails because `_fetch_snapshots` is not properly mocked when `massive` is absent -- `test_malformed_snapshot_skipped` — same cause -- `test_timestamp_conversion` — same cause -- `test_stop_cancels_task` — `patch("app.market.massive_client.RESTClient")` fails because the name doesn't exist at module level -- `test_start_immediate_poll` — same as above - -The underlying `_poll_once()` logic itself is correct. The 3 tests that mock `source._fetch_snapshots` directly fail because `asyncio.to_thread(self._fetch_snapshots)` calls the real method which tries to import `massive`. The 2 tests that use `patch("app.market.massive_client.RESTClient")` fail because the name doesn't exist in the module's namespace (lazy import). Both issues resolve when the `massive` package is installed. - -**Lint (ruff):** Source code passes clean. Tests have 5 unused-import warnings (`pytest`, `math`, `asyncio` imported but not used in some test files). - -**Coverage:** 84% overall. -| Module | Coverage | Notes | -|---|---|---| -| models.py | 100% | | -| cache.py | 100% | | -| interface.py | 100% | | -| seed_prices.py | 100% | | -| factory.py | 100% | | -| simulator.py | 98% | Uncovered: `_add_ticker_internal` duplicate guard (L145), exception log in `_run_loop` (L264-265) | -| massive_client.py | 56% | Expected — real API methods can't run without the massive package | -| stream.py | 31% | Expected — SSE generator requires a running ASGI server to test | - ---- - -## 2. Architecture Assessment - -The market data subsystem is well-designed. It follows a clean strategy pattern: - -``` -MarketDataSource (ABC) -├── SimulatorDataSource (GBM simulator) -└── MassiveDataSource (Polygon.io REST poller) - │ - ▼ - PriceCache (shared, thread-safe) - │ - ▼ - SSE stream → Frontend -``` - -**Strengths:** -- Clear separation of concerns across 8 focused modules -- Factory pattern with lazy imports — the `massive` package is only needed when `MASSIVE_API_KEY` is set -- PriceCache as the single point of truth decouples producers from consumers -- Immutable `PriceUpdate` dataclass with `frozen=True, slots=True` is correct and efficient -- The GBM math is proper: log-normal price paths via `exp((mu - 0.5*sigma^2)*dt + sigma*sqrt(dt)*Z)` -- Correlated moves via Cholesky decomposition are a nice touch for realism -- All background tasks are properly cancellable and idempotent on stop() - ---- - -## 3. Issues Found - -### 3.1 Build Configuration Bug (Severity: High) - -`pyproject.toml` is missing the hatchling package discovery configuration. Running `uv sync` fails: - -``` -ValueError: Unable to determine which files to ship inside the wheel -``` - -**Fix:** Add to `pyproject.toml`: -```toml -[tool.hatch.build.targets.wheel] -packages = ["app"] -``` - -This will block Docker builds and any fresh `uv sync` until fixed. - -### 3.2 Massive Test Fragility (Severity: Medium) - -Five tests in `test_massive.py` fail when the `massive` package is not installed. The root cause is twofold: - -1. **`_poll_once` uses `asyncio.to_thread(self._fetch_snapshots)`** — even when `_fetch_snapshots` is patched on the instance, `to_thread` runs it in a thread executor. Three tests mock `_fetch_snapshots` as a `MagicMock` (synchronous), but `asyncio.to_thread` wraps it in `loop.run_in_executor`, which works... except that when `_fetch_snapshots` is NOT patched, the real method tries `from massive.rest.models import SnapshotMarketType` and fails. - -2. **`patch("app.market.massive_client.RESTClient")`** targets a name that doesn't exist at module level because `massive_client.py` uses a lazy import inside `start()`. The patch needs `create=True` or the import needs to be at module level behind a `TYPE_CHECKING` guard. - -These tests pass when `massive>=1.0.0` is installed (as `pyproject.toml` declares it as a core dependency), so this is technically a test-environment issue, not a code bug. However, since the whole point of lazy imports is to make `massive` optional for simulator-only use, the tests should also work without it. - -### 3.3 `_generate_events` Return Type Annotation (Severity: Low) - -`stream.py:54` declares the return type as `-> None` but the function is an async generator (it uses `yield`). The correct annotation would be `-> AsyncGenerator[str, None]` or simply removing the annotation. This doesn't cause runtime issues but is misleading for type checkers and developers. - -### 3.4 `version` Property Not Under Lock (Severity: Low) - -`PriceCache.version` reads `self._version` without acquiring `self._lock`: - -```python -@property -def version(self) -> int: - return self._version -``` - -On CPython with the GIL, reading a single `int` is atomic, so this won't cause corruption. However, it's inconsistent with the rest of the class, and if the project ever runs on a no-GIL Python build (PEP 703, Python 3.13t+), this could become a race. A minor concern given the current context. - -### 3.5 `SimulatorDataSource.get_tickers` Accesses Private State (Severity: Low) - -`simulator.py:254`: -```python -def get_tickers(self) -> list[str]: - return list(self._sim._tickers) if self._sim else [] -``` - -This reaches into `GBMSimulator._tickers` (private attribute). `GBMSimulator` should expose a `get_tickers()` method or a `tickers` property to keep the boundary clean. - -### 3.6 Module-Level Router Instance (Severity: Low) - -`stream.py:16` creates a module-level `router` object, and `create_stream_router()` registers a route on it via closure. If `create_stream_router` were called twice (e.g., in tests), the `/prices` route would be registered twice on the same router. In practice this won't happen because the function is called once during app startup, but it's a latent footgun for testing. - -### 3.7 Unused Imports in Tests (Severity: Trivial) - -Five lint warnings from `ruff`: -- `test_cache.py`: unused `pytest` -- `test_factory.py`: unused `pytest` -- `test_massive.py`: unused `asyncio` -- `test_simulator.py`: unused `math`, unused `pytest` - ---- - -## 4. Design Observations - -### 4.1 Things Done Well - -- **GBM parameter tuning is thoughtful.** TSLA at sigma=0.50 vs V at 0.17 reflects real-world volatility differences. The shock event system (~0.1% per tick, producing visible moves every ~50s) adds visual drama without destabilizing prices. -- **Cholesky decomposition for correlated moves** is the mathematically correct approach. The sector-based correlation structure (tech 0.6, finance 0.5, cross 0.3) is reasonable. -- **Defensive error handling in both data sources.** Both `_run_loop` (simulator) and `_poll_once`/`_poll_loop` (massive) catch exceptions and continue, which is essential for a long-running background service. -- **SSE implementation is clean.** The version-based change detection avoids sending redundant payloads. The `retry: 1000\n\n` directive ensures browser auto-reconnect. Nginx buffering is proactively disabled. -- **Seed prices in the cache at start** means the frontend gets data on the first SSE poll, with no visible delay. -- **Thread-safe cache with Lock** is the right choice since the Massive client runs API calls via `asyncio.to_thread`. - -### 4.2 Missing Tests - -- **SSE streaming (`stream.py`)** at 31% coverage has no dedicated tests. Testing SSE requires an ASGI test client (e.g., `httpx.AsyncClient` with `app`). Given that this is the primary consumer of PriceCache, even a basic integration test would add confidence. -- **No concurrent/thread-safety test for PriceCache.** The lock usage looks correct from inspection, but a test with multiple threads writing simultaneously would verify it empirically. -- **No test for `GBMSimulator` with all 10 default tickers.** Tests use 1-2 tickers. A test confirming the Cholesky decomposition succeeds for the full 10-ticker default set would catch correlation matrix issues. - -### 4.3 Potential Future Considerations - -- The `PriceCache` doesn't cap history; it only stores the latest price per ticker, so memory is bounded at O(tickers). Good. -- The `DEFAULT_CORR` constant (0.3, `seed_prices.py:48`) is defined but never referenced in `_pairwise_correlation`. The static method returns `CROSS_GROUP_CORR` (also 0.3) as the fallback. This is semantically confusing — `DEFAULT_CORR` seems intended for tickers not in any group, but the code returns `CROSS_GROUP_CORR` for all non-matched pairs. Both happen to be 0.3, so behavior is correct, but the naming is misleading. - ---- - -## 5. Verdict - -The market data backend is solid and well-structured. The GBM simulator, price cache, abstract interface, factory pattern, and SSE streaming all work correctly and follow good practices. The architecture will integrate cleanly with the rest of the application. - -**Must fix before proceeding:** -1. Add `[tool.hatch.build.targets.wheel] packages = ["app"]` to `pyproject.toml` — without this, `uv sync` and Docker builds fail. - -**Should fix:** -2. Make the Massive tests resilient to the `massive` package being absent (use `create=True` on patches, or restructure mocks). -3. Fix the `_generate_events` return type annotation. -4. Remove unused imports in test files. - -**Nice to have:** -5. Add a `get_tickers()` public method to `GBMSimulator`. -6. Add at least one SSE integration test. -7. Clarify `DEFAULT_CORR` vs `CROSS_GROUP_CORR` naming. diff --git a/planning/archive/MARKET_INTERFACE.md b/planning/archive/MARKET_INTERFACE.md deleted file mode 100644 index 156cad287..000000000 --- a/planning/archive/MARKET_INTERFACE.md +++ /dev/null @@ -1,273 +0,0 @@ -# Market Data Interface Design - -Unified Python interface for market data in FinAlly. Two implementations (simulator and Massive API) behind one abstract interface. All downstream code — SSE streaming, price cache, portfolio valuation — is source-agnostic. - -## Core Data Model - -```python -from dataclasses import dataclass - -@dataclass -class PriceUpdate: - """A single price update for one ticker.""" - ticker: str - price: float - previous_price: float - timestamp: float # Unix seconds - change: float # price - previous_price - direction: str # "up", "down", or "flat" -``` - -This is the only data structure that leaves the market data layer. Everything downstream works with `PriceUpdate` objects. - -## Abstract Interface - -```python -from abc import ABC, abstractmethod - -class MarketDataSource(ABC): - """Abstract interface for market data providers.""" - - @abstractmethod - async def start(self, tickers: list[str]) -> None: - """Begin producing price updates for the given tickers.""" - - @abstractmethod - async def stop(self) -> None: - """Stop producing price updates and clean up.""" - - @abstractmethod - async def add_ticker(self, ticker: str) -> None: - """Add a ticker to the active set.""" - - @abstractmethod - async def remove_ticker(self, ticker: str) -> None: - """Remove a ticker from the active set.""" - - @abstractmethod - def get_tickers(self) -> list[str]: - """Return the current list of active tickers.""" -``` - -Both implementations write to a shared `PriceCache` (see below). The interface does **not** return prices directly — it pushes updates into the cache on its own schedule. - -## Price Cache - -Shared in-memory store that both data sources write to and the SSE streamer reads from. - -```python -import time -from threading import Lock - -class PriceCache: - """Thread-safe cache of latest prices per ticker.""" - - def __init__(self): - self._prices: dict[str, PriceUpdate] = {} - self._lock = Lock() - - def update(self, ticker: str, price: float, timestamp: float | None = None) -> PriceUpdate: - """Update price for a ticker. Returns the PriceUpdate.""" - with self._lock: - ts = timestamp or time.time() - previous = self._prices.get(ticker) - previous_price = previous.price if previous else price - - if price > previous_price: - direction = "up" - elif price < previous_price: - direction = "down" - else: - direction = "flat" - - update = PriceUpdate( - ticker=ticker, - price=price, - previous_price=previous_price, - timestamp=ts, - change=price - previous_price, - direction=direction, - ) - self._prices[ticker] = update - return update - - def get(self, ticker: str) -> PriceUpdate | None: - """Get latest price for a ticker.""" - with self._lock: - return self._prices.get(ticker) - - def get_all(self) -> dict[str, PriceUpdate]: - """Get all current prices.""" - with self._lock: - return dict(self._prices) - - def remove(self, ticker: str) -> None: - """Remove a ticker from the cache.""" - with self._lock: - self._prices.pop(ticker, None) -``` - -## Factory Function - -Select the data source at startup based on environment: - -```python -import os - -def create_market_data_source(price_cache: PriceCache) -> MarketDataSource: - """Create the appropriate market data source based on environment.""" - api_key = os.environ.get("MASSIVE_API_KEY", "").strip() - - if api_key: - from .massive_client import MassiveDataSource - return MassiveDataSource(api_key=api_key, price_cache=price_cache) - else: - from .simulator import SimulatorDataSource - return SimulatorDataSource(price_cache=price_cache) -``` - -## Massive Implementation Sketch - -```python -import asyncio -from massive import RESTClient -from massive.rest.models import SnapshotMarketType - -class MassiveDataSource(MarketDataSource): - def __init__(self, api_key: str, price_cache: PriceCache, poll_interval: float = 15.0): - self._client = RESTClient(api_key=api_key) - self._cache = price_cache - self._interval = poll_interval - self._tickers: list[str] = [] - self._task: asyncio.Task | None = None - - async def start(self, tickers: list[str]) -> None: - self._tickers = list(tickers) - self._task = asyncio.create_task(self._poll_loop()) - - async def stop(self) -> None: - if self._task: - self._task.cancel() - - async def add_ticker(self, ticker: str) -> None: - if ticker not in self._tickers: - self._tickers.append(ticker) - - async def remove_ticker(self, ticker: str) -> None: - self._tickers = [t for t in self._tickers if t != ticker] - self._cache.remove(ticker) - - def get_tickers(self) -> list[str]: - return list(self._tickers) - - async def _poll_loop(self) -> None: - while True: - await self._poll_once() - await asyncio.sleep(self._interval) - - async def _poll_once(self) -> None: - if not self._tickers: - return - # Run synchronous Massive client in thread pool - snapshots = await asyncio.to_thread( - self._client.get_snapshot_all, - market_type=SnapshotMarketType.STOCKS, - tickers=self._tickers, - ) - for snap in snapshots: - self._cache.update( - ticker=snap.ticker, - price=snap.last_trade.price, - timestamp=snap.last_trade.timestamp / 1000, # ms -> seconds - ) -``` - -## Simulator Implementation Sketch - -```python -import asyncio - -class SimulatorDataSource(MarketDataSource): - def __init__(self, price_cache: PriceCache, update_interval: float = 0.5): - self._cache = price_cache - self._interval = update_interval - self._tickers: list[str] = [] - self._task: asyncio.Task | None = None - self._sim: GBMSimulator | None = None # See MARKET_SIMULATOR.md - - async def start(self, tickers: list[str]) -> None: - self._tickers = list(tickers) - self._sim = GBMSimulator(tickers=self._tickers) - self._task = asyncio.create_task(self._run_loop()) - - async def stop(self) -> None: - if self._task: - self._task.cancel() - - async def add_ticker(self, ticker: str) -> None: - if ticker not in self._tickers: - self._tickers.append(ticker) - self._sim.add_ticker(ticker) - - async def remove_ticker(self, ticker: str) -> None: - self._tickers = [t for t in self._tickers if t != ticker] - self._sim.remove_ticker(ticker) - self._cache.remove(ticker) - - def get_tickers(self) -> list[str]: - return list(self._tickers) - - async def _run_loop(self) -> None: - while True: - prices = self._sim.step() # Returns dict[str, float] - for ticker, price in prices.items(): - self._cache.update(ticker=ticker, price=price) - await asyncio.sleep(self._interval) -``` - -## Integration with SSE - -The SSE endpoint reads from the `PriceCache` and pushes to connected clients: - -```python -async def price_stream(price_cache: PriceCache): - """SSE generator that yields price updates.""" - while True: - prices = price_cache.get_all() - data = { - ticker: { - "ticker": p.ticker, - "price": p.price, - "previous_price": p.previous_price, - "change": p.change, - "direction": p.direction, - "timestamp": p.timestamp, - } - for ticker, p in prices.items() - } - yield f"data: {json.dumps(data)}\n\n" - await asyncio.sleep(0.5) -``` - -## File Structure - -``` -backend/ - app/ - market/ - __init__.py - models.py # PriceUpdate dataclass - interface.py # MarketDataSource ABC, PriceCache - factory.py # create_market_data_source() - massive_client.py # MassiveDataSource - simulator.py # SimulatorDataSource + GBMSimulator - seed_prices.py # Default ticker seed prices -``` - -## Lifecycle - -1. **App startup**: Create `PriceCache`, call `create_market_data_source(price_cache)`, then `await source.start(initial_tickers)` -2. **Watchlist changes**: Call `source.add_ticker()` or `source.remove_ticker()` -3. **SSE streaming**: Reads from `PriceCache.get_all()` every 500ms -4. **Trade execution**: Reads current price from `PriceCache.get(ticker)` -5. **App shutdown**: Call `await source.stop()` diff --git a/planning/archive/MARKET_SIMULATOR.md b/planning/archive/MARKET_SIMULATOR.md deleted file mode 100644 index e157b6efb..000000000 --- a/planning/archive/MARKET_SIMULATOR.md +++ /dev/null @@ -1,245 +0,0 @@ -# Market Simulator Design - -Approach and code structure for simulating realistic stock prices when no Massive API key is configured. - -## Overview - -The simulator uses **Geometric Brownian Motion (GBM)** to generate realistic stock price paths. GBM is the standard model underlying Black-Scholes option pricing — prices evolve continuously with random noise, can't go negative, and exhibit the lognormal distribution seen in real markets. - -Updates run at ~500ms intervals, producing a continuous stream of price changes that feel alive. - -## GBM Math - -At each time step, a stock price evolves as: - -``` -S(t+dt) = S(t) * exp((mu - sigma^2/2) * dt + sigma * sqrt(dt) * Z) -``` - -Where: -- `S(t)` = current price -- `mu` = annualized drift (expected return), e.g. 0.05 (5%) -- `sigma` = annualized volatility, e.g. 0.20 (20%) -- `dt` = time step as fraction of a trading year -- `Z` = standard normal random variable (drawn from N(0,1)) - -For our 500ms updates with ~252 trading days and ~6.5 hours per day: -``` -dt = 0.5 / (252 * 6.5 * 3600) = ~8.5e-8 -``` - -This tiny `dt` produces small, realistic per-tick moves. - -## Correlated Moves - -Real stocks don't move independently — tech stocks tend to move together, etc. We use a **Cholesky decomposition** of a correlation matrix to generate correlated random draws. - -Given a correlation matrix `C`, compute `L = cholesky(C)`. Then for independent standard normals `Z_independent`: -``` -Z_correlated = L @ Z_independent -``` - -Default correlation groups: -- **Tech**: AAPL, GOOGL, MSFT, AMZN, META, NVDA, NFLX — corr ~0.6 within group -- **Finance**: JPM, V — corr ~0.5 within group -- **Cross-group**: ~0.3 baseline correlation -- **TSLA**: lower correlation with everything (~0.3) — it does its own thing - -## Random Events - -Every step, each ticker has a small probability (~0.001) of a random event — a sudden 2-5% move. This adds drama and makes the dashboard visually interesting. - -```python -if random.random() < event_probability: - shock = random.uniform(0.02, 0.05) * random.choice([-1, 1]) - price *= (1 + shock) -``` - -## Seed Prices - -Realistic starting prices for the default watchlist: - -```python -SEED_PRICES: dict[str, float] = { - "AAPL": 190.0, - "GOOGL": 175.0, - "MSFT": 420.0, - "AMZN": 185.0, - "TSLA": 250.0, - "NVDA": 800.0, - "META": 500.0, - "JPM": 195.0, - "V": 280.0, - "NFLX": 600.0, -} -``` - -Tickers added dynamically (not in the seed list) start at a random price between $50-$300. - -## Per-Ticker Parameters - -Each ticker has its own volatility to reflect real-world behavior: - -```python -TICKER_PARAMS: dict[str, dict] = { - "AAPL": {"sigma": 0.22, "mu": 0.05}, - "GOOGL": {"sigma": 0.25, "mu": 0.05}, - "MSFT": {"sigma": 0.20, "mu": 0.05}, - "AMZN": {"sigma": 0.28, "mu": 0.05}, - "TSLA": {"sigma": 0.50, "mu": 0.03}, # High vol - "NVDA": {"sigma": 0.40, "mu": 0.08}, # High vol, strong drift - "META": {"sigma": 0.30, "mu": 0.05}, - "JPM": {"sigma": 0.18, "mu": 0.04}, # Low vol (bank) - "V": {"sigma": 0.17, "mu": 0.04}, # Low vol (payments) - "NFLX": {"sigma": 0.35, "mu": 0.05}, -} - -# Default for unknown tickers -DEFAULT_PARAMS = {"sigma": 0.25, "mu": 0.05} -``` - -## Implementation - -```python -import math -import random -import time -import numpy as np - -class GBMSimulator: - """Generates correlated GBM price paths for multiple tickers.""" - - def __init__( - self, - tickers: list[str], - dt: float = 8.5e-8, - event_probability: float = 0.001, - ): - self._dt = dt - self._event_prob = event_probability - self._prices: dict[str, float] = {} - self._params: dict[str, dict] = {} - self._tickers: list[str] = [] - self._cholesky: np.ndarray | None = None - - for ticker in tickers: - self.add_ticker(ticker) - - def add_ticker(self, ticker: str) -> None: - if ticker in self._prices: - return - self._tickers.append(ticker) - self._prices[ticker] = SEED_PRICES.get(ticker, random.uniform(50, 300)) - self._params[ticker] = TICKER_PARAMS.get(ticker, DEFAULT_PARAMS) - self._rebuild_cholesky() - - def remove_ticker(self, ticker: str) -> None: - if ticker not in self._prices: - return - self._tickers.remove(ticker) - del self._prices[ticker] - del self._params[ticker] - self._rebuild_cholesky() - - def step(self) -> dict[str, float]: - """Advance one time step. Returns {ticker: new_price}.""" - n = len(self._tickers) - if n == 0: - return {} - - # Generate correlated random normals - z_independent = np.random.standard_normal(n) - if self._cholesky is not None: - z = self._cholesky @ z_independent - else: - z = z_independent - - result = {} - for i, ticker in enumerate(self._tickers): - params = self._params[ticker] - mu = params["mu"] - sigma = params["sigma"] - - # GBM step - drift = (mu - 0.5 * sigma**2) * self._dt - diffusion = sigma * math.sqrt(self._dt) * z[i] - self._prices[ticker] *= math.exp(drift + diffusion) - - # Random event - if random.random() < self._event_prob: - shock = random.uniform(0.02, 0.05) * random.choice([-1, 1]) - self._prices[ticker] *= (1 + shock) - - result[ticker] = round(self._prices[ticker], 2) - - return result - - def get_price(self, ticker: str) -> float | None: - return self._prices.get(ticker) - - def _rebuild_cholesky(self) -> None: - """Rebuild the Cholesky decomposition of the correlation matrix.""" - n = len(self._tickers) - if n <= 1: - self._cholesky = None - return - - corr = np.eye(n) - for i in range(n): - for j in range(i + 1, n): - rho = self._get_correlation(self._tickers[i], self._tickers[j]) - corr[i, j] = rho - corr[j, i] = rho - - self._cholesky = np.linalg.cholesky(corr) - - def _get_correlation(self, t1: str, t2: str) -> float: - """Return pairwise correlation between two tickers.""" - tech = {"AAPL", "GOOGL", "MSFT", "AMZN", "META", "NVDA", "NFLX"} - finance = {"JPM", "V"} - - t1_tech = t1 in tech - t2_tech = t2 in tech - t1_fin = t1 in finance - t2_fin = t2 in finance - - # Same sector: higher correlation - if t1_tech and t2_tech: - return 0.6 - if t1_fin and t2_fin: - return 0.5 - - # TSLA is a loner - if t1 == "TSLA" or t2 == "TSLA": - return 0.3 - - # Cross-sector or unknown - if (t1_tech and t2_fin) or (t1_fin and t2_tech): - return 0.3 - - # Default - return 0.3 -``` - -## File Structure - -All simulator code lives in a single module: - -``` -backend/ - app/ - market/ - simulator.py # GBMSimulator class + seed data + SimulatorDataSource - seed_prices.py # SEED_PRICES, TICKER_PARAMS, DEFAULT_PARAMS (constants) -``` - -`seed_prices.py` contains just the constant dictionaries. `simulator.py` contains the `GBMSimulator` class and the `SimulatorDataSource` (the `MarketDataSource` implementation that wraps `GBMSimulator` in an async loop). - -## Behavior Notes - -- Prices never go negative (GBM is multiplicative — `exp()` is always positive) -- The tiny `dt` produces sub-cent moves per tick, which accumulate naturally over time -- With `sigma=0.50` (TSLA), a day of simulated trading produces roughly the right intraday range -- The correlation matrix must be positive semi-definite — Cholesky decomposition guarantees this for valid correlation matrices -- Random events happen ~0.1% of steps = roughly once every 500 seconds per ticker. With 10 tickers, expect an event somewhere roughly every 50 seconds — enough to keep it interesting -- When a new ticker is added mid-session, the Cholesky matrix is rebuilt. This is O(n^2) but n is small (<50 tickers) diff --git a/planning/archive/MASSIVE_API.md b/planning/archive/MASSIVE_API.md deleted file mode 100644 index 3266bc64f..000000000 --- a/planning/archive/MASSIVE_API.md +++ /dev/null @@ -1,251 +0,0 @@ -# Massive API Reference (formerly Polygon.io) - -Reference documentation for the Massive (formerly Polygon.io) REST API as used in FinAlly. - -## Overview - -- **Base URL**: `https://api.massive.com` (legacy `https://api.polygon.io` still supported) -- **Python package**: `massive` (install via `pip install -U massive` / `uv add massive`) -- **Min Python version**: 3.9+ -- **Auth**: API key via `MASSIVE_API_KEY` env var or passed to `RESTClient(api_key=...)` -- **Auth header**: `Authorization: Bearer ` (the client handles this automatically) - -## Rate Limits - -| Tier | Limit | -|------|-------| -| Free | 5 requests/minute | -| Paid (all tiers) | Unlimited (recommended: stay under 100 req/s) | - -For FinAlly, we poll on a timer. Free tier: poll every 15s. Paid: poll every 2-5s. - -## Client Initialization - -```python -from massive import RESTClient - -# Reads MASSIVE_API_KEY from environment automatically -client = RESTClient() - -# Or pass explicitly -client = RESTClient(api_key="your_key_here") -``` - -## Endpoints Used in FinAlly - -### 1. Snapshot — All Tickers (Primary Endpoint) - -Gets current prices for multiple tickers in a **single API call**. This is the main endpoint we use for polling. - -**REST**: `GET /v2/snapshot/locale/us/markets/stocks/tickers?tickers=AAPL,GOOGL,MSFT` - -**Python client**: -```python -from massive import RESTClient -from massive.rest.models import SnapshotMarketType - -client = RESTClient() - -# Get snapshots for specific tickers (one API call) -snapshots = client.get_snapshot_all( - market_type=SnapshotMarketType.STOCKS, - tickers=["AAPL", "GOOGL", "MSFT", "AMZN", "TSLA"], -) - -for snap in snapshots: - print(f"{snap.ticker}: ${snap.last_trade.price}") - print(f" Day change: {snap.day.change_percent}%") - print(f" Day OHLC: O={snap.day.open} H={snap.day.high} L={snap.day.low} C={snap.day.close}") - print(f" Volume: {snap.day.volume}") -``` - -**Response structure** (per ticker): -```json -{ - "ticker": "AAPL", - "day": { - "open": 129.61, - "high": 130.15, - "low": 125.07, - "close": 125.07, - "volume": 111237700, - "volume_weighted_average_price": 127.35, - "previous_close": 129.61, - "change": -4.54, - "change_percent": -3.50 - }, - "last_trade": { - "price": 125.07, - "size": 100, - "exchange": "XNYS", - "timestamp": 1675190399000 - }, - "last_quote": { - "bid_price": 125.06, - "ask_price": 125.08, - "bid_size": 500, - "ask_size": 1000, - "spread": 0.02, - "timestamp": 1675190399500 - }, - "prev_daily_bar": { "...": "previous day OHLCV" }, - "minute_volume": { "...": "volume per minute" } -} -``` - -**Key fields we extract**: -- `last_trade.price` — current price for trading and display -- `day.previous_close` — for calculating day change -- `day.change_percent` — day change percentage -- `last_trade.timestamp` — when the price was recorded - -### 2. Single Ticker Snapshot - -For getting detailed data on one ticker (e.g., when user clicks a ticker for the detail view). - -**Python client**: -```python -snapshot = client.get_snapshot_ticker( - market_type=SnapshotMarketType.STOCKS, - ticker="AAPL", -) - -print(f"Price: ${snapshot.last_trade.price}") -print(f"Bid/Ask: ${snapshot.last_quote.bid_price} / ${snapshot.last_quote.ask_price}") -print(f"Day range: ${snapshot.day.low} - ${snapshot.day.high}") -``` - -### 3. Previous Close - -Gets the previous day's OHLC for a ticker. Useful for seed prices. - -**REST**: `GET /v2/aggs/ticker/{ticker}/prev` - -**Python client**: -```python -prev = client.get_previous_close_agg(ticker="AAPL") - -for agg in prev: - print(f"Previous close: ${agg.close}") - print(f"OHLC: O={agg.open} H={agg.high} L={agg.low} C={agg.close}") - print(f"Volume: {agg.volume}") -``` - -**Response**: -```json -{ - "ticker": "AAPL", - "results": [ - { - "o": 150.0, - "h": 155.0, - "l": 149.0, - "c": 154.5, - "v": 1000000, - "t": 1672531200000 - } - ] -} -``` - -### 4. Aggregates (Bars) - -Historical OHLCV bars over a date range. Not needed for live polling but useful if we add historical charts. - -**REST**: `GET /v2/aggs/ticker/{ticker}/range/{multiplier}/{timespan}/{from}/{to}` - -**Python client**: -```python -aggs = [] -for a in client.list_aggs( - ticker="AAPL", - multiplier=1, - timespan="day", - from_="2024-01-01", - to="2024-01-31", - limit=50000, -): - aggs.append(a) - -for a in aggs: - print(f"Date: {a.timestamp}, O={a.open} H={a.high} L={a.low} C={a.close} V={a.volume}") -``` - -**Response** (each bar): -```json -{ - "o": 130.0, - "h": 132.5, - "l": 129.8, - "c": 131.2, - "v": 50000000, - "t": 1672531200000 -} -``` - -### 5. Last Trade / Last Quote - -Individual endpoints for the most recent trade or NBBO quote. - -```python -# Last trade -trade = client.get_last_trade(ticker="AAPL") -print(f"Last trade: ${trade.price} x {trade.size}") - -# Last NBBO quote -quote = client.get_last_quote(ticker="AAPL") -print(f"Bid: ${quote.bid} x {quote.bid_size}") -print(f"Ask: ${quote.ask} x {quote.ask_size}") -``` - -## How FinAlly Uses the API - -The Massive poller runs as a background task: - -1. Collects all tickers from the watchlist -2. Calls `get_snapshot_all()` with those tickers (one API call) -3. Extracts `last_trade.price` and `day.previous_close` from each snapshot -4. Writes to the shared in-memory price cache -5. Sleeps for the poll interval, then repeats - -```python -import asyncio -from massive import RESTClient -from massive.rest.models import SnapshotMarketType - -async def poll_massive(api_key: str, get_tickers, price_cache, interval: float = 15.0): - """Poll Massive API and update the price cache.""" - client = RESTClient(api_key=api_key) - - while True: - tickers = get_tickers() - if tickers: - snapshots = client.get_snapshot_all( - market_type=SnapshotMarketType.STOCKS, - tickers=tickers, - ) - for snap in snapshots: - price_cache.update( - ticker=snap.ticker, - price=snap.last_trade.price, - previous_close=snap.day.previous_close, - timestamp=snap.last_trade.timestamp, - ) - - await asyncio.sleep(interval) -``` - -## Error Handling - -The client raises exceptions for HTTP errors: -- **401**: Invalid API key -- **403**: Insufficient permissions (plan doesn't include the endpoint) -- **429**: Rate limit exceeded (free tier: 5 req/min) -- **5xx**: Server errors (client has built-in retry with 3 retries by default) - -## Notes - -- The snapshot endpoint returns data for **all requested tickers in one call** — this is critical for staying within rate limits on the free tier -- Timestamps from the API are Unix milliseconds -- During market closed hours, `last_trade.price` reflects the last traded price (may include after-hours) -- The `day` object resets at market open; during pre-market, values may be from the previous session From 6bcff2859b1fcadff4844d6d9f6a07dc005f0d28 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 01:40:12 +0000 Subject: [PATCH 3/3] Build market data backend from planning docs Recreate backend/ as a uv project implementing MARKET_INTERFACE.md, MARKET_SIMULATOR.md and MASSIVE_API.md: PriceUpdate model, thread-safe PriceCache, MarketDataSource ABC, correlated GBM simulator, Massive poller with free-plan end-of-day fallback, env-based factory and the /api/stream/prices SSE router. A minimal app/main.py wires it up with a health check. 33 pytest tests cover the cache, simulator statistics, Massive paths (stubbed client), factory and SSE generator. Point CLAUDE.md and README at the current planning docs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LgHFcnnGnpzHzS6VU8pXJy --- CLAUDE.md | 2 +- README.md | 4 +- backend/README.md | 31 + backend/app/__init__.py | 1 + backend/app/main.py | 29 + backend/app/market/__init__.py | 15 + backend/app/market/cache.py | 57 ++ backend/app/market/factory.py | 16 + backend/app/market/interface.py | 27 + backend/app/market/massive_client.py | 123 ++++ backend/app/market/models.py | 45 ++ backend/app/market/seed_prices.py | 39 ++ backend/app/market/simulator.py | 152 +++++ backend/app/market/stream.py | 37 ++ backend/pyproject.toml | 39 ++ backend/tests/__init__.py | 0 backend/tests/market/__init__.py | 0 backend/tests/market/test_cache.py | 74 +++ backend/tests/market/test_factory.py | 25 + backend/tests/market/test_massive.py | 144 +++++ backend/tests/market/test_simulator.py | 116 ++++ backend/tests/market/test_stream.py | 39 ++ backend/uv.lock | 804 +++++++++++++++++++++++++ 23 files changed, 1816 insertions(+), 3 deletions(-) create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/main.py create mode 100644 backend/app/market/__init__.py create mode 100644 backend/app/market/cache.py create mode 100644 backend/app/market/factory.py create mode 100644 backend/app/market/interface.py create mode 100644 backend/app/market/massive_client.py create mode 100644 backend/app/market/models.py create mode 100644 backend/app/market/seed_prices.py create mode 100644 backend/app/market/simulator.py create mode 100644 backend/app/market/stream.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/market/__init__.py create mode 100644 backend/tests/market/test_cache.py create mode 100644 backend/tests/market/test_factory.py create mode 100644 backend/tests/market/test_massive.py create mode 100644 backend/tests/market/test_simulator.py create mode 100644 backend/tests/market/test_stream.py create mode 100644 backend/uv.lock diff --git a/CLAUDE.md b/CLAUDE.md index 2bdd6fa10..0ef2bacfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,6 @@ All project documentation is in the `planning` directory. -The key document is PLAN.md included in full below; the market data component has been completed and is summarized in the file `planning/MARKET_DATA_SUMMARY.md` with more details in the `planning/archive` folder. Consult these docs only when required. The remainder of the platform is still to be developed. +The key document is PLAN.md included in full below; the market data component has been completed in `backend/app/market/` and is specified in `planning/MARKET_INTERFACE.md`, `planning/MARKET_SIMULATOR.md` and `planning/MASSIVE_API.md`. Consult these docs only when required. The remainder of the platform is still to be developed. @planning/PLAN.md \ No newline at end of file diff --git a/README.md b/README.md index 4f515be88..5067eddaf 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ One Docker container on port 8000: ```bash cd backend uv sync --extra dev -uv run pytest # run tests -uv run market_data_demo.py # terminal demo of the simulator +uv run pytest # run tests +uv run uvicorn app.main:app --reload # serve on :8000; SSE at /api/stream/prices ``` ## Environment Variables diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 000000000..f533a7c95 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,31 @@ +# FinAlly Backend + +FastAPI app managed with `uv`. Currently contains the market data subsystem. + +```bash +uv sync --extra dev +uv run pytest +uv run ruff check . && uv run ruff format --check . +uv run uvicorn app.main:app --reload +``` + +## Layout + +``` +app/ +├── main.py # FastAPI app: lifespan starts the market source, /api/health +└── market/ + ├── models.py # PriceUpdate + ├── cache.py # PriceCache (thread-safe, version counter) + ├── interface.py # MarketDataSource ABC + ├── seed_prices.py # simulator seed prices, GBM params, sectors + ├── simulator.py # GBMSimulator + SimulatorDataSource + ├── massive_client.py# MassiveDataSource (snapshot, free-plan EOD fallback) + ├── factory.py # picks the source from MASSIVE_API_KEY + └── stream.py # GET /api/stream/prices (SSE) +``` + +Design: `planning/MARKET_INTERFACE.md`, `planning/MARKET_SIMULATOR.md`, `planning/MASSIVE_API.md`. + +`app/main.py` tracks the ten default tickers until the database layer provides +watchlist ∪ open positions. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..8297914b9 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""FinAlly backend.""" diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 000000000..3ca3143dd --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,29 @@ +"""FastAPI application: market data wiring and health check.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.market import PriceCache, create_market_data_source, create_stream_router + +# Replaced by watchlist ∪ positions from SQLite once the database layer exists. +DEFAULT_TICKERS = ["AAPL", "GOOGL", "MSFT", "AMZN", "TSLA", "NVDA", "META", "JPM", "V", "NFLX"] + +price_cache = PriceCache() +market_source = create_market_data_source(price_cache) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await market_source.start(DEFAULT_TICKERS) + yield + await market_source.stop() + + +app = FastAPI(title="FinAlly", lifespan=lifespan) +app.include_router(create_stream_router(price_cache)) + + +@app.get("/api/health") +async def health() -> dict: + return {"status": "ok", "tickers": market_source.get_tickers()} diff --git a/backend/app/market/__init__.py b/backend/app/market/__init__.py new file mode 100644 index 000000000..5156615fa --- /dev/null +++ b/backend/app/market/__init__.py @@ -0,0 +1,15 @@ +"""Market data: one interface, a GBM simulator and a Massive REST poller, feeding a shared PriceCache.""" + +from .cache import PriceCache +from .factory import create_market_data_source +from .interface import MarketDataSource +from .models import PriceUpdate +from .stream import create_stream_router + +__all__ = [ + "MarketDataSource", + "PriceCache", + "PriceUpdate", + "create_market_data_source", + "create_stream_router", +] diff --git a/backend/app/market/cache.py b/backend/app/market/cache.py new file mode 100644 index 000000000..a9ffb7160 --- /dev/null +++ b/backend/app/market/cache.py @@ -0,0 +1,57 @@ +"""In-memory store of the latest price per ticker.""" + +import time +from threading import Lock + +from .models import PriceUpdate + + +class PriceCache: + """Thread-safe latest-price store. Written by one data source, read by many.""" + + def __init__(self) -> None: + self._prices: dict[str, PriceUpdate] = {} + self._lock = Lock() + self.version = 0 + + def update( + self, + ticker: str, + price: float, + timestamp: float | None = None, + reference_price: float | None = None, + ) -> PriceUpdate: + """Record a new price. The first price seen becomes the reference unless one is given.""" + price = round(price, 2) + with self._lock: + prev = self._prices.get(ticker) + update = PriceUpdate( + ticker=ticker, + price=price, + previous_price=prev.price if prev else price, + reference_price=reference_price or (prev.reference_price if prev else price), + timestamp=timestamp or time.time(), + ) + self._prices[ticker] = update + self.version += 1 + return update + + def get(self, ticker: str) -> PriceUpdate | None: + """Latest update for one ticker, or None if it has no price yet.""" + return self._prices.get(ticker) + + def get_price(self, ticker: str) -> float | None: + """Latest price for one ticker, or None.""" + update = self._prices.get(ticker) + return update.price if update else None + + def get_all(self) -> dict[str, PriceUpdate]: + """Snapshot copy of every cached price.""" + with self._lock: + return dict(self._prices) + + def remove(self, ticker: str) -> None: + """Forget a ticker.""" + with self._lock: + if self._prices.pop(ticker, None): + self.version += 1 diff --git a/backend/app/market/factory.py b/backend/app/market/factory.py new file mode 100644 index 000000000..49ec90606 --- /dev/null +++ b/backend/app/market/factory.py @@ -0,0 +1,16 @@ +"""Chooses the market data source from the environment.""" + +import os + +from .cache import PriceCache +from .interface import MarketDataSource +from .massive_client import MassiveDataSource +from .simulator import SimulatorDataSource + + +def create_market_data_source(cache: PriceCache) -> MarketDataSource: + """Massive if MASSIVE_API_KEY is set and non-empty, otherwise the simulator.""" + api_key = os.environ.get("MASSIVE_API_KEY", "").strip() + if api_key: + return MassiveDataSource(cache, api_key) + return SimulatorDataSource(cache) diff --git a/backend/app/market/interface.py b/backend/app/market/interface.py new file mode 100644 index 000000000..f3a7d6f55 --- /dev/null +++ b/backend/app/market/interface.py @@ -0,0 +1,27 @@ +"""Abstract interface implemented by the simulator and the Massive poller.""" + +from abc import ABC, abstractmethod + + +class MarketDataSource(ABC): + """Background producer that keeps a PriceCache up to date for a set of tickers.""" + + @abstractmethod + async def start(self, tickers: list[str]) -> None: + """Begin producing prices for the given tickers. Returns once the first prices are cached.""" + + @abstractmethod + async def stop(self) -> None: + """Stop the background task. Safe to call more than once.""" + + @abstractmethod + async def add_ticker(self, ticker: str) -> None: + """Start tracking a ticker. No-op if already tracked.""" + + @abstractmethod + async def remove_ticker(self, ticker: str) -> None: + """Stop tracking a ticker and drop it from the cache. No-op if not tracked.""" + + @abstractmethod + def get_tickers(self) -> list[str]: + """Tickers currently tracked.""" diff --git a/backend/app/market/massive_client.py b/backend/app/market/massive_client.py new file mode 100644 index 000000000..2020a0ebc --- /dev/null +++ b/backend/app/market/massive_client.py @@ -0,0 +1,123 @@ +"""Market data source backed by the Massive (formerly Polygon.io) REST API.""" + +import asyncio +import contextlib +import logging +from datetime import date, timedelta + +from massive import RESTClient +from massive.exceptions import BadResponse + +from .cache import PriceCache +from .interface import MarketDataSource + +logger = logging.getLogger(__name__) + + +class MassiveDataSource(MarketDataSource): + """Polls Massive for the tracked tickers and writes prices to the PriceCache. + + Paid plans use the multi-ticker Snapshot endpoint every `interval` seconds. + Free plans are not entitled to snapshots; the source detects this on the first + poll and switches to end-of-day closes from Grouped Daily, refreshed every + `eod_interval` seconds. + """ + + def __init__( + self, + cache: PriceCache, + api_key: str, + interval: float = 5.0, + eod_interval: float = 900.0, + ) -> None: + self.cache = cache + self.client = RESTClient(api_key=api_key) + self.interval = interval + self.eod_interval = eod_interval + self.eod_mode = False + self._eod_closes: dict[str, float] = {} + self._tickers: set[str] = set() + self._task: asyncio.Task | None = None + + async def start(self, tickers: list[str]) -> None: + self._tickers = {t.upper() for t in tickers} + await self._poll() + self._task = asyncio.create_task(self._run(), name="massive-poller") + + async def stop(self) -> None: + if self._task: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + + async def add_ticker(self, ticker: str) -> None: + ticker = ticker.upper() + if ticker in self._tickers: + return + self._tickers.add(ticker) + if self.eod_mode and self._eod_closes: + self._write_eod({ticker}) + else: + await self._poll() + + async def remove_ticker(self, ticker: str) -> None: + ticker = ticker.upper() + self._tickers.discard(ticker) + self.cache.remove(ticker) + + def get_tickers(self) -> list[str]: + return sorted(self._tickers) + + async def _run(self) -> None: + """Poll until cancelled. Errors are logged and the next poll retries.""" + while True: + await asyncio.sleep(self.eod_interval if self.eod_mode else self.interval) + try: + await self._poll() + except Exception: + logger.exception("Massive poll failed") + + async def _poll(self) -> None: + """Fetch prices for all tracked tickers, falling back to EOD on a free plan.""" + if not self._tickers: + return + if not self.eod_mode: + try: + await asyncio.to_thread(self._fetch_snapshot) + return + except BadResponse as e: + if "NOT_AUTHORIZED" not in str(e): + raise + logger.warning("Massive key has no snapshot access; using end-of-day prices") + self.eod_mode = True + self._eod_closes = await asyncio.to_thread(self._fetch_latest_closes) + self._write_eod(self._tickers) + + def _fetch_snapshot(self) -> None: + """Write current prices for all tracked tickers from one Snapshot call.""" + snapshots = self.client.get_snapshot_all("stocks", tickers=sorted(self._tickers)) + for snap in snapshots: + prev_close = snap.prev_day.close if snap.prev_day else None + trade = snap.last_trade + price = (trade.price if trade else None) or (snap.day.close if snap.day else None) or prev_close + if not price: + continue + ts = trade.sip_timestamp / 1e9 if trade and trade.sip_timestamp else None + self.cache.update(snap.ticker, price, ts, reference_price=prev_close or None) + + def _fetch_latest_closes(self, max_days_back: int = 7) -> dict[str, float]: + """All closes from the most recent trading day that has Grouped Daily data.""" + day = date.today() - timedelta(days=1) + for _ in range(max_days_back): + bars = self.client.get_grouped_daily_aggs(day.isoformat()) + if bars: + return {b.ticker: b.close for b in bars} + day -= timedelta(days=1) + return {} + + def _write_eod(self, tickers: set[str]) -> None: + """Copy cached end-of-day closes for the given tickers into the PriceCache.""" + for ticker in tickers: + if ticker in self._eod_closes: + self.cache.update(ticker, self._eod_closes[ticker]) diff --git a/backend/app/market/models.py b/backend/app/market/models.py new file mode 100644 index 000000000..237626e7e --- /dev/null +++ b/backend/app/market/models.py @@ -0,0 +1,45 @@ +"""Price data model shared by every market data source.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class PriceUpdate: + """Latest price for one ticker, as stored in the cache and sent over SSE.""" + + ticker: str + price: float + previous_price: float + reference_price: float + timestamp: float + + @property + def change(self) -> float: + """Tick-over-tick price change.""" + return round(self.price - self.previous_price, 4) + + @property + def direction(self) -> str: + """'up', 'down' or 'flat' versus the previous tick.""" + if self.price > self.previous_price: + return "up" + if self.price < self.previous_price: + return "down" + return "flat" + + @property + def day_change_percent(self) -> float: + """Percent change versus the reference (previous close or session start).""" + return round((self.price / self.reference_price - 1) * 100, 4) + + def to_dict(self) -> dict: + """JSON-ready representation used by the SSE stream and REST API.""" + return { + "ticker": self.ticker, + "price": self.price, + "previous_price": self.previous_price, + "timestamp": self.timestamp, + "change": self.change, + "direction": self.direction, + "day_change_percent": self.day_change_percent, + } diff --git a/backend/app/market/seed_prices.py b/backend/app/market/seed_prices.py new file mode 100644 index 000000000..10956a571 --- /dev/null +++ b/backend/app/market/seed_prices.py @@ -0,0 +1,39 @@ +"""Seed prices, per-ticker GBM parameters and sector groups for the simulator.""" + +SEED_PRICES: dict[str, float] = { + "AAPL": 190.0, + "GOOGL": 175.0, + "MSFT": 420.0, + "AMZN": 185.0, + "TSLA": 250.0, + "NVDA": 800.0, + "META": 500.0, + "JPM": 195.0, + "V": 280.0, + "NFLX": 600.0, +} + +# Annualized (drift mu, volatility sigma). +TICKER_PARAMS: dict[str, tuple[float, float]] = { + "AAPL": (0.05, 0.22), + "GOOGL": (0.05, 0.25), + "MSFT": (0.05, 0.20), + "AMZN": (0.05, 0.28), + "TSLA": (0.03, 0.50), + "NVDA": (0.08, 0.40), + "META": (0.05, 0.30), + "JPM": (0.04, 0.18), + "V": (0.04, 0.17), + "NFLX": (0.05, 0.35), +} + +DEFAULT_PARAMS: tuple[float, float] = (0.05, 0.25) + +SECTORS: dict[str, set[str]] = { + "tech": {"AAPL", "GOOGL", "MSFT", "AMZN", "META", "NVDA", "NFLX"}, + "finance": {"JPM", "V"}, +} + +INTRA_SECTOR_CORR: dict[str, float] = {"tech": 0.6, "finance": 0.5} +CROSS_SECTOR_CORR = 0.3 +TSLA_CORR = 0.3 diff --git a/backend/app/market/simulator.py b/backend/app/market/simulator.py new file mode 100644 index 000000000..66da28ded --- /dev/null +++ b/backend/app/market/simulator.py @@ -0,0 +1,152 @@ +"""Correlated geometric Brownian motion price simulator.""" + +import asyncio +import contextlib +import time + +import numpy as np + +from .cache import PriceCache +from .interface import MarketDataSource +from .seed_prices import ( + CROSS_SECTOR_CORR, + DEFAULT_PARAMS, + INTRA_SECTOR_CORR, + SECTORS, + SEED_PRICES, + TICKER_PARAMS, + TSLA_CORR, +) + +TRADING_SECONDS_PER_YEAR = 252 * 6.5 * 3600 +TICK_SECONDS = 0.5 + + +def sector_of(ticker: str) -> str | None: + """Sector name for a ticker, or None if unclassified.""" + return next((name for name, members in SECTORS.items() if ticker in members), None) + + +def pair_correlation(a: str, b: str) -> float: + """Correlation between two tickers' moves.""" + if a == b: + return 1.0 + if "TSLA" in (a, b): + return TSLA_CORR + sector = sector_of(a) + if sector and sector == sector_of(b): + return INTRA_SECTOR_CORR[sector] + return CROSS_SECTOR_CORR + + +class GBMSimulator: + """Steps a set of correlated GBM price paths, with occasional jump events.""" + + def __init__( + self, + tickers: list[str], + dt: float = TICK_SECONDS / TRADING_SECONDS_PER_YEAR, + event_probability: float = 0.001, + seed: int | None = None, + ) -> None: + self.dt = dt + self.event_probability = event_probability + self.rng = np.random.default_rng(seed) + self.prices: dict[str, float] = {} + self._tickers: list[str] = [] + for ticker in tickers: + self.add_ticker(ticker) + + @property + def tickers(self) -> list[str]: + """Tickers being simulated.""" + return list(self._tickers) + + def add_ticker(self, ticker: str) -> None: + """Add a ticker at its seed price, or a random price if unknown.""" + if ticker in self.prices: + return + self.prices[ticker] = SEED_PRICES.get(ticker) or float(self.rng.uniform(50, 300)) + self._tickers.append(ticker) + self._rebuild() + + def remove_ticker(self, ticker: str) -> None: + """Stop simulating a ticker.""" + if ticker not in self.prices: + return + del self.prices[ticker] + self._tickers.remove(ticker) + self._rebuild() + + def _rebuild(self) -> None: + """Recompute parameter vectors and the Cholesky factor of the correlation matrix.""" + params = [TICKER_PARAMS.get(t, DEFAULT_PARAMS) for t in self._tickers] + self._mu = np.array([p[0] for p in params]) + self._sigma = np.array([p[1] for p in params]) + corr = np.array([[pair_correlation(a, b) for b in self._tickers] for a in self._tickers]) + self._chol = np.linalg.cholesky(corr) if self._tickers else corr + + def step(self) -> dict[str, float]: + """Advance every price by one tick and return the new prices.""" + n = len(self._tickers) + if n == 0: + return {} + z = self._chol @ self.rng.standard_normal(n) + log_returns = (self._mu - 0.5 * self._sigma**2) * self.dt + self._sigma * np.sqrt(self.dt) * z + shocks = self._event_shocks(n) + current = np.array([self.prices[t] for t in self._tickers]) + new = current * np.exp(log_returns) * (1 + shocks) + self.prices = dict(zip(self._tickers, new.tolist())) + return dict(self.prices) + + def _event_shocks(self, n: int) -> np.ndarray: + """Random 2-5% up or down jumps on a small fraction of tickers.""" + hit = self.rng.random(n) < self.event_probability + size = self.rng.uniform(0.02, 0.05, n) + sign = self.rng.choice([-1.0, 1.0], n) + return np.where(hit, size * sign, 0.0) + + +class SimulatorDataSource(MarketDataSource): + """Runs GBMSimulator in a background asyncio task and writes to the PriceCache.""" + + def __init__(self, cache: PriceCache, interval: float = TICK_SECONDS, seed: int | None = None) -> None: + self.cache = cache + self.interval = interval + self.sim = GBMSimulator([], seed=seed) + self._task: asyncio.Task | None = None + + async def start(self, tickers: list[str]) -> None: + for ticker in tickers: + await self.add_ticker(ticker) + self._task = asyncio.create_task(self._run(), name="market-simulator") + + async def stop(self) -> None: + if self._task: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + + async def add_ticker(self, ticker: str) -> None: + ticker = ticker.upper() + if ticker in self.sim.prices: + return + self.sim.add_ticker(ticker) + self.cache.update(ticker, self.sim.prices[ticker]) + + async def remove_ticker(self, ticker: str) -> None: + ticker = ticker.upper() + self.sim.remove_ticker(ticker) + self.cache.remove(ticker) + + def get_tickers(self) -> list[str]: + return self.sim.tickers + + async def _run(self) -> None: + """Step the simulator every interval until cancelled.""" + while True: + await asyncio.sleep(self.interval) + now = time.time() + for ticker, price in self.sim.step().items(): + self.cache.update(ticker, price, now) diff --git a/backend/app/market/stream.py b/backend/app/market/stream.py new file mode 100644 index 000000000..312d13861 --- /dev/null +++ b/backend/app/market/stream.py @@ -0,0 +1,37 @@ +"""SSE endpoint that streams the PriceCache to browsers.""" + +import asyncio +import json +from collections.abc import AsyncIterator + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse + +from .cache import PriceCache + + +def create_stream_router(cache: PriceCache, poll_seconds: float = 0.5) -> APIRouter: + """Router exposing GET /api/stream/prices.""" + router = APIRouter() + + @router.get("/api/stream/prices") + async def stream_prices(request: Request) -> StreamingResponse: + return StreamingResponse( + price_events(cache, request, poll_seconds), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + return router + + +async def price_events(cache: PriceCache, request: Request, poll_seconds: float) -> AsyncIterator[str]: + """Yield one SSE event with every ticker whenever the cache changes.""" + yield "retry: 1000\n\n" + last_version = -1 + while not await request.is_disconnected(): + if cache.version != last_version: + last_version = cache.version + payload = {t: u.to_dict() for t, u in cache.get_all().items()} + yield f"data: {json.dumps(payload)}\n\n" + await asyncio.sleep(poll_seconds) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 000000000..ad713c602 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "finally-backend" +version = "0.1.0" +description = "FinAlly backend - AI Trading Workstation" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "numpy>=2.0.0", + "massive>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", + "httpx>=0.27.0", + "ruff>=0.7.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.ruff] +line-length = 110 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W"] diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/market/__init__.py b/backend/tests/market/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/market/test_cache.py b/backend/tests/market/test_cache.py new file mode 100644 index 000000000..6e802b8f1 --- /dev/null +++ b/backend/tests/market/test_cache.py @@ -0,0 +1,74 @@ +from app.market.cache import PriceCache +from app.market.models import PriceUpdate + + +def test_first_update_is_flat_and_sets_reference(): + cache = PriceCache() + u = cache.update("AAPL", 190.0) + assert u.previous_price == 190.0 + assert u.reference_price == 190.0 + assert u.direction == "flat" + assert u.change == 0 + assert cache.version == 1 + + +def test_second_update_sets_direction_and_keeps_reference(): + cache = PriceCache() + cache.update("AAPL", 190.0) + up = cache.update("AAPL", 190.5) + assert up.direction == "up" + assert up.change == 0.5 + assert up.reference_price == 190.0 + down = cache.update("AAPL", 189.0) + assert down.direction == "down" + assert down.previous_price == 190.5 + assert down.day_change_percent == round((189 / 190 - 1) * 100, 4) + + +def test_explicit_reference_price_wins(): + cache = PriceCache() + u = cache.update("AAPL", 191.9, reference_price=190.58) + assert u.reference_price == 190.58 + + +def test_prices_rounded_to_cents(): + cache = PriceCache() + cache.update("AAPL", 190.001) + u = cache.update("AAPL", 190.004) + assert u.price == 190.0 + assert u.direction == "flat" + + +def test_get_and_remove(): + cache = PriceCache() + assert cache.get_price("AAPL") is None + cache.update("AAPL", 190.0) + assert cache.get_price("AAPL") == 190.0 + assert isinstance(cache.get("AAPL"), PriceUpdate) + v = cache.version + cache.remove("AAPL") + assert cache.get("AAPL") is None + assert cache.version == v + 1 + cache.remove("AAPL") # unknown ticker: no version bump + assert cache.version == v + 1 + + +def test_get_all_is_a_copy(): + cache = PriceCache() + cache.update("AAPL", 190.0) + snapshot = cache.get_all() + cache.update("MSFT", 420.0) + assert set(snapshot) == {"AAPL"} + + +def test_to_dict_shape(): + u = PriceUpdate("AAPL", 191.0, 190.0, 190.0, 1.0) + assert u.to_dict() == { + "ticker": "AAPL", + "price": 191.0, + "previous_price": 190.0, + "timestamp": 1.0, + "change": 1.0, + "direction": "up", + "day_change_percent": round((191 / 190 - 1) * 100, 4), + } diff --git a/backend/tests/market/test_factory.py b/backend/tests/market/test_factory.py new file mode 100644 index 000000000..8bfc61266 --- /dev/null +++ b/backend/tests/market/test_factory.py @@ -0,0 +1,25 @@ +import pytest + +from app.market.cache import PriceCache +from app.market.factory import create_market_data_source +from app.market.interface import MarketDataSource +from app.market.massive_client import MassiveDataSource +from app.market.simulator import SimulatorDataSource + + +@pytest.mark.parametrize("value", [None, "", " "]) +def test_no_key_uses_simulator(monkeypatch, value): + if value is None: + monkeypatch.delenv("MASSIVE_API_KEY", raising=False) + else: + monkeypatch.setenv("MASSIVE_API_KEY", value) + source = create_market_data_source(PriceCache()) + assert isinstance(source, SimulatorDataSource) + assert isinstance(source, MarketDataSource) + + +def test_key_uses_massive(monkeypatch): + monkeypatch.setenv("MASSIVE_API_KEY", "abc123") + source = create_market_data_source(PriceCache()) + assert isinstance(source, MassiveDataSource) + assert isinstance(source, MarketDataSource) diff --git a/backend/tests/market/test_massive.py b/backend/tests/market/test_massive.py new file mode 100644 index 000000000..28080f80e --- /dev/null +++ b/backend/tests/market/test_massive.py @@ -0,0 +1,144 @@ +import asyncio +from datetime import date, timedelta +from types import SimpleNamespace + +import pytest +from massive.exceptions import BadResponse + +from app.market.cache import PriceCache +from app.market.massive_client import MassiveDataSource + + +def snapshot(ticker, last=None, day_close=None, prev_close=None, ts=None): + return SimpleNamespace( + ticker=ticker, + last_trade=SimpleNamespace(price=last, sip_timestamp=ts) if last is not None else None, + day=SimpleNamespace(close=day_close) if day_close is not None else None, + prev_day=SimpleNamespace(close=prev_close) if prev_close is not None else None, + ) + + +class StubClient: + """Stands in for massive.RESTClient. Records calls; no network.""" + + def __init__(self, snapshots=None, grouped=None, authorized=True): + self.snapshots = snapshots or {} + self.grouped = grouped or {} # ISO date -> {ticker: close} + self.authorized = authorized + self.snapshot_calls = 0 + self.grouped_calls: list[str] = [] + + def get_snapshot_all(self, market, tickers): + self.snapshot_calls += 1 + if not self.authorized: + raise BadResponse('{"status":"NOT_AUTHORIZED","message":"You are not entitled to this data."}') + return [self.snapshots[t] for t in tickers if t in self.snapshots] + + def get_grouped_daily_aggs(self, day): + self.grouped_calls.append(day) + return [SimpleNamespace(ticker=t, close=c) for t, c in self.grouped.get(day, {}).items()] + + +def make_source(client, **kwargs): + cache = PriceCache() + source = MassiveDataSource(cache, "test-key", **kwargs) + source.client = client + return source, cache + + +async def test_paid_plan_uses_snapshot(): + client = StubClient( + snapshots={ + "AAPL": snapshot("AAPL", last=191.9, prev_close=190.58, ts=1_759_507_199_123_456_789), + "MSFT": snapshot("MSFT", day_close=421.0, prev_close=420.0), + "OLD": snapshot("OLD", prev_close=10.0), + } + ) + source, cache = make_source(client) + await source.start(["aapl", "MSFT", "OLD", "BOGUS"]) + try: + aapl = cache.get("AAPL") + assert aapl.price == 191.9 + assert aapl.reference_price == 190.58 + assert aapl.timestamp == pytest.approx(1_759_507_199.123, abs=1e-3) + assert cache.get_price("MSFT") == 421.0 # falls back to day close + assert cache.get_price("OLD") == 10.0 # then previous close + assert cache.get("BOGUS") is None # unknown tickers never get a price + assert not source.eod_mode + assert client.grouped_calls == [] + finally: + await source.stop() + + +async def test_free_plan_falls_back_to_grouped_daily(): + yesterday = (date.today() - timedelta(days=1)).isoformat() + client = StubClient(authorized=False, grouped={yesterday: {"AAPL": 190.58, "PYPL": 70.0}}) + source, cache = make_source(client) + await source.start(["AAPL"]) + try: + assert source.eod_mode + assert cache.get_price("AAPL") == 190.58 + + # A new ticker is served from the in-memory closes, with no further API call. + await source.add_ticker("PYPL") + assert cache.get_price("PYPL") == 70.0 + assert client.snapshot_calls == 1 + assert client.grouped_calls == [yesterday] + finally: + await source.stop() + + +async def test_weekend_walk_back(): + three_days_ago = (date.today() - timedelta(days=3)).isoformat() + client = StubClient(authorized=False, grouped={three_days_ago: {"AAPL": 188.0}}) + source, cache = make_source(client) + await source.start(["AAPL"]) + try: + assert cache.get_price("AAPL") == 188.0 + assert len(client.grouped_calls) == 3 + finally: + await source.stop() + + +async def test_other_bad_response_propagates_from_start(): + class Broken(StubClient): + def get_snapshot_all(self, market, tickers): + raise BadResponse('{"status":"ERROR","message":"boom"}') + + source, _ = make_source(Broken()) + with pytest.raises(BadResponse): + await source.start(["AAPL"]) + + +async def test_background_errors_do_not_kill_poller(): + client = StubClient(snapshots={"AAPL": snapshot("AAPL", last=190.0)}) + source, cache = make_source(client, interval=0.01) + await source.start(["AAPL"]) + try: + client.snapshots = None # next polls raise TypeError + await asyncio.sleep(0.05) + client.snapshots = {"AAPL": snapshot("AAPL", last=195.0)} + await asyncio.sleep(0.05) + assert cache.get_price("AAPL") == 195.0 + finally: + await source.stop() + await source.stop() + + +async def test_add_and_remove_tickers(): + client = StubClient( + snapshots={"AAPL": snapshot("AAPL", last=190.0), "MSFT": snapshot("MSFT", last=420.0)} + ) + source, cache = make_source(client) + await source.start(["AAPL"]) + try: + await source.add_ticker("msft") + assert cache.get_price("MSFT") == 420.0 + calls = client.snapshot_calls + await source.add_ticker("MSFT") # already tracked: no call + assert client.snapshot_calls == calls + await source.remove_ticker("msft") + assert source.get_tickers() == ["AAPL"] + assert cache.get("MSFT") is None + finally: + await source.stop() diff --git a/backend/tests/market/test_simulator.py b/backend/tests/market/test_simulator.py new file mode 100644 index 000000000..06f532625 --- /dev/null +++ b/backend/tests/market/test_simulator.py @@ -0,0 +1,116 @@ +import asyncio + +import numpy as np +import pytest + +from app.market import seed_prices +from app.market.cache import PriceCache +from app.market.simulator import GBMSimulator, SimulatorDataSource, pair_correlation + +DEFAULT = list(seed_prices.SEED_PRICES) + + +def log_returns(sim: GBMSimulator, steps: int) -> dict[str, np.ndarray]: + """Run the simulator and return per-ticker log returns.""" + paths = {t: [sim.prices[t]] for t in sim.tickers} + for _ in range(steps): + for t, p in sim.step().items(): + paths[t].append(p) + return {t: np.diff(np.log(p)) for t, p in paths.items()} + + +def test_prices_stay_positive(): + sim = GBMSimulator(DEFAULT, seed=1) + for _ in range(10_000): + assert all(p > 0 for p in sim.step().values()) + + +def test_volatility_matches_sigma(): + sim = GBMSimulator(["AAPL", "TSLA", "JPM"], dt=1 / 252, event_probability=0, seed=2) + returns = log_returns(sim, 20_000) + for ticker in ("AAPL", "TSLA", "JPM"): + sigma = seed_prices.TICKER_PARAMS[ticker][1] + assert returns[ticker].std() * np.sqrt(252) == pytest.approx(sigma, rel=0.05) + + +def test_drift(monkeypatch): + monkeypatch.setitem(seed_prices.TICKER_PARAMS, "AAPL", (0.05, 1e-9)) + sim = GBMSimulator(["AAPL"], dt=1.0, event_probability=0, seed=3) + new = sim.step()["AAPL"] + assert new == pytest.approx(190.0 * np.exp(0.05), rel=1e-6) + + +def test_correlation(): + sim = GBMSimulator(["AAPL", "MSFT", "JPM"], dt=1 / 252, event_probability=0, seed=4) + r = log_returns(sim, 20_000) + assert np.corrcoef(r["AAPL"], r["MSFT"])[0, 1] == pytest.approx(0.6, abs=0.05) + assert np.corrcoef(r["AAPL"], r["JPM"])[0, 1] == pytest.approx(0.3, abs=0.05) + + +def test_events_always_and_never(): + sim = GBMSimulator(DEFAULT, event_probability=1, seed=5) + before = dict(sim.prices) + for t, p in sim.step().items(): + move = abs(p / before[t] - 1) + assert 0.019 < move < 0.051 + + sim = GBMSimulator(DEFAULT, event_probability=0, seed=6) + for _ in range(1000): + before = dict(sim.prices) + for t, p in sim.step().items(): + assert abs(p / before[t] - 1) < 0.005 + + +def test_ticker_management(): + sim = GBMSimulator([], seed=7) + assert sim.step() == {} + sim.add_ticker("AAPL") + sim.add_ticker("AAPL") + assert sim.tickers == ["AAPL"] + assert sim.prices["AAPL"] == 190.0 + sim.add_ticker("ZZZZ") + assert 50 <= sim.prices["ZZZZ"] <= 300 + sim.remove_ticker("NOPE") + sim.remove_ticker("AAPL") + assert sim.tickers == ["ZZZZ"] + assert set(sim.step()) == {"ZZZZ"} + + +@pytest.mark.parametrize( + ("a", "b", "expected"), + [ + ("AAPL", "AAPL", 1.0), + ("AAPL", "MSFT", 0.6), + ("JPM", "V", 0.5), + ("AAPL", "JPM", 0.3), + ("TSLA", "AAPL", 0.3), + ("ZZZZ", "AAPL", 0.3), + ("ZZZZ", "YYYY", 0.3), + ], +) +def test_pair_correlation(a, b, expected): + assert pair_correlation(a, b) == expected + assert pair_correlation(b, a) == expected + + +async def test_data_source_lifecycle(): + cache = PriceCache() + source = SimulatorDataSource(cache, interval=0.01, seed=8) + await source.start(["AAPL", "msft"]) + assert cache.get_price("AAPL") == 190.0 + assert cache.get_price("MSFT") == 420.0 + assert source.get_tickers() == ["AAPL", "MSFT"] + + version = cache.version + await asyncio.sleep(0.1) + assert cache.version > version + + await source.add_ticker("AAPL") # idempotent + assert source.get_tickers() == ["AAPL", "MSFT"] + + await source.remove_ticker("MSFT") + assert cache.get("MSFT") is None + assert source.get_tickers() == ["AAPL"] + + await source.stop() + await source.stop() diff --git a/backend/tests/market/test_stream.py b/backend/tests/market/test_stream.py new file mode 100644 index 000000000..af0540bf4 --- /dev/null +++ b/backend/tests/market/test_stream.py @@ -0,0 +1,39 @@ +import json + +from app.market.cache import PriceCache +from app.market.stream import price_events + + +class StubRequest: + """Reports a disconnect after `checks` calls to is_disconnected.""" + + def __init__(self, checks: int) -> None: + self.remaining = checks + + async def is_disconnected(self) -> bool: + self.remaining -= 1 + return self.remaining < 0 + + +async def collect(cache: PriceCache, checks: int) -> list[str]: + return [e async for e in price_events(cache, StubRequest(checks), poll_seconds=0)] + + +async def test_first_event_is_retry_then_data(): + cache = PriceCache() + cache.update("AAPL", 190.0) + cache.update("MSFT", 420.0) + events = await collect(cache, checks=1) + assert events[0] == "retry: 1000\n\n" + assert events[1].startswith("data: ") and events[1].endswith("\n\n") + payload = json.loads(events[1].removeprefix("data: ")) + assert set(payload) == {"AAPL", "MSFT"} + assert payload["AAPL"]["price"] == 190.0 + assert payload["AAPL"]["direction"] == "flat" + + +async def test_sends_only_when_cache_changes(): + cache = PriceCache() + cache.update("AAPL", 190.0) + events = await collect(cache, checks=5) + assert len(events) == 2 # retry + one data event; version never changed again diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 000000000..67472c96e --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,804 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "fastapi" +version = "0.142.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/4f/f7c30a73127e0a8bbffe788369b8359e530b01ae06e2757936fa35bc5e6d/fastapi-0.142.2.tar.gz", hash = "sha256:06366626f2e70576367714d9ab2fe8472e6c8456dba69b399f9f797ab5e92570", size = 466721, upload-time = "2026-09-30T09:51:11.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/b6/78aaf9141fb46742928c113f3cf6ef2259d538cb02b604b7656c1dc9883c/fastapi-0.142.2-py3-none-any.whl", hash = "sha256:bd5f4d81f1e93a88bcd77caf4dfe3c2dbffc3805407a0007e9a114c18b3a670b", size = 144409, upload-time = "2026-09-30T09:51:09.8Z" }, +] + +[[package]] +name = "finally-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "massive" }, + { name = "numpy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, + { name = "massive", specifier = ">=2.0.0" }, + { name = "numpy", specifier = ">=2.0.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "massive" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/b2/7cc9fadccd111b9fa1c378dad6a668312b563600d19498d26051fb57cf73/massive-2.8.0.tar.gz", hash = "sha256:e3f70c4b51e03b105a01a5a91e01745c43f9f5d4da9459ea80e1b7c3e7a17278", size = 50942, upload-time = "2026-05-26T08:18:34.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/0d/01464a7faa974cf0e6345cf93f2f5d10991a316e733d3f55e36fbb2d814d/massive-2.8.0-py3-none-any.whl", hash = "sha256:d04332c9dec289bdf71e4cfaf8bfba26bd10e5829806d27b833488e89ee5015b", size = 68725, upload-time = "2026-05-26T08:18:35.766Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/dc/e12c1fe1ed8a7b7149777127b1a0e12ce5bd5a81d97408bedc2128c260f5/opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7", size = 72115, upload-time = "2026-09-25T12:32:28.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/040d1a1c7836922828e6480cd2366bb8fe0ebf75b413d2bb51a9b0e7f78f/opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3", size = 60020, upload-time = "2026-09-25T12:32:03.192Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, +] + +[[package]] +name = "ruff" +version = "0.16.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, + { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, + { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, + { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, + { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +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" } +wheels = [ + { 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" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +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" } +wheels = [ + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +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" } +wheels = [ + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, + { 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" }, +] + +[[package]] +name = "websockets" +version = "17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" }, + { url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" }, + { url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" }, + { url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" }, + { url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" }, + { url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" }, + { url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" }, + { url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" }, + { url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" }, + { url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" }, + { url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" }, + { url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" }, + { url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" }, + { url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" }, + { url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" }, + { url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" }, + { url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" }, + { url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" }, + { url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" }, + { url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" }, + { url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" }, + { url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" }, + { url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" }, + { url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" }, + { url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" }, + { url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" }, + { url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" }, + { url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" }, + { url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" }, + { url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" }, + { url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" }, + { url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" }, + { url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" }, +]