From 1f058318213a56fe314e5aa925b89d6c53f6dd4f Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:32:05 -0400 Subject: [PATCH 01/11] =?UTF-8?q?M1:=20core=20analytics=20=E2=80=94=20Blac?= =?UTF-8?q?k-Scholes=20greeks=20and=20a=20numerical=20IV=20solver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prices come from the vendor; everything analytical is derived here. The provider dataclasses have no fields for vendor greeks or impliedVolatility, so that boundary is enforced by the type definitions rather than by care. - black_scholes: BSM pricing plus the five analytic greeks, with a continuous dividend yield. Degenerate branches (T<=0, sigma<=0) collapse to discounted forward intrinsic instead of dividing by zero. No scipy — the normal CDF is math.erf, and chains are small enough that scalar Python is milliseconds. Verified against reference values and against finite differences of price(). - implied_vol: Newton-Raphson on vega with a bisection fallback for the wings, where vega underflows and the Newton step explodes. Price tolerance scales with the quote, and bisection also stops on bracket width — an absolute tolerance let sub-penny wing contracts satisfy the price test while sigma was still wrong in the fourth decimal. Round-trip error is now under 4e-9 across sigma 1e-4..5 and T 1e-4..2y. Every failure mode returns None with a named status and a human-readable explanation; none returns a number. - providers: MarketDataProvider interface, a yfinance implementation with backoff around Yahoo's throttling, and a FixtureProvider replaying a real 1,122-contract capture. The fixture is what makes the suite runnable with no network and keeps the demo working when the unofficial endpoint rate-limits, which it did during development. - chain: one tidy typed frame, nullable dtypes so "no reported volume" stays null rather than becoming a real zero. Unsolvable strikes are kept and labelled, not dropped. - cli: `python -m options_tool chain AAPL --expiry 2027-01-15`, with --provider fixture for the offline path. Live SPY 0-DTE output has delta running -0.04 to -0.96, gamma and vega both peaking at the ATM strike, and a correctly shaped put smile. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 32 + .gitignore | 35 + CLAUDE.md | 63 + README.md | 163 +- SPEC.md | 237 + api/options_tool/__init__.py | 4 + api/options_tool/__main__.py | 8 + api/options_tool/analytics/__init__.py | 1 + api/options_tool/analytics/black_scholes.py | 246 + api/options_tool/analytics/chain.py | 217 + api/options_tool/analytics/implied_vol.py | 239 + api/options_tool/api/__init__.py | 0 api/options_tool/cli.py | 273 + api/options_tool/config.py | 98 + api/options_tool/db/__init__.py | 0 api/options_tool/providers/__init__.py | 50 + api/options_tool/providers/base.py | 135 + api/options_tool/providers/capture.py | 89 + .../providers/fixture_provider.py | 115 + api/options_tool/providers/rates.py | 54 + .../providers/yfinance_provider.py | 220 + api/pyproject.toml | 110 + api/tests/__init__.py | 0 api/tests/fixtures/chains.json | 11253 ++++++++++++++++ 24 files changed, 13640 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 SPEC.md create mode 100644 api/options_tool/__init__.py create mode 100644 api/options_tool/__main__.py create mode 100644 api/options_tool/analytics/__init__.py create mode 100644 api/options_tool/analytics/black_scholes.py create mode 100644 api/options_tool/analytics/chain.py create mode 100644 api/options_tool/analytics/implied_vol.py create mode 100644 api/options_tool/api/__init__.py create mode 100644 api/options_tool/cli.py create mode 100644 api/options_tool/config.py create mode 100644 api/options_tool/db/__init__.py create mode 100644 api/options_tool/providers/__init__.py create mode 100644 api/options_tool/providers/base.py create mode 100644 api/options_tool/providers/capture.py create mode 100644 api/options_tool/providers/fixture_provider.py create mode 100644 api/options_tool/providers/rates.py create mode 100644 api/options_tool/providers/yfinance_provider.py create mode 100644 api/pyproject.toml create mode 100644 api/tests/__init__.py create mode 100644 api/tests/fixtures/chains.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..68dd5d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Copy to .env and adjust. .env is gitignored and must never be committed. + +# --- Market data ------------------------------------------------------------- +# Which provider the CLI and API use by default: yfinance | fixture +OPTIONS_PROVIDER=yfinance + +# Optional keys for alternative providers. yfinance needs none; these slots exist +# so swapping the provider stays a one-file change (see providers/base.py). +POLYGON_API_KEY= +TRADIER_API_KEY= + +# --- Pricing assumptions ----------------------------------------------------- +# Annualised continuously-compounded risk-free rate used by Black-Scholes. +# Ignored when OPTIONS_FETCH_RISK_FREE_RATE=true. +OPTIONS_RISK_FREE_RATE=0.04 +# Fetch the 13-week T-bill (^IRX) and use it as the risk-free rate instead. +OPTIONS_FETCH_RISK_FREE_RATE=false +# Continuous dividend yield assumed for every underlying. See README Limitations. +OPTIONS_DIVIDEND_YIELD=0.0 + +# --- IV rank ----------------------------------------------------------------- +# Trailing window for IV rank/percentile, in stored snapshot days. +OPTIONS_IV_WINDOW_DAYS=252 +# Minimum stored days before an IV rank is reported at all. Below this the API +# returns null with a reason rather than a misleading zero. +OPTIONS_MIN_HISTORY_DAYS=20 + +# --- Storage ----------------------------------------------------------------- +OPTIONS_DATABASE_URL=sqlite:///./data/options.db + +# --- Web --------------------------------------------------------------------- +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e5276c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Secrets +.env +.env.* +!.env.example + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ + +# Node +node_modules/ +.next/ +dist/ +build/ +*.tsbuildinfo + +# Local data +*.db +*.sqlite +*.sqlite3 +data/ + +# OS / editor +.DS_Store +.idea/ +.vscode/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e62948 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# CLAUDE.md — options analytics tool + +## What this is + +A personal options-analytics tool: option chains with **greeks and implied +volatility computed locally**, plus an implied-volatility history built from the +project's own daily snapshots so IV rank can be computed at all. + +`SPEC.md` is the contract. Read it before changing anything — several decisions +in it look like they could be simplified and specifically must not be. + +## The two rules that are the whole point + +1. **Never read the vendor's greeks or `impliedVolatility`.** Take *prices* from + the provider; derive everything else in `api/options_tool/analytics/`. + `OptionQuote` has no fields for vendor greeks, which enforces this by + construction — don't add them. +2. **Never return a fabricated number.** Every solver edge case returns `None` + with a status explaining why, and IV rank returns `null` plus a reason until + the minimum history window is met. A blank cell is correct; a plausible-looking + wrong number is the failure mode this project exists to avoid. + +## Layout + +- `api/options_tool/providers/` — the market-data boundary. Vendor types stop here. +- `api/options_tool/analytics/` — pure functions, no I/O. `mypy --strict` applies. +- `api/options_tool/db/` — SQLAlchemy models and the snapshot writer. +- `api/options_tool/api/` — FastAPI app. +- `web/` — Next.js front end. + +## Running it + +```bash +cd api +uv venv --python 3.11 .venv +# This machine's python3.11 is a universal2 binary and uv otherwise resolves +# x86_64 wheels onto an arm64 interpreter, which breaks numpy at import. +uv pip install -e ".[dev]" --python .venv/bin/python --python-platform aarch64-apple-darwin + +.venv/bin/python -m options_tool chain AAPL --expiry 2027-01-15 +.venv/bin/python -m options_tool --provider fixture chain AAPL # offline +``` + +## Testing + +```bash +cd api && .venv/bin/pytest # offline: sockets are disabled in config +.venv/bin/pytest -m network # opt in to live-vendor tests +.venv/bin/ruff check . && .venv/bin/mypy options_tool +``` + +The suite runs with `--disable-socket`. If a test needs market data it must use +`FixtureProvider`, never the network. A test that starts failing with a socket +error is telling you a code path acquired a hidden network dependency. + +## Constraints + +- yfinance is an unofficial Yahoo scraper. It rate-limits and breaks without + notice. That is why `FixtureProvider` and `--provider fixture` exist. +- Black-Scholes assumes European exercise; US equity options are American. Stated + in the README's Limitations section and not to be quietly dropped. +- No claim of profitability, returns, or backtested edge belongs anywhere in this + repo. Not investment advice. diff --git a/README.md b/README.md index 311cbd6..3ec3857 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,161 @@ -# stockwatchlist -stock watch list that displays chart and other data +# Options & Markets Analytics + +A personal options-analytics tool. Option chains with **Black-Scholes greeks and +implied volatility computed locally**, and an IV-rank history the tool builds +itself by snapshotting chains daily. + +> Not investment advice. A personal analytics tool, built for my own use. + +--- + +## Why I built it + +I trade options, and the thing I actually want to look at — where today's implied +volatility sits against its own recent history — is not something a free data +source will give you. So this tool computes the analytics from first principles +and accumulates the history it needs. + +Two design decisions drive the whole project: + +**I compute the greeks rather than reading the vendor's columns.** Yahoo ships a +`delta` and an `impliedVolatility` field. This tool ignores both. Prices come +from the provider; Black-Scholes-Merton pricing, all five greeks, and the implied +volatility solve all happen in `api/options_tool/analytics/`. The provider's data +classes have nowhere to put a vendor greek, so the rule is enforced by the type +definitions rather than by discipline. + +**I build the IV history instead of faking it.** IV rank needs historical implied +volatility, and free APIs do not provide it. Rather than substitute realised +volatility and call it the same thing, the tool runs a daily snapshot job that +writes the full chain — including its own computed IV — to SQLite. History +accumulates from day one forward. Until there are enough stored days, IV rank +returns `null` and a reason, and the UI says *"insufficient history (3/20 days)"* +rather than rendering a misleading zero. + +--- + +## Status + +| Milestone | State | +|---|---| +| M1 — core analytics (pricing, greeks, IV solver, chain normalisation, CLI) | done | +| M2 — persistence + daily snapshots + IV rank | in progress | +| M3 — FastAPI | planned | +| M4 — tests + CI | planned | +| M5 — Next.js front end | planned | +| M6 — Docker + Linux deploy | planned | +| M7 — polish (earnings flag, screener, CSV export) | planned | + +--- + +## Setup + +Requires Python 3.11+. + +```bash +git clone https://github.com/Tejas12972/stockwatchlist.git +cd stockwatchlist/api + +python3.11 -m venv .venv +.venv/bin/pip install -e ".[dev]" + +cp ../.env.example ../.env # optional; every setting has a working default +``` + +### Use it + +```bash +# Live chain with self-computed greeks +.venv/bin/python -m options_tool chain AAPL --expiry 2027-01-15 + +# Only near-the-money calls +.venv/bin/python -m options_tool chain SPY --right call --near-the-money 5 + +# Offline, from the checked-in capture -- works when Yahoo is throttling +.venv/bin/python -m options_tool --provider fixture chain AAPL + +.venv/bin/python -m options_tool expiries AAPL +.venv/bin/python -m options_tool quote SPY +``` + +``` + SPY 2026-09-14 spot 764.29 T 0.0050y r 4.00% +┏━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓ +┃ right ┃ strike ┃ bid ┃ ask ┃ mid ┃ src ┃ IV ┃ delta ┃ gamma ┃ vega ┃ th/day ┃ +┡━━━━━━━╇━━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━━━╇━━━━━╇━━━━━━━╇━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━┩ +│ put │ 750.00 │ 0.13 │ 0.14 │ 0.14 │ mid │ 15.5% │ -0.0399 │ 0.01031 │ 4.633 │ -0.1948 │ +│ put │ 760.00 │ 0.73 │ 0.74 │ 0.73 │ mid │ 10.8% │ -0.2201 │ 0.05107 │ 15.949 │ -0.4554 │ +│ put │ 765.00 │ 2.20 │ 2.23 │ 2.21 │ mid │ 8.9% │ -0.5448 │ 0.08227 │ 21.348 │ -0.4812 │ +│ put │ 770.00 │ 5.76 │ 5.84 │ 5.80 │ mid │ 8.4% │ -0.8888 │ 0.04186 │ 10.203 │ -0.1615 │ +└───────┴────────┴───────┴───────┴───────┴─────┴───────┴─────────┴─────────┴────────┴─────────┘ + 6/6 strikes solved (100%) · greeks computed locally, not vendor-supplied +``` + +--- + +## How the analytics work + +**Pricing** is Black-Scholes-Merton with a continuous dividend yield. Delta, +gamma, vega, theta and rho are the analytic partial derivatives, returned in raw +per-unit terms (`Greeks.theta_per_day` and `.vega_per_point` scale them for +display). + +**Implied volatility** is solved numerically: Newton-Raphson on vega, with a +bisection fallback for the contracts where Newton misbehaves — deep in- and +out-of-the-money strikes where vega underflows and the update step explodes. +Bisection cannot diverge, so the wings still solve; across a test surface +spanning 1e-4 to 5.0 volatility and 0.0001 to 2 years, round-trip error is under +4e-9. + +**Every failure returns `None` with a reason**, never a number: + +| Status | Meaning | +|---|---| +| `expired` | contract has expired; its price implies no future volatility | +| `no_quote` | no usable bid/ask or last price | +| `non_positive_price` | quoted price is zero or negative | +| `below_intrinsic` | price is below intrinsic value — quote is stale or crossed | +| `above_max` | price exceeds the no-arbitrage maximum | +| `no_bracket` | no volatility in [0.0001, 5] reproduces this price | +| `not_converged` | solver ran out of iterations | + +Strikes that fail are kept in the table, greyed out and labelled. A strike that +cannot be priced is information, so it is shown rather than dropped. + +--- + +## Limitations + +These are real and I would rather state them than hide them. + +**Black-Scholes assumes European exercise. US equity options are American.** They +can be exercised early, which Black-Scholes does not model. For liquid +non-dividend-paying names the pricing error is small. For deep in-the-money puts +and for calls just before an ex-dividend date it is not small, and the greeks +this tool reports for those contracts will be off. + +**yfinance is an unofficial scraper of Yahoo Finance, not a licensed feed.** It +has no SLA, its schema changes without notice, and it rate-limits — it was +throttling this machine during development, which is exactly why the offline +fixture provider exists. Data is delayed, and the tool is not a market-data +service. All market data is fetched behind the `MarketDataProvider` interface, so +moving to Polygon or Tradier is one new file plus a config change. + +**IV rank is meaningless until enough history exists.** The tool starts with an +empty database and accumulates one snapshot per day. Below the configured minimum +(20 days by default), it reports `null` and a day count instead of a number. +A tool that showed a rank on day one would be making it up. + +**The risk-free rate and dividend yield are assumptions.** The rate defaults to a +configured constant (`OPTIONS_RISK_FREE_RATE`, 4%), optionally the live 13-week +T-bill. Dividend yield defaults to zero for every underlying, which is wrong for +dividend payers and will bias their greeks. + +**Time to expiry is calendar time**, measured to the 16:00 New York close, not +trading time. Weekend decay is therefore priced as if it were trading decay. + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..31851a7 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,237 @@ +# Build Spec — Options & Markets Analytics Tool + +**For:** Karthik Tejas Basavarajappa · **Written:** 2026-09-12 +**How to use:** drop this file into a fresh repo as `SPEC.md`, open Claude Code there, and say +*"build this following SPEC.md, one milestone at a time."* + +--- + +## 0. Why this project exists (read before choosing anything) + +This is a **resume artifact with a real user (me)**. Every design decision below is made to close a +specific, documented gap. Do not "improve" the plan by dropping these — they are the point: + +| Gap being closed | How this project closes it | +|---|---| +| **Data/analytics is the #1 requested specialization** across 1,036 co-op postings; my CV lists Pandas and SQL as bare keywords with **no supporting bullet** | Pandas is the analysis layer, and a real stored dataset backs it | +| **Test/QA is #2 requested (67 postings); I have zero testing evidence** | pytest + GitHub Actions are **milestone 4, not optional** | +| **No CI/CD experience** — had to concede this in a Philips cover letter | GitHub Actions on every push | +| **No Linux evidence**, though Philips lists it as a *stated requirement* | Milestone 6 deploys to a Linux box in Docker | +| **95 finance-sector roles; my "why finance?" is an assertion with no artifact** | The artifact | +| I trade options personally but nothing public shows it | The tool does what I actually do | + +**Scope discipline is itself a goal.** My GitHub already has `stockwatchlist` — an empty stub with a +LICENSE and a 69-byte README. A second abandoned repo is worse than no repo. **Milestones 1-4 are +the deliverable. 5-7 are bonus.** Ship 1-4 completely before starting 5. + +--- + +## 1. What it does + +A personal options-analytics dashboard: + +1. **Watchlist** — track tickers, see price and basic stats +2. **Options chain viewer** — full chain for a ticker/expiry, with greeks **computed by me, not + taken from the vendor** (see §4 — this is deliberate) +3. **IV rank / IV percentile** — where today's implied volatility sits against its own history +4. **Spread payoff visualizer** — build a multi-leg position, see P/L at expiry and today +5. **Daily snapshot job** — persists chains so IV history accumulates (see §3 — this is the + interesting engineering problem) + +**Not** a trading bot, not a recommendation engine, not a backtester. See §9. + +--- + +## 2. Stack (decided — do not re-litigate) + +| Layer | Choice | Why | +|---|---|---| +| Analysis | **Python 3.11 + Pandas + NumPy** | The gap being closed. Non-negotiable. | +| API | **FastAPI** + Pydantic | Typed, auto-docs, fast to write | +| Storage | **SQLite** via SQLAlchemy | Zero-ops, real SQL, gives the SQL keyword an artifact. Postgres only if deploying multi-user. | +| Front end | **Next.js + React + TypeScript** | Already my strongest skill — keeps it visible without being the point | +| Charts | **Recharts** or hand-rolled SVG | No heavy dep | +| Tests | **pytest** (+ `pytest-cov`), **vitest** for the front end | Milestone 4 | +| CI | **GitHub Actions** | Runs tests + lint on push | +| Container | **Docker + docker-compose** | Milestone 6 | + +**Market data: `yfinance` as the default.** Free, no key, has options chains. +**Be honest about it in the README:** it is an unofficial Yahoo Finance scraper, it breaks without +warning, and it is not a licensed feed. Put the fetch behind a `MarketDataProvider` interface so a +swap to Polygon/Tradier is a one-file change — *that abstraction is itself a talking point*. + +--- + +## 3. The interesting problem: building IV history + +IV rank needs historical implied volatility. **Free APIs do not give you this.** Do not fake it and +do not silently substitute realized volatility. + +**The design:** a scheduled job snapshots the full options chain daily and writes it to SQLite. +History accumulates from day one forward. On day 1 IV rank is undefined and the UI must **say so** +rather than render a misleading zero. + +This is the best engineering story in the project — a real data pipeline with idempotency, schema +design, and a degraded-state UI. Build it properly: + +- `snapshot` command, idempotent per (ticker, expiry, strike, right, date) — safe to re-run +- Store raw mid-price, bid, ask, volume, OI, **and my computed IV** (§4) +- IV rank over a trailing window: `(iv_today - iv_min) / (iv_max - iv_min) * 100` +- **Require a minimum history window (e.g. 20 trading days) before showing a rank.** Below that, + return `null` and have the UI render "insufficient history (N/20 days)". + +--- + +## 4. Compute the greeks yourself + +Take the **price** from the vendor. Compute **IV and the greeks** yourself. + +Reason: anyone can render a vendor's `delta` column. Implementing Black-Scholes, then solving for +implied volatility numerically, is the part that demonstrates quantitative ability — and it is the +thing a finance-sector interviewer will actually ask about. + +- **Black-Scholes-Merton** for price; delta, gamma, theta, vega, rho analytically +- **Implied vol** by Newton-Raphson on vega, bisection fallback when it fails to converge +- Handle the ugly cases explicitly: zero/near-zero time to expiry, deep ITM/OTM, zero bid, + non-convergence. **Return `None`, never a silently wrong number.** + +**State the limitation in the README:** Black-Scholes assumes European exercise, but US equity +options are American and can be exercised early. For liquid non-dividend names the error is small; +for deep ITM puts and pre-dividend calls it is not. Naming this limitation is worth more in an +interview than hiding it. + +--- + +## 5. Milestones + +Each milestone ends with **working, committed, tested code**. Do not start the next until the +previous is green in CI. + +### M1 — Core analytics (Python only, no UI) +- [ ] `MarketDataProvider` interface + `YFinanceProvider` +- [ ] Black-Scholes pricing + all five greeks +- [ ] IV solver (Newton-Raphson + bisection fallback), edge cases returning `None` +- [ ] Pandas chain normalization: tidy DataFrame, one row per contract, typed columns +- [ ] CLI: `python -m options_tool chain AAPL --expiry 2027-01-15` +- **Done when:** greeks for a real chain print correctly and match a reference within tolerance + +### M2 — Persistence + snapshots +- [ ] SQLAlchemy schema: `tickers`, `snapshots`, `contracts` +- [ ] `snapshot` command, idempotent per (ticker, expiry, strike, right, date) +- [ ] IV rank / percentile with the minimum-history guard from §3 +- [ ] Alembic migration (or a documented single-schema-version decision) +- **Done when:** two runs on the same day produce no duplicate rows + +### M3 — API +- [ ] FastAPI: `/watchlist`, `/chain/{ticker}`, `/ivrank/{ticker}`, `/payoff` +- [ ] Pydantic models on every response +- [ ] `/payoff` accepts multi-leg positions, returns a P/L curve at expiry **and** at today +- [ ] Errors are typed and useful — never a bare 500 +- **Done when:** `/docs` is browsable and every endpoint returns real data + +### M4 — Tests + CI ← **the milestone with the most resume value. Do not skip.** +- [ ] pytest covering: BS against known reference values, IV round-trip + (price → IV → price), every edge case in §4, IV-rank math, snapshot idempotency +- [ ] **Market data mocked in tests** — the suite must pass offline with no network +- [ ] `pytest --cov`, coverage reported, aim ≥80% on the analytics module +- [ ] GitHub Actions: tests + `ruff` + `mypy` on push and PR +- [ ] Badge in the README +- **Done when:** CI is green on a PR and the suite passes with the network off + +### M5 — Front end +- [ ] Next.js + TypeScript, watchlist page, chain table, IV-rank display +- [ ] Payoff diagram for a user-built multi-leg spread +- [ ] **"Insufficient history" state rendered honestly** where IV rank is unavailable +- [ ] Loading and error states — no infinite spinners +- [ ] vitest on the payoff math and any client-side calc + +### M6 — Docker + Linux deploy *(bonus, closes the Linux gap)* +- [ ] Multi-stage Dockerfile, `docker-compose` for api + web +- [ ] Deploy to a cheap VPS or EC2 behind nginx +- [ ] `cron` or systemd timer running the daily snapshot +- [ ] README documents the deploy so it is reproducible + +### M7 — Polish *(bonus)* +- [ ] Earnings-date proximity flag +- [ ] Simple screener (high IV rank, unusual volume/OI) +- [ ] Export to CSV + +--- + +## 6. Suggested tree + +``` +options-analytics/ +├─ SPEC.md README.md LICENSE +├─ .github/workflows/ci.yml +├─ docker-compose.yml +├─ api/ +│ ├─ pyproject.toml +│ ├─ options_tool/ +│ │ ├─ providers/{base,yfinance_provider}.py +│ │ ├─ analytics/{black_scholes,implied_vol,iv_rank,payoff}.py +│ │ ├─ db/{models,session,snapshot}.py +│ │ ├─ api/{main,routes,schemas}.py +│ │ └─ cli.py +│ └─ tests/{test_black_scholes,test_implied_vol,test_iv_rank,test_snapshot,test_api}.py +└─ web/ # Next.js + TS +``` + +--- + +## 7. The README is part of the deliverable + +A recruiter reads the README, not the source. It must contain: + +1. **A screenshot or GIF** at the top — first thing seen +2. **What it does and why I built it** — one paragraph, first person, mentions that I trade options + and wanted the tool +3. **The IV-history design** (§3) — the strongest engineering idea here; explain the snapshot + approach and why the data isn't otherwise obtainable +4. **Why I compute greeks instead of reading vendor columns** (§4) +5. **An honest Limitations section** — Black-Scholes vs American exercise, yfinance being an + unofficial and breakable source, minimum history before IV rank means anything +6. **Setup that actually works from a clean clone** — and test it from a clean clone +7. **CI badge and coverage number** + +Section 5 is not a weakness. Stating known limitations precisely is the single clearest signal of +engineering maturity a student repo can send. + +--- + +## 8. Definition of done (M1-M4) + +- `git clone` → documented setup → `pytest` passes **offline** +- CI green on a PR +- `python -m options_tool chain SPY` prints a chain with self-computed greeks +- `snapshot` run twice in a day creates no duplicates +- IV rank returns `null` with a reason, not a fake number, before minimum history +- README has all seven items from §7 + +--- + +## 9. Guardrails + +- **Not investment advice.** One line in the README and in the UI footer. A personal analytics tool. +- **No claim of profitability, returns, or backtested edge.** Nothing in this repo should imply a + trading track record. On the resume: retail, self-directed, analytical — never professional. +- **No paid-feed data redistribution.** yfinance is unofficial; don't republish its data or present + the tool as a market-data service. +- **No secrets committed.** API keys via `.env`, `.env.example` committed, `.env` gitignored. + +--- + +## 10. Resume bullets this earns (once M1-M4 ship) + +Write these only when true: + +- Built an options analytics platform in Python/Pandas computing Black-Scholes greeks and + implied volatility from live chain data, with a daily snapshot pipeline persisting IV history + to SQLite for IV-rank analysis +- Achieved N% test coverage on the analytics core with a fully offline pytest suite; automated + tests, lint and type-checking with GitHub Actions on every push +- Designed a provider abstraction isolating market-data vendors, so the source can be swapped + without touching analytics or API layers + +The first bullet alone answers "do you have data experience?", "why finance?", and "can you build +something real?" in one line — which is why this project is ranked first in `upskill/PLAN.md`. diff --git a/api/options_tool/__init__.py b/api/options_tool/__init__.py new file mode 100644 index 0000000..c59211e --- /dev/null +++ b/api/options_tool/__init__.py @@ -0,0 +1,4 @@ +"""Personal options analytics: self-computed greeks, implied vol, and a locally +accumulated IV history. Not investment advice.""" + +__version__ = "0.1.0" diff --git a/api/options_tool/__main__.py b/api/options_tool/__main__.py new file mode 100644 index 0000000..0a0adeb --- /dev/null +++ b/api/options_tool/__main__.py @@ -0,0 +1,8 @@ +"""Enables `python -m options_tool`.""" + +import sys + +from options_tool.cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/options_tool/analytics/__init__.py b/api/options_tool/analytics/__init__.py new file mode 100644 index 0000000..ca2e85e --- /dev/null +++ b/api/options_tool/analytics/__init__.py @@ -0,0 +1 @@ +"""Pricing, implied volatility, IV rank, payoff and screening. No I/O lives here.""" diff --git a/api/options_tool/analytics/black_scholes.py b/api/options_tool/analytics/black_scholes.py new file mode 100644 index 0000000..ee006ce --- /dev/null +++ b/api/options_tool/analytics/black_scholes.py @@ -0,0 +1,246 @@ +"""Black-Scholes-Merton pricing and analytic greeks. + +The vendor ships a `delta` column. We do not read it. Everything here is derived +from the model so the assumptions are visible and testable, which is the whole +point of the project (SPEC.md section 4). + +Conventions +----------- +All inputs are in natural units: `sigma` and `r` are annualised decimals (0.25 = +25%), `T` is in years. Greeks are returned **raw** -- per 1.00 of the underlying +variable, per year for theta. Trading desks quote them scaled, so `Greeks` +exposes `vega_per_point`, `theta_per_day` and `rho_per_point` for display. Tests +check the raw values against finite differences of `price`, which only works +because the raw form is the true derivative. + +No scipy: the normal CDF comes from `math.erf`, and chains are small enough +(hundreds to a few thousand rows) that scalar Python is milliseconds. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import StrEnum + +__all__ = ["Right", "Greeks", "price", "greeks", "d1_d2", "norm_cdf", "norm_pdf"] + +_SQRT_2 = math.sqrt(2.0) +_INV_SQRT_2PI = 1.0 / math.sqrt(2.0 * math.pi) + +# Below this, time or volatility is treated as zero rather than divided by. +_EPS = 1e-12 + +DAYS_PER_YEAR = 365.0 +"""Theta is divided by this for per-day display. Calendar days, not trading days: +an option decays over weekends too.""" + + +class Right(StrEnum): + """Option right. A StrEnum so it serialises and compares as 'call'/'put'.""" + + CALL = "call" + PUT = "put" + + @classmethod + def parse(cls, value: str | Right) -> Right: + """Accept 'c', 'C', 'call', 'CALL', Right.CALL ... and reject the rest.""" + if isinstance(value, cls): + return value + text = str(value).strip().lower() + if text in {"c", "call", "calls"}: + return cls.CALL + if text in {"p", "put", "puts"}: + return cls.PUT + raise ValueError(f"not an option right: {value!r}") + + +@dataclass(frozen=True, slots=True) +class Greeks: + """Raw (unscaled) greeks. See module docstring for the convention.""" + + delta: float + gamma: float + vega: float + theta: float + rho: float + + @property + def vega_per_point(self) -> float: + """P/L for a 1 percentage-point move in implied vol.""" + return self.vega / 100.0 + + @property + def theta_per_day(self) -> float: + """P/L for one calendar day of decay, all else equal.""" + return self.theta / DAYS_PER_YEAR + + @property + def rho_per_point(self) -> float: + """P/L for a 1 percentage-point move in the risk-free rate.""" + return self.rho / 100.0 + + +def norm_cdf(x: float) -> float: + """Standard normal CDF via the error function.""" + return 0.5 * (1.0 + math.erf(x / _SQRT_2)) + + +def norm_pdf(x: float) -> float: + """Standard normal PDF.""" + return _INV_SQRT_2PI * math.exp(-0.5 * x * x) + + +def _validate(S: float, K: float, T: float, sigma: float) -> None: + """Reject inputs that are wrong, as opposed to merely degenerate. + + A zero spot or strike is a data error and must surface loudly. Expired or + zero-vol contracts are legitimate market states and are handled in-band. + """ + if not math.isfinite(S) or S <= 0.0: + raise ValueError(f"spot must be finite and positive, got {S!r}") + if not math.isfinite(K) or K <= 0.0: + raise ValueError(f"strike must be finite and positive, got {K!r}") + if not math.isfinite(T): + raise ValueError(f"time to expiry must be finite, got {T!r}") + if not math.isfinite(sigma): + raise ValueError(f"sigma must be finite, got {sigma!r}") + + +def d1_d2( + S: float, K: float, T: float, r: float, sigma: float, q: float = 0.0 +) -> tuple[float, float]: + """The two Black-Scholes moneyness terms. + + Only defined for T > 0 and sigma > 0; callers must handle the degenerate + branches before reaching here. + """ + vol_sqrt_t = sigma * math.sqrt(T) + d1 = (math.log(S / K) + (r - q + 0.5 * sigma * sigma) * T) / vol_sqrt_t + return d1, d1 - vol_sqrt_t + + +def _forward_intrinsic(S: float, K: float, T: float, r: float, q: float, right: Right) -> float: + """Discounted intrinsic value of the forward. + + This is the correct price in both degenerate branches: at T = 0 the discount + factors are 1 and it collapses to plain intrinsic value; at sigma = 0 the + underlying arrives at its forward with certainty. + """ + carried_spot = S * math.exp(-q * T) + discounted_strike = K * math.exp(-r * T) + if right is Right.CALL: + return max(carried_spot - discounted_strike, 0.0) + return max(discounted_strike - carried_spot, 0.0) + + +def price( + S: float, + K: float, + T: float, + r: float, + sigma: float, + q: float = 0.0, + right: Right | str = Right.CALL, +) -> float: + """Black-Scholes-Merton price of a European option. + + Parameters + ---------- + S : spot price of the underlying + K : strike + T : time to expiry in years + r : annualised continuously-compounded risk-free rate + sigma : annualised volatility + q : continuous dividend yield + right : 'call' or 'put' + """ + right = Right.parse(right) + _validate(S, K, T, sigma) + + # Expired, or a zero-vol underlying: both collapse to discounted intrinsic. + if T <= _EPS or sigma <= _EPS: + return _forward_intrinsic(S, K, max(T, 0.0), r, q, right) + + d1, d2 = d1_d2(S, K, T, r, sigma, q) + carried_spot = S * math.exp(-q * T) + discounted_strike = K * math.exp(-r * T) + + if right is Right.CALL: + return carried_spot * norm_cdf(d1) - discounted_strike * norm_cdf(d2) + return discounted_strike * norm_cdf(-d2) - carried_spot * norm_cdf(-d1) + + +def greeks( + S: float, + K: float, + T: float, + r: float, + sigma: float, + q: float = 0.0, + right: Right | str = Right.CALL, +) -> Greeks: + """Analytic delta, gamma, vega, theta and rho. Raw units -- see module docstring.""" + right = Right.parse(right) + _validate(S, K, T, sigma) + + if T <= _EPS or sigma <= _EPS: + return _degenerate_greeks(S, K, max(T, 0.0), r, q, right) + + d1, d2 = d1_d2(S, K, T, r, sigma, q) + sqrt_t = math.sqrt(T) + carry = math.exp(-q * T) + discount = math.exp(-r * T) + pdf_d1 = norm_pdf(d1) + + # Identical for calls and puts. + gamma = carry * pdf_d1 / (S * sigma * sqrt_t) + vega = S * carry * pdf_d1 * sqrt_t + + # Shared first term of theta: the pure time-decay of optionality. + decay = -S * carry * pdf_d1 * sigma / (2.0 * sqrt_t) + + if right is Right.CALL: + delta = carry * norm_cdf(d1) + theta = decay - r * K * discount * norm_cdf(d2) + q * S * carry * norm_cdf(d1) + rho = K * T * discount * norm_cdf(d2) + else: + delta = -carry * norm_cdf(-d1) + theta = decay + r * K * discount * norm_cdf(-d2) - q * S * carry * norm_cdf(-d1) + rho = -K * T * discount * norm_cdf(-d2) + + return Greeks(delta=delta, gamma=gamma, vega=vega, theta=theta, rho=rho) + + +def _degenerate_greeks(S: float, K: float, T: float, r: float, q: float, right: Right) -> Greeks: + """Greeks where the distribution has collapsed to a point. + + With no time or no volatility the payoff is a step function: gamma and vega + vanish, delta is an indicator, and theta/rho are just the derivatives of the + discount factors on whichever side is in the money. Exactly at the money the + derivative does not exist; we report 0 delta rather than pick a side. + """ + forward = S * math.exp((r - q) * T) + in_the_money = forward > K if right is Right.CALL else forward < K + + if not in_the_money: + return Greeks(delta=0.0, gamma=0.0, vega=0.0, theta=0.0, rho=0.0) + + carry = math.exp(-q * T) + discount = math.exp(-r * T) + if right is Right.CALL: + # d/dS, d/dT and d/dr of (S e^-qT - K e^-rT) + return Greeks( + delta=carry, + gamma=0.0, + vega=0.0, + theta=q * S * carry - r * K * discount, + rho=K * T * discount, + ) + return Greeks( + delta=-carry, + gamma=0.0, + vega=0.0, + theta=r * K * discount - q * S * carry, + rho=-K * T * discount, + ) diff --git a/api/options_tool/analytics/chain.py b/api/options_tool/analytics/chain.py new file mode 100644 index 0000000..b809288 --- /dev/null +++ b/api/options_tool/analytics/chain.py @@ -0,0 +1,217 @@ +"""Turn a provider's option chain into a tidy, typed Pandas frame. + +One row per contract, every column named and typed, greeks and implied vol +computed here rather than read from the vendor. This frame is the single shape +that the CLI, the snapshot writer and the API all consume -- normalising once, +here, is what keeps vendor quirks from leaking into three places. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +from zoneinfo import ZoneInfo + +import pandas as pd + +from options_tool.analytics.black_scholes import Right, greeks +from options_tool.analytics.implied_vol import IVStatus, solve_implied_vol +from options_tool.providers.base import OptionChain, OptionQuote + +__all__ = ["CHAIN_COLUMNS", "build_chain_frame", "time_to_expiry", "summarise_chain"] + +# US equity options stop trading at 16:00 New York time on the expiry date. +_MARKET_CLOSE = (16, 0) +_MARKET_TZ = ZoneInfo("America/New_York") +_SECONDS_PER_YEAR = 365.0 * 24 * 60 * 60 + +CHAIN_COLUMNS: dict[str, str] = { + "ticker": "string", + "expiry": "datetime64[ns]", + "right": "string", + "strike": "float64", + "spot": "float64", + "bid": "float64", + "ask": "float64", + "last": "float64", + "mid": "float64", + "price": "float64", + "price_source": "string", + "spread": "float64", + "volume": "Int64", + "open_interest": "Int64", + "time_to_expiry": "float64", + "moneyness": "float64", + "in_the_money": "boolean", + "iv": "float64", + "iv_status": "string", + "iv_method": "string", + "delta": "float64", + "gamma": "float64", + "vega": "float64", + "theta": "float64", + "rho": "float64", +} +"""The contract of this module. Order is the display order; dtypes are enforced. + +`Int64`/`boolean` (capitalised) are the nullable pandas dtypes -- an option with +no reported volume must stay null, not silently become 0, because 0 volume is a +real and different observation. +""" + + +def time_to_expiry(expiry: date, as_of: datetime | None = None) -> float: + """Years until the 16:00 New York close on `expiry`. Never negative. + + Calendar time, not trading time: an option decays over a weekend. Using whole + days instead would price every 0-DTE contract at exactly zero and throw away + the one day of the front expiry that traders care most about. + """ + as_of = as_of or datetime.now(UTC) + if as_of.tzinfo is None: + as_of = as_of.replace(tzinfo=UTC) + close = datetime(expiry.year, expiry.month, expiry.day, *_MARKET_CLOSE, tzinfo=_MARKET_TZ) + return max((close - as_of).total_seconds() / _SECONDS_PER_YEAR, 0.0) + + +def _resolve_price(quote: OptionQuote) -> tuple[float | None, str | None]: + """Pick the price to imply vol from, and say where it came from. + + Mid of a two-sided book first. A one-sided or crossed book falls back to the + last trade, flagged, because a stale print is materially weaker evidence than + a live market -- and the UI should be able to show which it was. + """ + mid = quote.mid + if mid is not None and mid > 0: + return mid, "mid" + if quote.last is not None and quote.last > 0: + return quote.last, "last" + return None, None + + +def build_chain_frame( + chain: OptionChain, + risk_free_rate: float, + dividend_yield: float = 0.0, + as_of: datetime | None = None, +) -> pd.DataFrame: + """Normalise `chain` into the frame described by `CHAIN_COLUMNS`. + + Rows whose implied vol could not be solved keep their quote data and carry a + null `iv` plus an `iv_status` saying why -- they are never dropped, because a + strike that cannot be priced is itself information (SPEC.md section 4). + """ + as_of = as_of or chain.as_of or datetime.now(UTC) + T = time_to_expiry(chain.expiry, as_of) + spot = chain.spot + + records = [] + for quote in chain.quotes: + right = Right.parse(quote.right) + price, price_source = _resolve_price(quote) + + if price is None: + iv_result = None + iv_status, iv_method = IVStatus.NO_QUOTE, "" + else: + iv_result = solve_implied_vol( + price, spot, quote.strike, T, risk_free_rate, dividend_yield, right + ) + iv_status, iv_method = iv_result.status, iv_result.method + + sigma = iv_result.sigma if iv_result is not None else None + # Greeks are only meaningful at the vol the market is actually pricing. + # Substituting a default sigma here would manufacture a delta for a + # contract that has no tradeable market -- exactly the fabrication this + # project exists to avoid. + g = ( + greeks(spot, quote.strike, T, risk_free_rate, sigma, dividend_yield, right) + if sigma is not None + else None + ) + + in_the_money = spot > quote.strike if right is Right.CALL else spot < quote.strike + spread = ( + quote.ask - quote.bid + if quote.bid is not None and quote.ask is not None and quote.ask >= quote.bid + else None + ) + + records.append( + { + "ticker": chain.ticker, + "expiry": pd.Timestamp(chain.expiry), + "right": right.value, + "strike": quote.strike, + "spot": spot, + "bid": quote.bid, + "ask": quote.ask, + "last": quote.last, + "mid": quote.mid, + "price": price, + "price_source": price_source, + "spread": spread, + "volume": quote.volume, + "open_interest": quote.open_interest, + "time_to_expiry": T, + "moneyness": spot / quote.strike, + "in_the_money": in_the_money, + "iv": sigma, + "iv_status": iv_status.value, + "iv_method": iv_method, + "delta": g.delta if g else None, + "gamma": g.gamma if g else None, + "vega": g.vega if g else None, + "theta": g.theta if g else None, + "rho": g.rho if g else None, + } + ) + + frame = pd.DataFrame.from_records(records, columns=list(CHAIN_COLUMNS)) + frame = frame.astype(CHAIN_COLUMNS) + frame = frame.sort_values(["right", "strike"], ignore_index=True) + # Attributes rather than columns: they describe the chain, not any one row. + frame.attrs.update( + { + "ticker": chain.ticker, + "expiry": chain.expiry, + "spot": spot, + "as_of": as_of, + "risk_free_rate": risk_free_rate, + "dividend_yield": dividend_yield, + "time_to_expiry": T, + } + ) + return frame + + +def summarise_chain(frame: pd.DataFrame) -> dict[str, object]: + """Headline numbers for a chain, including how much of it failed to solve. + + The solve rate is reported on purpose. A chain where a third of the strikes + have no implied vol is a chain you should not draw conclusions from, and + hiding that behind a tidy-looking table is how a tool starts lying. + """ + solved = frame["iv"].notna() + atm = frame.loc[solved].copy() + atm_iv = None + if not atm.empty: + # Nearest-to-the-money solved strike on each side, averaged: a cheap, + # transparent ATM vol. Not an interpolated surface -- and not claimed to be. + atm["distance"] = (atm["moneyness"] - 1.0).abs() + nearest = atm.sort_values("distance").groupby("right", observed=True).head(1) + atm_iv = float(nearest["iv"].mean()) + + failures = frame.loc[~solved, "iv_status"].value_counts().to_dict() + return { + "ticker": frame.attrs.get("ticker"), + "expiry": frame.attrs.get("expiry"), + "spot": frame.attrs.get("spot"), + "time_to_expiry": frame.attrs.get("time_to_expiry"), + "contracts": len(frame), + "solved": int(solved.sum()), + "solve_rate": float(solved.mean()) if len(frame) else 0.0, + "atm_iv": atm_iv, + "total_volume": int(frame["volume"].sum(skipna=True) or 0), + "total_open_interest": int(frame["open_interest"].sum(skipna=True) or 0), + "unsolved_reasons": {str(k): int(v) for k, v in failures.items()}, + } diff --git a/api/options_tool/analytics/implied_vol.py b/api/options_tool/analytics/implied_vol.py new file mode 100644 index 0000000..694720e --- /dev/null +++ b/api/options_tool/analytics/implied_vol.py @@ -0,0 +1,239 @@ +"""Implied volatility by numerical inversion of Black-Scholes. + +Newton-Raphson on vega, falling back to bisection when Newton misbehaves -- +which it does, routinely, on exactly the contracts a real chain is full of: +deep in- or out-of-the-money strikes where vega underflows and the update step +explodes. + +The contract of this module is that **it never returns a wrong number.** Every +failure mode returns `None` (or an `IVResult` whose `sigma` is `None` and whose +`status` says why). A chain row with no implied vol renders as blank; a chain row +with a silently fabricated one is a lie that propagates into IV rank, into the +snapshot history, and eventually into a trade. See SPEC.md section 4. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from options_tool.analytics.black_scholes import Right, greeks, price + +__all__ = ["IVStatus", "IVResult", "implied_vol", "solve_implied_vol"] + +# Search bracket. 500% annualised is past anything a listed equity option trades +# at; below 0.01% the price is indistinguishable from intrinsic in float64. +MIN_SIGMA = 1e-4 +MAX_SIGMA = 5.0 + +NEWTON_MAX_ITER = 50 +BISECTION_MAX_ITER = 200 +# Convergence in price space, in dollars. Tighter than any real quote increment. +PRICE_TOLERANCE = 1e-8 +# Vega below this makes the Newton step numerically meaningless. +MIN_VEGA = 1e-8 +# Bisection also stops once the sigma bracket itself is this narrow. Without it, +# a deep-wing contract worth a fraction of a cent satisfies an absolute price +# tolerance while its sigma is still wrong in the fourth decimal -- the price is +# simply flat in sigma out there. +SIGMA_TOLERANCE = 1e-9 + + +def _price_tolerance(target_price: float) -> float: + """Scale the price tolerance to the quote, with a floor at float64 noise.""" + return max(abs(target_price) * 1e-10, 1e-13) + + +class IVStatus(StrEnum): + """Why a solve ended the way it did. Carried into the chain for diagnostics.""" + + OK = "ok" + EXPIRED = "expired" + NO_QUOTE = "no_quote" + NON_POSITIVE_PRICE = "non_positive_price" + BELOW_INTRINSIC = "below_intrinsic" + ABOVE_MAX = "above_max" + NO_BRACKET = "no_bracket" + NOT_CONVERGED = "not_converged" + INVALID_INPUT = "invalid_input" + + @property + def explanation(self) -> str: + """Why no implied vol was produced, in words a UI can show verbatim.""" + return _EXPLANATIONS[self] + + +_EXPLANATIONS: dict[IVStatus, str] = { + IVStatus.OK: "solved", + IVStatus.EXPIRED: "contract has expired; its price implies no future volatility", + IVStatus.NO_QUOTE: "no usable bid/ask or last price", + IVStatus.NON_POSITIVE_PRICE: "quoted price is zero or negative", + IVStatus.BELOW_INTRINSIC: "quoted price is below intrinsic value; quote is stale or crossed", + IVStatus.ABOVE_MAX: "quoted price exceeds the no-arbitrage maximum", + IVStatus.NO_BRACKET: f"no volatility in [{MIN_SIGMA:g}, {MAX_SIGMA:g}] reproduces this price", + IVStatus.NOT_CONVERGED: "solver did not converge", + IVStatus.INVALID_INPUT: "inputs are not finite or not positive", +} + + +@dataclass(frozen=True, slots=True) +class IVResult: + """`sigma is None` whenever `status is not IVStatus.OK`. Never both.""" + + sigma: float | None + status: IVStatus + iterations: int = 0 + method: str = "" + + @property + def ok(self) -> bool: + return self.status is IVStatus.OK + + +def _arbitrage_bounds( + S: float, K: float, T: float, r: float, q: float, right: Right +) -> tuple[float, float]: + """(lower, upper) no-arbitrage price bounds for a European option. + + Outside these there is no sigma that reproduces the price, so the honest + answer is that the quote is stale or crossed, not some best-effort number. + """ + carried_spot = S * math.exp(-q * T) + discounted_strike = K * math.exp(-r * T) + if right is Right.CALL: + return max(carried_spot - discounted_strike, 0.0), carried_spot + return max(discounted_strike - carried_spot, 0.0), discounted_strike + + +def _initial_guess(target: float, S: float, T: float, r: float, q: float) -> float: + """Brenner-Subrahmanyam at-the-money approximation, clamped to the bracket. + + sigma ~= sqrt(2*pi/T) * C/S is exact for an ATM forward and a decent starting + point elsewhere; Newton fixes the rest in a handful of steps. + """ + forward = S * math.exp((r - q) * T) + seed = math.sqrt(2.0 * math.pi / T) * target / forward if forward > 0 else 0.5 + return min(max(seed, 0.05), 2.0) + + +def solve_implied_vol( + target_price: float, + S: float, + K: float, + T: float, + r: float, + q: float = 0.0, + right: Right | str = Right.CALL, +) -> IVResult: + """Solve for the sigma that reproduces `target_price`, with a reason on failure.""" + try: + right = Right.parse(right) + except ValueError: + return IVResult(None, IVStatus.INVALID_INPUT) + + if not all(math.isfinite(x) for x in (target_price, S, K, T, r, q)): + return IVResult(None, IVStatus.INVALID_INPUT) + if S <= 0.0 or K <= 0.0: + return IVResult(None, IVStatus.INVALID_INPUT) + + # An expired option has no implied vol -- its price carries no information + # about future variance. This is also the zero-bid case for weeklies on + # expiry day, which is why it gets its own status. + if T <= 0.0: + return IVResult(None, IVStatus.EXPIRED) + if target_price <= 0.0: + return IVResult(None, IVStatus.NON_POSITIVE_PRICE) + + lower, upper = _arbitrage_bounds(S, K, T, r, q, right) + # Strictly inside the bounds: at the boundary sigma is 0 or infinite, and in + # float64 the interior solve degenerates well before you get there. + if target_price <= lower + PRICE_TOLERANCE: + return IVResult(None, IVStatus.BELOW_INTRINSIC) + if target_price >= upper - PRICE_TOLERANCE: + return IVResult(None, IVStatus.ABOVE_MAX) + + tolerance = _price_tolerance(target_price) + + def error_at(sigma: float) -> float: + return price(S, K, T, r, sigma, q, right) - target_price + + newton = _newton(error_at, S, K, T, r, q, right, target_price, tolerance) + if newton is not None: + sigma, iterations = newton + return IVResult(sigma, IVStatus.OK, iterations, "newton") + + return _bisect(error_at, tolerance) + + +def _newton( + error_at: Callable[[float], float], + S: float, + K: float, + T: float, + r: float, + q: float, + right: Right, + target: float, + tolerance: float, +) -> tuple[float, int] | None: + """Newton-Raphson on vega. Returns None to hand over to bisection.""" + sigma = _initial_guess(target, S, T, r, q) + + for iteration in range(1, NEWTON_MAX_ITER + 1): + err = error_at(sigma) + if abs(err) < tolerance: + return sigma, iteration + + vega = greeks(S, K, T, r, sigma, q, right).vega + # Deep ITM/OTM: the price is flat in sigma, so the Newton step is either + # meaningless or enormous. Bisection handles these without diverging. + if vega < MIN_VEGA: + return None + + sigma -= err / vega + if not math.isfinite(sigma) or sigma <= MIN_SIGMA or sigma >= MAX_SIGMA: + return None + + return None + + +def _bisect(error_at: Callable[[float], float], tolerance: float) -> IVResult: + """Bisection over the full sigma bracket. Slow, but it cannot diverge.""" + lo, hi = MIN_SIGMA, MAX_SIGMA + err_lo, err_hi = error_at(lo), error_at(hi) + + if abs(err_lo) < tolerance: + return IVResult(lo, IVStatus.OK, 0, "bisection") + if abs(err_hi) < tolerance: + return IVResult(hi, IVStatus.OK, 0, "bisection") + # The price is monotonic in sigma, so no sign change means the target simply + # is not attainable anywhere in [MIN_SIGMA, MAX_SIGMA]. + if err_lo * err_hi > 0.0: + return IVResult(None, IVStatus.NO_BRACKET) + + for iteration in range(1, BISECTION_MAX_ITER + 1): + mid = 0.5 * (lo + hi) + err_mid = error_at(mid) + if abs(err_mid) < tolerance or (hi - lo) < SIGMA_TOLERANCE: + return IVResult(mid, IVStatus.OK, iteration, "bisection") + if err_lo * err_mid < 0.0: + hi, err_hi = mid, err_mid + else: + lo, err_lo = mid, err_mid + + return IVResult(None, IVStatus.NOT_CONVERGED, BISECTION_MAX_ITER, "bisection") + + +def implied_vol( + target_price: float, + S: float, + K: float, + T: float, + r: float, + q: float = 0.0, + right: Right | str = Right.CALL, +) -> float | None: + """`solve_implied_vol` reduced to the number, or None. Never a wrong number.""" + return solve_implied_vol(target_price, S, K, T, r, q, right).sigma diff --git a/api/options_tool/api/__init__.py b/api/options_tool/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/options_tool/cli.py b/api/options_tool/cli.py new file mode 100644 index 0000000..18a3f81 --- /dev/null +++ b/api/options_tool/cli.py @@ -0,0 +1,273 @@ +"""Command-line entry point. + + python -m options_tool chain AAPL --expiry 2027-01-15 + +Every command takes `--provider` so the whole tool can be driven offline from +the checked-in fixture, which is how the demo stays reproducible when Yahoo's +unofficial endpoint is throttling. +""" + +from __future__ import annotations + +import argparse +import logging +import sys +from datetime import date +from pathlib import Path +from typing import Any + +from options_tool import __version__ +from options_tool.providers.base import MarketDataProvider + +__all__ = ["main", "build_parser"] + +logger = logging.getLogger("options_tool") + +DISCLAIMER = "Not investment advice. Personal analytics only." + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="options-tool", + description=f"Options analytics with self-computed greeks. {DISCLAIMER}", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--version", action="version", version=f"options-tool {__version__}") + parser.add_argument( + "--provider", + choices=("yfinance", "fixture"), + default=None, + help="market-data source (default: OPTIONS_PROVIDER, else yfinance)", + ) + parser.add_argument("-v", "--verbose", action="store_true", help="log provider activity") + + sub = parser.add_subparsers(dest="command", required=True) + + p_quote = sub.add_parser("quote", help="spot quote for a ticker") + p_quote.add_argument("ticker") + + p_expiries = sub.add_parser("expiries", help="list listed option expiries") + p_expiries.add_argument("ticker") + + p_chain = sub.add_parser("chain", help="option chain with self-computed greeks") + p_chain.add_argument("ticker") + p_chain.add_argument( + "--expiry", type=date.fromisoformat, default=None, help="YYYY-MM-DD (default: nearest)" + ) + p_chain.add_argument( + "--right", choices=("call", "put", "both"), default="both", help="filter by right" + ) + p_chain.add_argument( + "--near-the-money", + type=float, + default=None, + metavar="PCT", + help="only strikes within PCT%% of spot (e.g. 10)", + ) + p_chain.add_argument("--csv", type=Path, default=None, help="also write the frame to CSV") + + p_capture = sub.add_parser( + "capture-fixture", help="refresh the offline test/demo fixture from the live vendor" + ) + p_capture.add_argument("tickers", nargs="+") + p_capture.add_argument("--out", type=Path, default=None) + p_capture.add_argument("--expiries", type=int, default=3) + + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + logging.basicConfig( + level=logging.INFO if args.verbose else logging.WARNING, + format="%(levelname)s %(name)s: %(message)s", + ) + + # Imported lazily so `--help` and `--version` stay instant and import-light. + from options_tool.providers import ProviderError, get_provider # noqa: PLC0415 + + try: + provider = get_provider(args.provider) + handlers = { + "quote": _cmd_quote, + "expiries": _cmd_expiries, + "chain": _cmd_chain, + "capture-fixture": _cmd_capture, + } + return handlers[args.command](args, provider) + except ProviderError as exc: + # A vendor failure is an expected outcome, not a crash. Say what happened + # and what to do about it; no traceback. + print(f"error: {exc}", file=sys.stderr) + return 2 + except (FileNotFoundError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + except KeyboardInterrupt: + return 130 + + +# -------------------------------------------------------------------------- +# commands +# -------------------------------------------------------------------------- + + +def _cmd_quote(args: argparse.Namespace, provider: MarketDataProvider) -> int: + quote = provider.get_quote(args.ticker) + print( + f"{quote.ticker} {quote.price:,.2f} {quote.currency} " + f"as of {quote.as_of:%Y-%m-%d %H:%M %Z}" + ) + if quote.previous_close: + change = quote.price - quote.previous_close + print( + f" prev close {quote.previous_close:,.2f} " + f"change {change:+,.2f} ({change / quote.previous_close:+.2%})" + ) + return 0 + + +def _cmd_expiries(args: argparse.Namespace, provider: MarketDataProvider) -> int: + from options_tool.analytics.chain import time_to_expiry # noqa: PLC0415 + + expiries = provider.get_expiries(args.ticker) + print(f"{args.ticker.upper()}: {len(expiries)} listed expiries") + for expiry in expiries: + days = time_to_expiry(expiry) * 365 + print(f" {expiry} ({days:6.1f} days)") + return 0 + + +def _cmd_chain(args: argparse.Namespace, provider: MarketDataProvider) -> int: + from rich.console import Console # noqa: PLC0415 + from rich.table import Table # noqa: PLC0415 + + from options_tool.analytics.chain import build_chain_frame, summarise_chain # noqa: PLC0415 + from options_tool.config import get_settings # noqa: PLC0415 + from options_tool.providers.rates import resolve_risk_free_rate # noqa: PLC0415 + + settings = get_settings() + rate = resolve_risk_free_rate(settings) + + expiry = provider.resolve_expiry(args.ticker, args.expiry) + chain = provider.get_chain(args.ticker, expiry) + frame = build_chain_frame(chain, rate, settings.options_dividend_yield, chain.as_of) + + if args.right != "both": + frame = frame[frame["right"] == args.right] + if args.near_the_money is not None: + band = args.near_the_money / 100.0 + frame = frame[(frame["moneyness"] - 1.0).abs() <= band] + + if args.csv: + args.csv.parent.mkdir(parents=True, exist_ok=True) + frame.to_csv(args.csv, index=False) + + summary = summarise_chain(frame) + # Rich falls back to 80 columns when stdout is not a terminal, which silently + # ellipsises every number in this table when piped to a file or a reviewer. + console = Console(width=None if sys.stdout.isatty() else 150) + + table = Table( + title=( + f"{chain.ticker} {expiry} spot {chain.spot:,.2f} " + f"T {summary['time_to_expiry']:.4f}y r {rate:.2%}" + ), + caption=( + f"{summary['solved']}/{summary['contracts']} strikes solved " + f"({summary['solve_rate']:.0%})" + + (f" · ATM IV {summary['atm_iv']:.1%}" if summary["atm_iv"] else "") + + f" · greeks computed locally, not vendor-supplied · {DISCLAIMER}" + ), + ) + columns = ( + "right", + "strike", + "bid", + "ask", + "mid", + "src", + "IV", + "delta", + "gamma", + "vega", + "th/day", + "OI", + ) + for column in columns: + table.add_column( + column, + justify="left" if column in ("right", "src") else "right", + no_wrap=True, + overflow="fold", + ) + + # `to_dict("records")` rather than `itertuples`: the pandas stubs type tuple + # fields as a union of every possible scalar, which makes arithmetic on a + # column that is plainly a float unrepresentable to the type checker. + for row in frame.to_dict("records"): + theta = row["theta"] + table.add_row( + str(row["right"]), + f"{row['strike']:,.2f}", + _num(row["bid"]), + _num(row["ask"]), + _num(row["mid"]), + str(row["price_source"] or "-"), + f"{row['iv']:.1%}" if _present(row["iv"]) else _dash(str(row["iv_status"])), + _num(row["delta"], 4), + _num(row["gamma"], 5), + _num(row["vega"], 3), + _num(theta / 365 if _present(theta) else None, 4), + f"{row['open_interest']:,}" if _present(row["open_interest"]) else "-", + style=None if _present(row["iv"]) else "dim", + ) + + console.print(table) + if summary["unsolved_reasons"]: + console.print(f"[dim]unsolved: {summary['unsolved_reasons']}[/dim]") + if args.csv: + console.print(f"[dim]wrote {args.csv}[/dim]") + return 0 + + +def _cmd_capture(args: argparse.Namespace, provider: MarketDataProvider) -> int: + from options_tool.providers.capture import capture_fixture # noqa: PLC0415 + from options_tool.providers.fixture_provider import DEFAULT_FIXTURE_PATH # noqa: PLC0415 + + out = args.out or DEFAULT_FIXTURE_PATH + counts = capture_fixture(provider, args.tickers, out, args.expiries) + total = sum(counts.values()) + print(f"captured {total} contracts to {out}") + for ticker, count in sorted(counts.items()): + print(f" {ticker}: {count}") + return 0 + + +# -------------------------------------------------------------------------- + + +def _present(value: Any) -> bool: + """True unless the value is missing -- None, NaN or pandas NA alike.""" + import pandas as pd # noqa: PLC0415 -- keeps `--help` free of the pandas import + + return value is not None and not bool(pd.isna(value)) + + +def _num(value: Any, places: int = 2) -> str: + return f"{value:,.{places}f}" if _present(value) else "-" + + +def _dash(status: str) -> str: + """Show *why* a strike has no IV rather than an unexplained blank.""" + return { + "no_quote": "[dim]no quote[/dim]", + "below_intrinsic": "[dim]stale[/dim]", + "above_max": "[dim]crossed[/dim]", + "expired": "[dim]expired[/dim]", + "non_positive_price": "[dim]no bid[/dim]", + }.get(status, "[dim]-[/dim]") + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/api/options_tool/config.py b/api/options_tool/config.py new file mode 100644 index 0000000..cf28d11 --- /dev/null +++ b/api/options_tool/config.py @@ -0,0 +1,98 @@ +"""Runtime settings, read from the environment (and `.env` when present). + +Every pricing assumption lives here rather than being buried as a literal in the +maths, because the README has to state them honestly and a number you can't find +is a number you can't document. See README "Limitations". +""" + +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +__all__ = ["Settings", "get_settings"] + +# Repo-root-relative default so a clean clone works with no configuration. +_API_DIR = Path(__file__).resolve().parent.parent + + +class Settings(BaseSettings): + """Configuration for analytics, storage and the market-data provider.""" + + model_config = SettingsConfigDict( + env_file=(".env", str(_API_DIR / ".env"), str(_API_DIR.parent / ".env")), + env_file_encoding="utf-8", + extra="ignore", + ) + + # --- Market data --------------------------------------------------------- + options_provider: str = Field( + default="yfinance", + description="Default market-data provider: 'yfinance' or 'fixture'.", + ) + polygon_api_key: str | None = None + tradier_api_key: str | None = None + + # --- Pricing assumptions ------------------------------------------------- + options_risk_free_rate: float = Field( + default=0.04, + ge=-0.05, + le=0.50, + description="Annualised continuously-compounded risk-free rate.", + ) + options_fetch_risk_free_rate: bool = Field( + default=False, + description="Fetch the 13-week T-bill (^IRX) instead of using the constant.", + ) + options_dividend_yield: float = Field( + default=0.0, + ge=0.0, + le=0.50, + description="Continuous dividend yield assumed for every underlying.", + ) + + # --- IV rank ------------------------------------------------------------- + options_iv_window_days: int = Field( + default=252, + ge=2, + description="Trailing window for IV rank and percentile, in stored days.", + ) + options_min_history_days: int = Field( + default=20, + ge=1, + description=( + "Stored days required before an IV rank is reported at all. Below " + "this the API returns null with a reason -- never a fabricated zero." + ), + ) + + # --- Storage ------------------------------------------------------------- + options_database_url: str = Field(default="sqlite:///./data/options.db") + + @field_validator("options_provider") + @classmethod + def _known_provider(cls, value: str) -> str: + normalised = value.strip().lower() + if normalised not in {"yfinance", "fixture"}: + raise ValueError(f"unknown provider {value!r}; expected 'yfinance' or 'fixture'") + return normalised + + @field_validator("options_min_history_days") + @classmethod + def _history_fits_window(cls, value: int, info) -> int: # type: ignore[no-untyped-def] + window = info.data.get("options_iv_window_days") + if window is not None and value > window: + raise ValueError( + f"min_history_days ({value}) exceeds iv_window_days ({window}); " + "the rank could never be reported" + ) + return value + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + """Process-wide settings. Cached; call `get_settings.cache_clear()` in tests.""" + return Settings() diff --git a/api/options_tool/db/__init__.py b/api/options_tool/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/options_tool/providers/__init__.py b/api/options_tool/providers/__init__.py new file mode 100644 index 0000000..d782c8c --- /dev/null +++ b/api/options_tool/providers/__init__.py @@ -0,0 +1,50 @@ +"""Market-data providers and the factory that picks one. + +Import providers from here rather than reaching into the submodules, so the +lazy-import discipline in `yfinance_provider` keeps working. +""" + +from __future__ import annotations + +from options_tool.providers.base import ( + MarketDataProvider, + OptionChain, + OptionQuote, + ProviderError, + Quote, + RateLimitedError, + UnknownTickerError, +) + +__all__ = [ + "MarketDataProvider", + "OptionChain", + "OptionQuote", + "ProviderError", + "Quote", + "RateLimitedError", + "UnknownTickerError", + "get_provider", +] + + +def get_provider(name: str | None = None) -> MarketDataProvider: + """Build the named provider, defaulting to the configured one. + + Swapping vendors is this function plus one new module -- nothing in + `analytics/`, `db/` or `api/` refers to a vendor by name. + """ + from options_tool.config import get_settings # noqa: PLC0415 -- avoids an import cycle + + resolved = (name or get_settings().options_provider).strip().lower() + + if resolved == "yfinance": + from options_tool.providers.yfinance_provider import YFinanceProvider # noqa: PLC0415 + + return YFinanceProvider() + if resolved == "fixture": + from options_tool.providers.fixture_provider import FixtureProvider # noqa: PLC0415 + + return FixtureProvider() + + raise ValueError(f"unknown provider {resolved!r}; expected 'yfinance' or 'fixture'") diff --git a/api/options_tool/providers/base.py b/api/options_tool/providers/base.py new file mode 100644 index 0000000..79b0505 --- /dev/null +++ b/api/options_tool/providers/base.py @@ -0,0 +1,135 @@ +"""The market-data boundary. + +Everything downstream -- analytics, storage, API, UI -- talks to these +dataclasses and never to a vendor object. That is the whole contract: swapping +yfinance for Polygon or Tradier means writing one new file in this package and +changing one setting, and nothing in `analytics/` or `api/` notices. + +It also keeps the vendor's own `impliedVolatility` and greek columns from +leaking into the system by construction. `OptionQuote` has nowhere to put them, +so the only implied vol that can ever reach the database is the one solved for +in `analytics.implied_vol` (SPEC.md section 4). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import date, datetime + +__all__ = [ + "ProviderError", + "RateLimitedError", + "UnknownTickerError", + "Quote", + "OptionQuote", + "OptionChain", + "MarketDataProvider", +] + + +class ProviderError(RuntimeError): + """A market-data lookup failed. Always raised with a human-readable reason.""" + + +class RateLimitedError(ProviderError): + """The vendor throttled us. Distinct because it is retryable and transient.""" + + +class UnknownTickerError(ProviderError): + """The vendor has no such symbol. A 404, not a 503.""" + + +@dataclass(frozen=True, slots=True) +class Quote: + """A spot quote for an underlying.""" + + ticker: str + price: float + as_of: datetime + currency: str = "USD" + previous_close: float | None = None + + +@dataclass(frozen=True, slots=True) +class OptionQuote: + """One listed contract, as the vendor reports it. + + Prices only. No greeks and no implied volatility -- see the module docstring. + """ + + ticker: str + expiry: date + strike: float + right: str # "call" | "put" + bid: float | None = None + ask: float | None = None + last: float | None = None + volume: int | None = None + open_interest: int | None = None + contract_symbol: str | None = None + + @property + def mid(self) -> float | None: + """Mid of a two-sided book, else None. + + Deliberately strict: a one-sided or crossed book has no mid, and + substituting `last` here would quietly mix a stale print into a live + quote. `analytics.chain` handles that fallback explicitly and flags it. + """ + if self.bid is None or self.ask is None: + return None + if self.bid <= 0.0 or self.ask <= 0.0 or self.ask < self.bid: + return None + return (self.bid + self.ask) / 2.0 + + +@dataclass(frozen=True, slots=True) +class OptionChain: + """Every listed contract for one underlying and one expiry, plus its spot.""" + + ticker: str + expiry: date + spot: float + as_of: datetime + quotes: tuple[OptionQuote, ...] + + def __len__(self) -> int: + return len(self.quotes) + + +class MarketDataProvider(ABC): + """Interface every market-data source implements.""" + + name: str = "base" + + @abstractmethod + def get_quote(self, ticker: str) -> Quote: + """Spot quote for `ticker`. Raises UnknownTickerError if it does not exist.""" + + @abstractmethod + def get_expiries(self, ticker: str) -> tuple[date, ...]: + """Listed option expiries for `ticker`, ascending.""" + + @abstractmethod + def get_chain(self, ticker: str, expiry: date) -> OptionChain: + """Full option chain for one `ticker` and `expiry`.""" + + def resolve_expiry(self, ticker: str, expiry: date | None) -> date: + """Pick an expiry: the one given, or the nearest listed one if None. + + Shared by every provider so `--expiry` stays optional on the CLI without + each implementation reinventing the default. + """ + expiries = self.get_expiries(ticker) + if not expiries: + raise ProviderError(f"{ticker}: no listed option expiries") + if expiry is None: + return expiries[0] + if expiry not in expiries: + available = ", ".join(str(e) for e in expiries[:8]) + raise ProviderError( + f"{ticker}: no chain for expiry {expiry}. Available: {available}" + + (" ..." if len(expiries) > 8 else "") + ) + return expiry diff --git a/api/options_tool/providers/capture.py b/api/options_tool/providers/capture.py new file mode 100644 index 0000000..c8f58da --- /dev/null +++ b/api/options_tool/providers/capture.py @@ -0,0 +1,89 @@ +"""Capture live vendor chains into the offline fixture. + +Run occasionally to refresh `tests/fixtures/chains.json`. The fixture is checked +in deliberately: it is what makes `pytest` pass with no network and what makes +`--provider fixture` a working demo. Refreshing it is a conscious act with a +visible diff, not something that happens silently at test time. +""" + +from __future__ import annotations + +import json +from datetime import UTC, date, datetime +from pathlib import Path +from typing import Any + +from options_tool.providers.base import MarketDataProvider + +__all__ = ["capture_fixture"] + + +def capture_fixture( + provider: MarketDataProvider, + tickers: list[str], + path: Path, + expiries_per_ticker: int = 3, +) -> dict[str, int]: + """Write a fixture covering `tickers`. Returns contracts captured per ticker.""" + underlyings: dict[str, dict[str, Any]] = {} + counts: dict[str, int] = {} + + for raw_ticker in tickers: + ticker = raw_ticker.upper() + quote = provider.get_quote(ticker) + all_expiries = provider.get_expiries(ticker) + # Front, middle and back of the listed term structure, so the fixture + # exercises near-expiry (tiny T) and LEAPS (large T) code paths rather + # than three near-identical weeklies. + chosen = _spread_expiries(all_expiries, expiries_per_ticker) + + chains: dict[str, list[dict[str, Any]]] = {} + total = 0 + for expiry in chosen: + chain = provider.get_chain(ticker, expiry) + chains[expiry.isoformat()] = [ + { + "strike": q.strike, + "right": q.right, + "bid": q.bid, + "ask": q.ask, + "last": q.last, + "volume": q.volume, + "open_interest": q.open_interest, + "contract_symbol": q.contract_symbol, + } + for q in chain.quotes + ] + total += len(chain.quotes) + + underlyings[ticker] = { + "spot": quote.price, + "currency": quote.currency, + "previous_close": quote.previous_close, + "chains": chains, + } + counts[ticker] = total + + payload = { + "source": provider.name, + "captured_at": datetime.now(UTC).isoformat(), + "note": ( + "Real vendor capture, frozen for offline tests and the --provider fixture demo. " + "Prices are stale by construction and are not represented as live market data." + ), + "underlyings": underlyings, + } + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=1, sort_keys=True) + "\n") + return counts + + +def _spread_expiries(expiries: tuple[date, ...], count: int) -> list[date]: + """Pick `count` expiries spread across the term structure, not just the front.""" + if len(expiries) <= count: + return list(expiries) + if count == 1: + return [expiries[0]] + step = (len(expiries) - 1) / (count - 1) + return [expiries[round(i * step)] for i in range(count)] diff --git a/api/options_tool/providers/fixture_provider.py b/api/options_tool/providers/fixture_provider.py new file mode 100644 index 0000000..d8181d6 --- /dev/null +++ b/api/options_tool/providers/fixture_provider.py @@ -0,0 +1,115 @@ +"""A provider that replays a chain captured from a real vendor response. + +This exists for two reasons, and the second one matters more than it looks: + +1. **The test suite must pass with the network off** (SPEC.md section 8). Sockets + are disabled in `pytest` config, so every test that needs a chain gets one + from here. +2. **The demo has to work when Yahoo does not.** The unofficial endpoint + rate-limits unpredictably -- it did so while this project was being planned. + `--provider fixture` means a reviewer who clones the repo sees a working tool + on the first run instead of a stack trace from someone else's outage. + +The fixture is a real capture, not invented numbers. Its provenance is recorded +in the file's own `captured_at` / `source` fields, and the prices are stale by +construction -- that is fine, because nothing here claims to be live. +""" + +from __future__ import annotations + +import json +from datetime import date, datetime +from pathlib import Path +from typing import Any + +from options_tool.providers.base import ( + MarketDataProvider, + OptionChain, + OptionQuote, + Quote, + UnknownTickerError, +) + +__all__ = ["FixtureProvider", "DEFAULT_FIXTURE_PATH"] + +_PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent +DEFAULT_FIXTURE_PATH = _PACKAGE_ROOT / "tests" / "fixtures" / "chains.json" + + +class FixtureProvider(MarketDataProvider): + """Replays captured chains. Deterministic, offline, and honest about being stale.""" + + name = "fixture" + + def __init__(self, path: Path | None = None) -> None: + self.path = path or DEFAULT_FIXTURE_PATH + if not self.path.exists(): + raise FileNotFoundError( + f"fixture file not found: {self.path}. " + "Regenerate it with `python -m options_tool capture-fixture`." + ) + with self.path.open() as handle: + self._data = json.load(handle) + self._captured_at = datetime.fromisoformat(self._data["captured_at"]) + + @property + def captured_at(self) -> datetime: + """When this data was pulled from the vendor. Callers should surface it.""" + return self._captured_at + + def _underlying(self, ticker: str) -> dict[str, Any]: + symbol = ticker.upper() + try: + return self._data["underlyings"][symbol] # type: ignore[no-any-return] + except KeyError: + available = ", ".join(sorted(self._data["underlyings"])) + raise UnknownTickerError( + f"{symbol} is not in the fixture. Captured tickers: {available}" + ) from None + + def get_quote(self, ticker: str) -> Quote: + underlying = self._underlying(ticker) + return Quote( + ticker=ticker.upper(), + price=float(underlying["spot"]), + as_of=self._captured_at, + currency=underlying.get("currency", "USD"), + previous_close=underlying.get("previous_close"), + ) + + def get_expiries(self, ticker: str) -> tuple[date, ...]: + underlying = self._underlying(ticker) + return tuple(sorted(date.fromisoformat(key) for key in underlying["chains"])) + + def get_chain(self, ticker: str, expiry: date) -> OptionChain: + symbol = ticker.upper() + underlying = self._underlying(symbol) + rows = underlying["chains"].get(expiry.isoformat()) + if rows is None: + available = ", ".join(sorted(underlying["chains"])) + raise UnknownTickerError( + f"{symbol}: fixture has no chain for {expiry}. Captured expiries: {available}" + ) + + quotes = tuple( + OptionQuote( + ticker=symbol, + expiry=expiry, + strike=float(row["strike"]), + right=row["right"], + bid=row.get("bid"), + ask=row.get("ask"), + last=row.get("last"), + volume=row.get("volume"), + open_interest=row.get("open_interest"), + contract_symbol=row.get("contract_symbol"), + ) + for row in rows + ) + return OptionChain( + ticker=symbol, + expiry=expiry, + spot=float(underlying["spot"]), + as_of=self._captured_at, + quotes=quotes, + ) diff --git a/api/options_tool/providers/rates.py b/api/options_tool/providers/rates.py new file mode 100644 index 0000000..e9465b3 --- /dev/null +++ b/api/options_tool/providers/rates.py @@ -0,0 +1,54 @@ +"""Resolve the risk-free rate used by Black-Scholes. + +A constant by default, because a constant you can read in `.env.example` is +honest and reproducible. Optionally the live 13-week T-bill (^IRX), which is a +better short-dated proxy but makes every run non-deterministic -- so it is +opt-in, and a failed fetch falls back to the constant with a warning rather than +aborting the run or silently using zero. +""" + +from __future__ import annotations + +import logging + +from options_tool.config import Settings + +__all__ = ["resolve_risk_free_rate", "IRX_SYMBOL"] + +logger = logging.getLogger(__name__) + +IRX_SYMBOL = "^IRX" +"""13-week US Treasury bill discount rate, quoted by Yahoo in percent.""" + + +def resolve_risk_free_rate(settings: Settings) -> float: + """The annualised rate to price with, per the configured policy.""" + if not settings.options_fetch_risk_free_rate: + return settings.options_risk_free_rate + + try: + import yfinance # noqa: PLC0415 -- keeps the network out of module import + + value = yfinance.Ticker(IRX_SYMBOL).fast_info.get("lastPrice") + rate = float(value) / 100.0 + except Exception as exc: # noqa: BLE001 -- any failure here is non-fatal by design + logger.warning( + "could not fetch %s, falling back to the configured rate %.4f: %s", + IRX_SYMBOL, + settings.options_risk_free_rate, + exc, + ) + return settings.options_risk_free_rate + + # A T-bill outside this band means the quote is garbage, not that rates moved. + if not -0.01 <= rate <= 0.25: + logger.warning( + "%s returned an implausible rate (%.4f); using the configured %.4f instead", + IRX_SYMBOL, + rate, + settings.options_risk_free_rate, + ) + return settings.options_risk_free_rate + + logger.info("using live %s risk-free rate: %.4f", IRX_SYMBOL, rate) + return rate diff --git a/api/options_tool/providers/yfinance_provider.py b/api/options_tool/providers/yfinance_provider.py new file mode 100644 index 0000000..1eb4a5f --- /dev/null +++ b/api/options_tool/providers/yfinance_provider.py @@ -0,0 +1,220 @@ +"""Market data from Yahoo Finance via the `yfinance` package. + +This is an **unofficial scraper of a consumer website**, not a licensed feed. It +breaks without warning, it rate-limits, and its schema is whatever Yahoo shipped +this week. That is a deliberate, documented trade-off (free, no key, has chains) +and the reason `MarketDataProvider` exists at all -- see README "Limitations". + +Two things are load-bearing here: + +1. `yfinance` is imported **inside** the methods. The test suite runs with + sockets disabled and must never so much as import a networking library by + accident, and importing yfinance at module scope would drag it into every + `from options_tool.providers import ...` in the project. +2. The vendor's `impliedVolatility`, `inTheMoney` and greek columns are read and + then **dropped on the floor**. We compute those ourselves (SPEC.md section 4). +""" + +from __future__ import annotations + +import logging +import math +import random +import time +from datetime import UTC, date, datetime +from typing import Any + +from options_tool.providers.base import ( + MarketDataProvider, + OptionChain, + OptionQuote, + ProviderError, + Quote, + RateLimitedError, + UnknownTickerError, +) + +__all__ = ["YFinanceProvider"] + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_RETRIES = 3 +DEFAULT_BACKOFF_SECONDS = 1.5 + +# Substrings Yahoo puts in throttling errors. Matching on message text is ugly, +# but yfinance flattens HTTP status into generic exceptions, so there is nothing +# structured to match on. +_RATE_LIMIT_MARKERS = ("too many requests", "rate limit", "429", "invalid crumb", "unauthorized") + + +def _clean(value: Any) -> float | None: + """Yahoo uses NaN, None and 0.0 interchangeably for 'no quote'. Normalise.""" + if value is None: + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(number): + return None + return number + + +def _clean_int(value: Any) -> int | None: + number = _clean(value) + return None if number is None else int(number) + + +def _is_rate_limit(exc: Exception) -> bool: + message = str(exc).lower() + return any(marker in message for marker in _RATE_LIMIT_MARKERS) + + +class YFinanceProvider(MarketDataProvider): + """Yahoo Finance, with retries around its throttling.""" + + name = "yfinance" + + def __init__( + self, + max_retries: int = DEFAULT_MAX_RETRIES, + backoff_seconds: float = DEFAULT_BACKOFF_SECONDS, + ) -> None: + self.max_retries = max_retries + self.backoff_seconds = backoff_seconds + + # -- vendor plumbing ---------------------------------------------------- + + def _ticker(self, ticker: str) -> Any: + import yfinance # noqa: PLC0415 -- see module docstring + + return yfinance.Ticker(ticker.upper()) + + def _with_retries(self, what: str, fn: Any) -> Any: + """Run `fn`, retrying throttling with exponential backoff and jitter. + + Only rate limits are retried. A missing ticker or a schema change will + not fix itself by waiting, so those fail immediately with a real reason. + """ + last: Exception | None = None + for attempt in range(1, self.max_retries + 1): + try: + return fn() + except Exception as exc: # noqa: BLE001 -- yfinance raises bare Exception + last = exc + if not _is_rate_limit(exc) or attempt == self.max_retries: + break + delay = self.backoff_seconds * (2 ** (attempt - 1)) * (1 + random.random() * 0.25) + logger.warning( + "yahoo throttled %s (attempt %d/%d), retrying in %.1fs", + what, + attempt, + self.max_retries, + delay, + ) + time.sleep(delay) + + assert last is not None + if _is_rate_limit(last): + raise RateLimitedError( + f"Yahoo rate-limited the request for {what} after {self.max_retries} attempts. " + "This is normal for the unofficial endpoint; wait a minute and retry, " + "or use --provider fixture." + ) from last + raise ProviderError(f"Yahoo lookup failed for {what}: {last}") from last + + # -- MarketDataProvider ------------------------------------------------- + + def get_quote(self, ticker: str) -> Quote: + symbol = ticker.upper() + + def fetch() -> dict[str, Any]: + fast = self._ticker(symbol).fast_info + return { + "price": fast.get("lastPrice") or fast.get("last_price"), + "previous_close": fast.get("previousClose") or fast.get("previous_close"), + "currency": fast.get("currency") or "USD", + } + + raw = self._with_retries(f"quote {symbol}", fetch) + price = _clean(raw.get("price")) + if price is None or price <= 0: + raise UnknownTickerError(f"{symbol}: Yahoo returned no usable price") + + return Quote( + ticker=symbol, + price=price, + as_of=datetime.now(UTC), + currency=str(raw.get("currency") or "USD"), + previous_close=_clean(raw.get("previous_close")), + ) + + def get_expiries(self, ticker: str) -> tuple[date, ...]: + symbol = ticker.upper() + raw = self._with_retries(f"expiries {symbol}", lambda: self._ticker(symbol).options) + if not raw: + raise UnknownTickerError(f"{symbol}: Yahoo lists no option expiries") + return tuple(sorted(date.fromisoformat(str(value)) for value in raw)) + + def get_chain(self, ticker: str, expiry: date) -> OptionChain: + symbol = ticker.upper() + chain = self._with_retries( + f"chain {symbol} {expiry}", + lambda: self._ticker(symbol).option_chain(expiry.isoformat()), + ) + + spot = self._spot_from_chain(chain, symbol) + quotes: list[OptionQuote] = [] + for right, frame in (("call", chain.calls), ("put", chain.puts)): + quotes.extend(self._rows_to_quotes(frame, symbol, expiry, right)) + + if not quotes: + raise ProviderError(f"{symbol} {expiry}: Yahoo returned an empty chain") + + return OptionChain( + ticker=symbol, + expiry=expiry, + spot=spot, + as_of=datetime.now(UTC), + quotes=tuple(quotes), + ) + + def _spot_from_chain(self, chain: Any, symbol: str) -> float: + """Prefer the spot Yahoo embedded in the chain response. + + It is the price the quotes were struck against; a second call to + `get_quote` could land on the other side of a tick and make every + computed greek subtly inconsistent with its own chain. + """ + underlying = getattr(chain, "underlying", None) + if isinstance(underlying, dict): + for key in ("regularMarketPrice", "last_price", "lastPrice"): + spot = _clean(underlying.get(key)) + if spot is not None and spot > 0: + return spot + return self.get_quote(symbol).price + + @staticmethod + def _rows_to_quotes(frame: Any, symbol: str, expiry: date, right: str) -> list[OptionQuote]: + if frame is None or frame.empty: + return [] + quotes = [] + for row in frame.to_dict("records"): + strike = _clean(row.get("strike")) + if strike is None or strike <= 0: + continue # a strike-less row is corrupt, not merely unquoted + quotes.append( + OptionQuote( + ticker=symbol, + expiry=expiry, + strike=strike, + right=right, + bid=_clean(row.get("bid")), + ask=_clean(row.get("ask")), + last=_clean(row.get("lastPrice")), + volume=_clean_int(row.get("volume")), + open_interest=_clean_int(row.get("openInterest")), + contract_symbol=row.get("contractSymbol"), + ) + ) + return quotes diff --git a/api/pyproject.toml b/api/pyproject.toml new file mode 100644 index 0000000..06be299 --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,110 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "options-tool" +version = "0.1.0" +description = "Personal options analytics: self-computed Black-Scholes greeks, implied vol, and an IV history built from daily snapshots." +requires-python = ">=3.11" +license = { text = "MIT" } +authors = [{ name = "Karthik Tejas Basavarajappa" }] +dependencies = [ + "numpy>=1.26", + "pandas>=2.1", + "sqlalchemy>=2.0", + "alembic>=1.13", + "pydantic>=2.6", + "pydantic-settings>=2.2", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "yfinance>=0.2.40", + "rich>=13.7", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=4.1", + "pytest-socket>=0.7", + "httpx>=0.27", + "ruff>=0.4", + "mypy>=1.9", + "pandas-stubs>=2.1", + "types-requests>=2.31", +] + +[project.scripts] +options-tool = "options_tool.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["options_tool"] + +# --------------------------------------------------------------------------- +# Tests +# +# --disable-socket is the point: the suite is not merely "intended" to run +# offline, any accidental network call fails the test. Live-data tests opt back +# in with @pytest.mark.network and are deselected by default. +# --------------------------------------------------------------------------- +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = [ + "--disable-socket", + "-m", "not network", + "--strict-markers", +] +markers = [ + "network: hits a live market-data vendor; deselected by default", +] +filterwarnings = ["error::DeprecationWarning:options_tool.*"] + +[tool.coverage.run] +source = ["options_tool"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", + "@abstractmethod", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM", "RET", "ARG", "PTH", "RUF", "PL", "BLE"] +ignore = [ + "N803", "N806", # single-capital maths names: S, K, T, N(d1) + "RUF022", # __all__ is grouped by meaning, not alphabetised + "PLR0913", # Black-Scholes genuinely takes S, K, T, r, sigma, q, right + "PLR0917", # ...and (S, K, T, r, sigma, q, right) is the conventional order + "PLR2004", # numeric tolerances read better inline than as constants +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["ARG001"] +# Each early return in the solver is one documented, separately-named failure +# mode. Collapsing them into fewer exits would hide exactly what section 4 of +# SPEC.md asks to be made explicit. +"options_tool/analytics/implied_vol.py" = ["PLR0911"] + +[tool.mypy] +python_version = "3.11" +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true +no_implicit_optional = true +disallow_untyped_defs = true +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "options_tool.analytics.*" +strict = true + +[[tool.mypy.overrides]] +module = ["yfinance.*", "pandas.*"] +ignore_missing_imports = true diff --git a/api/tests/__init__.py b/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/fixtures/chains.json b/api/tests/fixtures/chains.json new file mode 100644 index 0000000..f58698c --- /dev/null +++ b/api/tests/fixtures/chains.json @@ -0,0 +1,11253 @@ +{ + "captured_at": "2026-09-13T00:26:23.985057+00:00", + "note": "Real vendor capture, frozen for offline tests and the --provider fixture demo. Prices are stale by construction and are not represented as live market data.", + "source": "yfinance", + "underlyings": { + "AAPL": { + "chains": { + "2026-09-14": [ + { + "ask": 89.2, + "bid": 85.75, + "contract_symbol": "AAPL260914C00245000", + "last": 87.66, + "open_interest": 1, + "right": "call", + "strike": 245.0, + "volume": 20 + }, + { + "ask": 48.4, + "bid": 45.8, + "contract_symbol": "AAPL260914C00285000", + "last": 50.2, + "open_interest": 12, + "right": "call", + "strike": 285.0, + "volume": 1 + }, + { + "ask": 42.6, + "bid": 41.5, + "contract_symbol": "AAPL260914C00290000", + "last": 42.6, + "open_interest": 10, + "right": "call", + "strike": 290.0, + "volume": 23 + }, + { + "ask": 38.6, + "bid": 36.1, + "contract_symbol": "AAPL260914C00295000", + "last": 37.52, + "open_interest": 35, + "right": "call", + "strike": 295.0, + "volume": 8 + }, + { + "ask": 33.4, + "bid": 31.15, + "contract_symbol": "AAPL260914C00300000", + "last": 32.45, + "open_interest": 40, + "right": "call", + "strike": 300.0, + "volume": 309 + }, + { + "ask": 28.05, + "bid": 26.85, + "contract_symbol": "AAPL260914C00305000", + "last": 27.61, + "open_interest": 363, + "right": "call", + "strike": 305.0, + "volume": 71 + }, + { + "ask": 23.45, + "bid": 21.1, + "contract_symbol": "AAPL260914C00310000", + "last": 22.77, + "open_interest": 303, + "right": "call", + "strike": 310.0, + "volume": 50 + }, + { + "ask": 17.8, + "bid": 16.7, + "contract_symbol": "AAPL260914C00315000", + "last": 17.4, + "open_interest": 6497, + "right": "call", + "strike": 315.0, + "volume": 320 + }, + { + "ask": 13.2, + "bid": 12.15, + "contract_symbol": "AAPL260914C00320000", + "last": 12.15, + "open_interest": 10731, + "right": "call", + "strike": 320.0, + "volume": 2294 + }, + { + "ask": 7.65, + "bid": 7.35, + "contract_symbol": "AAPL260914C00325000", + "last": 7.45, + "open_interest": 3263, + "right": "call", + "strike": 325.0, + "volume": 4058 + }, + { + "ask": 3.55, + "bid": 3.3, + "contract_symbol": "AAPL260914C00330000", + "last": 3.4, + "open_interest": 5843, + "right": "call", + "strike": 330.0, + "volume": 18399 + }, + { + "ask": 1.05, + "bid": 0.98, + "contract_symbol": "AAPL260914C00335000", + "last": 1.02, + "open_interest": 2911, + "right": "call", + "strike": 335.0, + "volume": 61462 + }, + { + "ask": 0.24, + "bid": 0.2, + "contract_symbol": "AAPL260914C00340000", + "last": 0.2, + "open_interest": 2511, + "right": "call", + "strike": 340.0, + "volume": 26076 + }, + { + "ask": 0.06, + "bid": 0.05, + "contract_symbol": "AAPL260914C00345000", + "last": 0.06, + "open_interest": 714, + "right": "call", + "strike": 345.0, + "volume": 9379 + }, + { + "ask": 0.03, + "bid": 0.02, + "contract_symbol": "AAPL260914C00350000", + "last": 0.02, + "open_interest": 1760, + "right": "call", + "strike": 350.0, + "volume": 5375 + }, + { + "ask": 0.02, + "bid": 0.0, + "contract_symbol": "AAPL260914C00355000", + "last": 0.01, + "open_interest": 272, + "right": "call", + "strike": 355.0, + "volume": 961 + }, + { + "ask": 0.02, + "bid": 0.0, + "contract_symbol": "AAPL260914C00360000", + "last": 0.02, + "open_interest": 496, + "right": "call", + "strike": 360.0, + "volume": 684 + }, + { + "ask": 0.04, + "bid": 0.0, + "contract_symbol": "AAPL260914C00365000", + "last": 0.01, + "open_interest": 88, + "right": "call", + "strike": 365.0, + "volume": 40 + }, + { + "ask": 0.14, + "bid": 0.0, + "contract_symbol": "AAPL260914C00370000", + "last": 0.01, + "open_interest": 277, + "right": "call", + "strike": 370.0, + "volume": 15 + }, + { + "ask": 0.04, + "bid": 0.0, + "contract_symbol": "AAPL260914C00375000", + "last": 0.01, + "open_interest": 14, + "right": "call", + "strike": 375.0, + "volume": 4 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "AAPL260914C00380000", + "last": 0.02, + "open_interest": 23, + "right": "call", + "strike": 380.0, + "volume": 1 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "AAPL260914C00385000", + "last": 0.01, + "open_interest": 20, + "right": "call", + "strike": 385.0, + "volume": 1 + }, + { + "ask": 0.06, + "bid": 0.0, + "contract_symbol": "AAPL260914P00250000", + "last": 0.01, + "open_interest": 3, + "right": "put", + "strike": 250.0, + "volume": 9501 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "AAPL260914P00255000", + "last": 0.01, + "open_interest": 373, + "right": "put", + "strike": 255.0, + "volume": 10500 + }, + { + "ask": 0.02, + "bid": 0.0, + "contract_symbol": "AAPL260914P00260000", + "last": 0.01, + "open_interest": 369, + "right": "put", + "strike": 260.0, + "volume": 1 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "AAPL260914P00265000", + "last": 0.01, + "open_interest": 161, + "right": "put", + "strike": 265.0, + "volume": 5 + }, + { + "ask": 0.05, + "bid": 0.0, + "contract_symbol": "AAPL260914P00270000", + "last": 0.03, + "open_interest": 33, + "right": "put", + "strike": 270.0, + "volume": 2 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "AAPL260914P00275000", + "last": 0.01, + "open_interest": 152, + "right": "put", + "strike": 275.0, + "volume": 12 + }, + { + "ask": 0.02, + "bid": 0.0, + "contract_symbol": "AAPL260914P00280000", + "last": 0.01, + "open_interest": 309, + "right": "put", + "strike": 280.0, + "volume": 52 + }, + { + "ask": 0.03, + "bid": 0.0, + "contract_symbol": "AAPL260914P00285000", + "last": 0.01, + "open_interest": 1280, + "right": "put", + "strike": 285.0, + "volume": 8 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "AAPL260914P00290000", + "last": 0.01, + "open_interest": 1328, + "right": "put", + "strike": 290.0, + "volume": 13 + }, + { + "ask": 0.03, + "bid": 0.0, + "contract_symbol": "AAPL260914P00295000", + "last": 0.01, + "open_interest": 1342, + "right": "put", + "strike": 295.0, + "volume": 308 + }, + { + "ask": 0.02, + "bid": 0.01, + "contract_symbol": "AAPL260914P00300000", + "last": 0.01, + "open_interest": 3923, + "right": "put", + "strike": 300.0, + "volume": 1055 + }, + { + "ask": 0.03, + "bid": 0.02, + "contract_symbol": "AAPL260914P00305000", + "last": 0.02, + "open_interest": 1128, + "right": "put", + "strike": 305.0, + "volume": 463 + }, + { + "ask": 0.06, + "bid": 0.03, + "contract_symbol": "AAPL260914P00310000", + "last": 0.04, + "open_interest": 4346, + "right": "put", + "strike": 310.0, + "volume": 1022 + }, + { + "ask": 0.07, + "bid": 0.06, + "contract_symbol": "AAPL260914P00315000", + "last": 0.07, + "open_interest": 1551, + "right": "put", + "strike": 315.0, + "volume": 1648 + }, + { + "ask": 0.12, + "bid": 0.08, + "contract_symbol": "AAPL260914P00320000", + "last": 0.11, + "open_interest": 1786, + "right": "put", + "strike": 320.0, + "volume": 8446 + }, + { + "ask": 0.28, + "bid": 0.28, + "contract_symbol": "AAPL260914P00325000", + "last": 0.27, + "open_interest": 1722, + "right": "put", + "strike": 325.0, + "volume": 16981 + }, + { + "ask": 1.21, + "bid": 1.11, + "contract_symbol": "AAPL260914P00330000", + "last": 1.21, + "open_interest": 212, + "right": "put", + "strike": 330.0, + "volume": 36676 + }, + { + "ask": 3.85, + "bid": 3.55, + "contract_symbol": "AAPL260914P00335000", + "last": 3.75, + "open_interest": 90, + "right": "put", + "strike": 335.0, + "volume": 26253 + }, + { + "ask": 8.1, + "bid": 6.85, + "contract_symbol": "AAPL260914P00340000", + "last": 7.95, + "open_interest": 62, + "right": "put", + "strike": 340.0, + "volume": 716 + }, + { + "ask": 14.45, + "bid": 11.2, + "contract_symbol": "AAPL260914P00345000", + "last": 12.6, + "open_interest": 0, + "right": "put", + "strike": 345.0, + "volume": 36 + } + ], + "2027-01-15": [ + { + "ask": 329.25, + "bid": 325.85, + "contract_symbol": "AAPL270115C00005000", + "last": 308.05, + "open_interest": 8, + "right": "call", + "strike": 5.0, + "volume": 1 + }, + { + "ask": 324.3, + "bid": 320.85, + "contract_symbol": "AAPL270115C00010000", + "last": 296.34, + "open_interest": 1, + "right": "call", + "strike": 10.0, + "volume": 2 + }, + { + "ask": 319.3, + "bid": 315.9, + "contract_symbol": "AAPL270115C00015000", + "last": 289.93, + "open_interest": 11, + "right": "call", + "strike": 15.0, + "volume": 1 + }, + { + "ask": 314.35, + "bid": 310.95, + "contract_symbol": "AAPL270115C00020000", + "last": 286.39, + "open_interest": 12, + "right": "call", + "strike": 20.0, + "volume": 1 + }, + { + "ask": 178.65, + "bid": 174.8, + "contract_symbol": "AAPL270115C00025000", + "last": 172.1, + "open_interest": 5, + "right": "call", + "strike": 25.0, + "volume": 3 + }, + { + "ask": 304.55, + "bid": 301.05, + "contract_symbol": "AAPL270115C00030000", + "last": 277.3, + "open_interest": 4, + "right": "call", + "strike": 30.0, + "volume": 4 + }, + { + "ask": 299.6, + "bid": 296.25, + "contract_symbol": "AAPL270115C00035000", + "last": 270.14, + "open_interest": 5, + "right": "call", + "strike": 35.0, + "volume": 2 + }, + { + "ask": 263.0, + "bid": 259.2, + "contract_symbol": "AAPL270115C00040000", + "last": 218.8, + "open_interest": 39, + "right": "call", + "strike": 40.0, + "volume": 10 + }, + { + "ask": 230.95, + "bid": 227.25, + "contract_symbol": "AAPL270115C00045000", + "last": 213.4, + "open_interest": 2, + "right": "call", + "strike": 45.0, + "volume": 1 + }, + { + "ask": 284.85, + "bid": 281.5, + "contract_symbol": "AAPL270115C00050000", + "last": 279.83, + "open_interest": 120, + "right": "call", + "strike": 50.0, + "volume": 1 + }, + { + "ask": 264.15, + "bid": 260.35, + "contract_symbol": "AAPL270115C00060000", + "last": 239.0, + "open_interest": 131, + "right": "call", + "strike": 60.0, + "volume": 2 + }, + { + "ask": 260.75, + "bid": 256.85, + "contract_symbol": "AAPL270115C00070000", + "last": 222.74, + "open_interest": 139, + "right": "call", + "strike": 70.0, + "volume": 1 + }, + { + "ask": 255.3, + "bid": 251.9, + "contract_symbol": "AAPL270115C00080000", + "last": 233.3, + "open_interest": 283, + "right": "call", + "strike": 80.0, + "volume": 6 + }, + { + "ask": 250.4, + "bid": 246.9, + "contract_symbol": "AAPL270115C00085000", + "last": 245.95, + "open_interest": 116, + "right": "call", + "strike": 85.0, + "volume": 1 + }, + { + "ask": 245.45, + "bid": 242.05, + "contract_symbol": "AAPL270115C00090000", + "last": 228.05, + "open_interest": 276, + "right": "call", + "strike": 90.0, + "volume": 1 + }, + { + "ask": 240.5, + "bid": 237.05, + "contract_symbol": "AAPL270115C00095000", + "last": 214.43, + "open_interest": 28, + "right": "call", + "strike": 95.0, + "volume": 4 + }, + { + "ask": 235.65, + "bid": 232.3, + "contract_symbol": "AAPL270115C00100000", + "last": 225.72, + "open_interest": 598, + "right": "call", + "strike": 100.0, + "volume": 1 + }, + { + "ask": 230.75, + "bid": 227.25, + "contract_symbol": "AAPL270115C00105000", + "last": 210.05, + "open_interest": 89, + "right": "call", + "strike": 105.0, + "volume": 3 + }, + { + "ask": 225.8, + "bid": 222.45, + "contract_symbol": "AAPL270115C00110000", + "last": 205.23, + "open_interest": 219, + "right": "call", + "strike": 110.0, + "volume": 12 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "AAPL270115C00115000", + "last": 195.83, + "open_interest": 0, + "right": "call", + "strike": 115.0, + "volume": 62 + }, + { + "ask": 215.95, + "bid": 212.55, + "contract_symbol": "AAPL270115C00120000", + "last": 187.21, + "open_interest": 494, + "right": "call", + "strike": 120.0, + "volume": 6 + }, + { + "ask": 211.05, + "bid": 207.7, + "contract_symbol": "AAPL270115C00125000", + "last": 195.65, + "open_interest": 334, + "right": "call", + "strike": 125.0, + "volume": 10 + }, + { + "ask": 206.1, + "bid": 202.75, + "contract_symbol": "AAPL270115C00130000", + "last": 203.45, + "open_interest": 479, + "right": "call", + "strike": 130.0, + "volume": 1 + }, + { + "ask": 201.2, + "bid": 197.9, + "contract_symbol": "AAPL270115C00135000", + "last": 184.42, + "open_interest": 1576, + "right": "call", + "strike": 135.0, + "volume": 2 + }, + { + "ask": 196.3, + "bid": 192.9, + "contract_symbol": "AAPL270115C00140000", + "last": 179.61, + "open_interest": 1592, + "right": "call", + "strike": 140.0, + "volume": 3 + }, + { + "ask": 191.2, + "bid": 187.95, + "contract_symbol": "AAPL270115C00145000", + "last": 191.3, + "open_interest": 6415, + "right": "call", + "strike": 145.0, + "volume": 89 + }, + { + "ask": 186.25, + "bid": 183.1, + "contract_symbol": "AAPL270115C00150000", + "last": 184.8, + "open_interest": 1700, + "right": "call", + "strike": 150.0, + "volume": 2 + }, + { + "ask": 181.6, + "bid": 178.2, + "contract_symbol": "AAPL270115C00155000", + "last": 168.29, + "open_interest": 82, + "right": "call", + "strike": 155.0, + "volume": 5 + }, + { + "ask": 176.65, + "bid": 173.25, + "contract_symbol": "AAPL270115C00160000", + "last": 171.3, + "open_interest": 650, + "right": "call", + "strike": 160.0, + "volume": 1 + }, + { + "ask": 171.75, + "bid": 168.35, + "contract_symbol": "AAPL270115C00165000", + "last": 155.9, + "open_interest": 460, + "right": "call", + "strike": 165.0, + "volume": 1 + }, + { + "ask": 166.85, + "bid": 163.45, + "contract_symbol": "AAPL270115C00170000", + "last": 148.44, + "open_interest": 983, + "right": "call", + "strike": 170.0, + "volume": 1 + }, + { + "ask": 161.95, + "bid": 158.55, + "contract_symbol": "AAPL270115C00175000", + "last": 160.5, + "open_interest": 1149, + "right": "call", + "strike": 175.0, + "volume": 1 + }, + { + "ask": 156.25, + "bid": 153.75, + "contract_symbol": "AAPL270115C00180000", + "last": 158.03, + "open_interest": 1050, + "right": "call", + "strike": 180.0, + "volume": 1 + }, + { + "ask": 152.15, + "bid": 148.7, + "contract_symbol": "AAPL270115C00185000", + "last": 152.54, + "open_interest": 826, + "right": "call", + "strike": 185.0, + "volume": 6 + }, + { + "ask": 147.25, + "bid": 143.8, + "contract_symbol": "AAPL270115C00190000", + "last": 129.0, + "open_interest": 800, + "right": "call", + "strike": 190.0, + "volume": 3 + }, + { + "ask": 142.4, + "bid": 139.0, + "contract_symbol": "AAPL270115C00195000", + "last": 121.33, + "open_interest": 639, + "right": "call", + "strike": 195.0, + "volume": 2 + }, + { + "ask": 137.45, + "bid": 134.3, + "contract_symbol": "AAPL270115C00200000", + "last": 136.0, + "open_interest": 12549, + "right": "call", + "strike": 200.0, + "volume": 23 + }, + { + "ask": 132.65, + "bid": 129.25, + "contract_symbol": "AAPL270115C00205000", + "last": 115.27, + "open_interest": 118, + "right": "call", + "strike": 205.0, + "volume": 2 + }, + { + "ask": 127.75, + "bid": 124.35, + "contract_symbol": "AAPL270115C00210000", + "last": 126.4, + "open_interest": 2342, + "right": "call", + "strike": 210.0, + "volume": 2 + }, + { + "ask": 122.9, + "bid": 119.55, + "contract_symbol": "AAPL270115C00215000", + "last": 114.3, + "open_interest": 228, + "right": "call", + "strike": 215.0, + "volume": 1 + }, + { + "ask": 118.1, + "bid": 114.8, + "contract_symbol": "AAPL270115C00220000", + "last": 116.8, + "open_interest": 7123, + "right": "call", + "strike": 220.0, + "volume": 5 + }, + { + "ask": 113.2, + "bid": 110.0, + "contract_symbol": "AAPL270115C00225000", + "last": 96.13, + "open_interest": 480, + "right": "call", + "strike": 225.0, + "volume": 5 + }, + { + "ask": 107.5, + "bid": 105.1, + "contract_symbol": "AAPL270115C00230000", + "last": 108.65, + "open_interest": 10774, + "right": "call", + "strike": 230.0, + "volume": 234 + }, + { + "ask": 103.75, + "bid": 100.4, + "contract_symbol": "AAPL270115C00235000", + "last": 102.04, + "open_interest": 375, + "right": "call", + "strike": 235.0, + "volume": 2 + }, + { + "ask": 98.75, + "bid": 96.05, + "contract_symbol": "AAPL270115C00240000", + "last": 97.53, + "open_interest": 9254, + "right": "call", + "strike": 240.0, + "volume": 222 + }, + { + "ask": 93.65, + "bid": 91.2, + "contract_symbol": "AAPL270115C00245000", + "last": 92.35, + "open_interest": 3318, + "right": "call", + "strike": 245.0, + "volume": 7 + }, + { + "ask": 88.25, + "bid": 87.05, + "contract_symbol": "AAPL270115C00250000", + "last": 87.93, + "open_interest": 21527, + "right": "call", + "strike": 250.0, + "volume": 96 + }, + { + "ask": 84.45, + "bid": 82.5, + "contract_symbol": "AAPL270115C00255000", + "last": 84.14, + "open_interest": 4424, + "right": "call", + "strike": 255.0, + "volume": 7 + }, + { + "ask": 79.25, + "bid": 77.45, + "contract_symbol": "AAPL270115C00260000", + "last": 78.7, + "open_interest": 9974, + "right": "call", + "strike": 260.0, + "volume": 27 + }, + { + "ask": 74.6, + "bid": 72.8, + "contract_symbol": "AAPL270115C00265000", + "last": 73.9, + "open_interest": 2410, + "right": "call", + "strike": 265.0, + "volume": 30 + }, + { + "ask": 70.05, + "bid": 67.95, + "contract_symbol": "AAPL270115C00270000", + "last": 69.7, + "open_interest": 11698, + "right": "call", + "strike": 270.0, + "volume": 119 + }, + { + "ask": 66.1, + "bid": 63.85, + "contract_symbol": "AAPL270115C00275000", + "last": 67.82, + "open_interest": 1074, + "right": "call", + "strike": 275.0, + "volume": 17 + }, + { + "ask": 61.5, + "bid": 60.1, + "contract_symbol": "AAPL270115C00280000", + "last": 60.33, + "open_interest": 31644, + "right": "call", + "strike": 280.0, + "volume": 82 + }, + { + "ask": 57.6, + "bid": 55.55, + "contract_symbol": "AAPL270115C00285000", + "last": 57.44, + "open_interest": 3172, + "right": "call", + "strike": 285.0, + "volume": 29 + }, + { + "ask": 53.95, + "bid": 51.3, + "contract_symbol": "AAPL270115C00290000", + "last": 52.15, + "open_interest": 10813, + "right": "call", + "strike": 290.0, + "volume": 109 + }, + { + "ask": 49.4, + "bid": 47.5, + "contract_symbol": "AAPL270115C00295000", + "last": 48.9, + "open_interest": 8071, + "right": "call", + "strike": 295.0, + "volume": 67 + }, + { + "ask": 44.35, + "bid": 43.85, + "contract_symbol": "AAPL270115C00300000", + "last": 44.0, + "open_interest": 65700, + "right": "call", + "strike": 300.0, + "volume": 610 + }, + { + "ask": 40.85, + "bid": 39.65, + "contract_symbol": "AAPL270115C00305000", + "last": 40.77, + "open_interest": 5167, + "right": "call", + "strike": 305.0, + "volume": 76 + }, + { + "ask": 37.05, + "bid": 36.2, + "contract_symbol": "AAPL270115C00310000", + "last": 36.97, + "open_interest": 28174, + "right": "call", + "strike": 310.0, + "volume": 507 + }, + { + "ask": 33.6, + "bid": 33.05, + "contract_symbol": "AAPL270115C00315000", + "last": 33.52, + "open_interest": 14045, + "right": "call", + "strike": 315.0, + "volume": 116 + }, + { + "ask": 30.3, + "bid": 29.85, + "contract_symbol": "AAPL270115C00320000", + "last": 30.13, + "open_interest": 36962, + "right": "call", + "strike": 320.0, + "volume": 447 + }, + { + "ask": 27.3, + "bid": 26.75, + "contract_symbol": "AAPL270115C00325000", + "last": 27.0, + "open_interest": 8679, + "right": "call", + "strike": 325.0, + "volume": 237 + }, + { + "ask": 24.9, + "bid": 24.0, + "contract_symbol": "AAPL270115C00330000", + "last": 24.1, + "open_interest": 21653, + "right": "call", + "strike": 330.0, + "volume": 1040 + }, + { + "ask": 21.7, + "bid": 21.3, + "contract_symbol": "AAPL270115C00335000", + "last": 21.9, + "open_interest": 6897, + "right": "call", + "strike": 335.0, + "volume": 448 + }, + { + "ask": 19.15, + "bid": 18.85, + "contract_symbol": "AAPL270115C00340000", + "last": 19.0, + "open_interest": 9885, + "right": "call", + "strike": 340.0, + "volume": 855 + }, + { + "ask": 16.9, + "bid": 16.6, + "contract_symbol": "AAPL270115C00345000", + "last": 16.74, + "open_interest": 6491, + "right": "call", + "strike": 345.0, + "volume": 177 + }, + { + "ask": 14.8, + "bid": 14.55, + "contract_symbol": "AAPL270115C00350000", + "last": 14.85, + "open_interest": 45034, + "right": "call", + "strike": 350.0, + "volume": 1924 + }, + { + "ask": 12.95, + "bid": 12.7, + "contract_symbol": "AAPL270115C00355000", + "last": 12.85, + "open_interest": 13256, + "right": "call", + "strike": 355.0, + "volume": 272 + }, + { + "ask": 11.3, + "bid": 11.0, + "contract_symbol": "AAPL270115C00360000", + "last": 11.45, + "open_interest": 14751, + "right": "call", + "strike": 360.0, + "volume": 396 + }, + { + "ask": 9.8, + "bid": 9.5, + "contract_symbol": "AAPL270115C00365000", + "last": 9.82, + "open_interest": 4950, + "right": "call", + "strike": 365.0, + "volume": 240 + }, + { + "ask": 8.45, + "bid": 8.2, + "contract_symbol": "AAPL270115C00370000", + "last": 8.57, + "open_interest": 9717, + "right": "call", + "strike": 370.0, + "volume": 259 + }, + { + "ask": 7.3, + "bid": 7.05, + "contract_symbol": "AAPL270115C00375000", + "last": 7.19, + "open_interest": 3224, + "right": "call", + "strike": 375.0, + "volume": 254 + }, + { + "ask": 6.25, + "bid": 6.0, + "contract_symbol": "AAPL270115C00380000", + "last": 6.15, + "open_interest": 8082, + "right": "call", + "strike": 380.0, + "volume": 3483 + }, + { + "ask": 5.35, + "bid": 5.1, + "contract_symbol": "AAPL270115C00385000", + "last": 5.32, + "open_interest": 1887, + "right": "call", + "strike": 385.0, + "volume": 195 + }, + { + "ask": 4.6, + "bid": 4.35, + "contract_symbol": "AAPL270115C00390000", + "last": 4.5, + "open_interest": 5741, + "right": "call", + "strike": 390.0, + "volume": 268 + }, + { + "ask": 3.95, + "bid": 3.75, + "contract_symbol": "AAPL270115C00395000", + "last": 3.8, + "open_interest": 1510, + "right": "call", + "strike": 395.0, + "volume": 136 + }, + { + "ask": 3.3, + "bid": 3.15, + "contract_symbol": "AAPL270115C00400000", + "last": 3.28, + "open_interest": 9082, + "right": "call", + "strike": 400.0, + "volume": 1422 + }, + { + "ask": 2.42, + "bid": 2.3, + "contract_symbol": "AAPL270115C00410000", + "last": 2.32, + "open_interest": 2951, + "right": "call", + "strike": 410.0, + "volume": 1167 + }, + { + "ask": 1.73, + "bid": 1.62, + "contract_symbol": "AAPL270115C00420000", + "last": 1.75, + "open_interest": 3116, + "right": "call", + "strike": 420.0, + "volume": 237 + }, + { + "ask": 1.28, + "bid": 1.15, + "contract_symbol": "AAPL270115C00430000", + "last": 1.19, + "open_interest": 4594, + "right": "call", + "strike": 430.0, + "volume": 308 + }, + { + "ask": 0.91, + "bid": 0.82, + "contract_symbol": "AAPL270115C00440000", + "last": 0.85, + "open_interest": 4446, + "right": "call", + "strike": 440.0, + "volume": 382 + }, + { + "ask": 0.62, + "bid": 0.57, + "contract_symbol": "AAPL270115C00450000", + "last": 0.62, + "open_interest": 16632, + "right": "call", + "strike": 450.0, + "volume": 834 + }, + { + "ask": 0.51, + "bid": 0.34, + "contract_symbol": "AAPL270115C00460000", + "last": 0.42, + "open_interest": 386, + "right": "call", + "strike": 460.0, + "volume": 1 + }, + { + "ask": 0.38, + "bid": 0.28, + "contract_symbol": "AAPL270115C00470000", + "last": 0.34, + "open_interest": 432, + "right": "call", + "strike": 470.0, + "volume": 3 + }, + { + "ask": 0.29, + "bid": 0.2, + "contract_symbol": "AAPL270115C00480000", + "last": 0.31, + "open_interest": 8867, + "right": "call", + "strike": 480.0, + "volume": 1 + }, + { + "ask": 0.23, + "bid": 0.11, + "contract_symbol": "AAPL270115C00490000", + "last": 0.19, + "open_interest": 993, + "right": "call", + "strike": 490.0, + "volume": 33 + }, + { + "ask": 0.17, + "bid": 0.06, + "contract_symbol": "AAPL270115C00500000", + "last": 0.13, + "open_interest": 12627, + "right": "call", + "strike": 500.0, + "volume": 110 + }, + { + "ask": 0.14, + "bid": 0.02, + "contract_symbol": "AAPL270115C00510000", + "last": 0.1, + "open_interest": 195, + "right": "call", + "strike": 510.0, + "volume": 12 + }, + { + "ask": 0.11, + "bid": 0.0, + "contract_symbol": "AAPL270115C00520000", + "last": 0.06, + "open_interest": 4639, + "right": "call", + "strike": 520.0, + "volume": 2 + }, + { + "ask": 0.1, + "bid": 0.03, + "contract_symbol": "AAPL270115C00530000", + "last": 0.07, + "open_interest": 5195, + "right": "call", + "strike": 530.0, + "volume": 7 + }, + { + "ask": 0.18, + "bid": 0.0, + "contract_symbol": "AAPL270115C00540000", + "last": 0.08, + "open_interest": 732, + "right": "call", + "strike": 540.0, + "volume": 3 + }, + { + "ask": 0.06, + "bid": 0.03, + "contract_symbol": "AAPL270115C00550000", + "last": 0.05, + "open_interest": 10623, + "right": "call", + "strike": 550.0, + "volume": 9 + }, + { + "ask": 0.31, + "bid": 0.0, + "contract_symbol": "AAPL270115C00560000", + "last": 0.11, + "open_interest": 38, + "right": "call", + "strike": 560.0, + "volume": 1 + }, + { + "ask": 0.15, + "bid": 0.0, + "contract_symbol": "AAPL270115C00570000", + "last": 0.09, + "open_interest": 63, + "right": "call", + "strike": 570.0, + "volume": 4 + }, + { + "ask": 0.19, + "bid": 0.0, + "contract_symbol": "AAPL270115C00580000", + "last": 0.1, + "open_interest": 127, + "right": "call", + "strike": 580.0, + "volume": 1 + }, + { + "ask": 0.18, + "bid": 0.0, + "contract_symbol": "AAPL270115C00590000", + "last": 0.11, + "open_interest": 120, + "right": "call", + "strike": 590.0, + "volume": 2 + }, + { + "ask": 0.03, + "bid": 0.02, + "contract_symbol": "AAPL270115C00600000", + "last": 0.03, + "open_interest": 13489, + "right": "call", + "strike": 600.0, + "volume": 34 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "AAPL270115P00005000", + "last": 0.01, + "open_interest": 1699, + "right": "put", + "strike": 5.0, + "volume": 50 + }, + { + "ask": 0.22, + "bid": 0.0, + "contract_symbol": "AAPL270115P00010000", + "last": 0.2, + "open_interest": 143, + "right": "put", + "strike": 10.0, + "volume": 10 + }, + { + "ask": 1.05, + "bid": 0.0, + "contract_symbol": "AAPL270115P00015000", + "last": 0.09, + "open_interest": 90, + "right": "put", + "strike": 15.0, + "volume": 1 + }, + { + "ask": 1.01, + "bid": 0.0, + "contract_symbol": "AAPL270115P00020000", + "last": 0.27, + "open_interest": 3434, + "right": "put", + "strike": 20.0, + "volume": 20 + }, + { + "ask": 0.02, + "bid": 0.0, + "contract_symbol": "AAPL270115P00025000", + "last": 0.01, + "open_interest": 3415, + "right": "put", + "strike": 25.0, + "volume": 996 + }, + { + "ask": 0.04, + "bid": 0.0, + "contract_symbol": "AAPL270115P00030000", + "last": 0.03, + "open_interest": 46, + "right": "put", + "strike": 30.0, + "volume": 1 + }, + { + "ask": 1.13, + "bid": 0.0, + "contract_symbol": "AAPL270115P00035000", + "last": 0.02, + "open_interest": 128, + "right": "put", + "strike": 35.0, + "volume": 10 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "AAPL270115P00040000", + "last": 0.02, + "open_interest": 1025, + "right": "put", + "strike": 40.0, + "volume": 1 + }, + { + "ask": 0.52, + "bid": 0.0, + "contract_symbol": "AAPL270115P00045000", + "last": 0.05, + "open_interest": 80, + "right": "put", + "strike": 45.0, + "volume": 1 + }, + { + "ask": 0.05, + "bid": 0.0, + "contract_symbol": "AAPL270115P00050000", + "last": 0.01, + "open_interest": 2799, + "right": "put", + "strike": 50.0, + "volume": 1 + }, + { + "ask": 0.02, + "bid": 0.0, + "contract_symbol": "AAPL270115P00060000", + "last": 0.01, + "open_interest": 1224, + "right": "put", + "strike": 60.0, + "volume": 41 + }, + { + "ask": 0.18, + "bid": 0.0, + "contract_symbol": "AAPL270115P00070000", + "last": 0.03, + "open_interest": 189, + "right": "put", + "strike": 70.0, + "volume": 16 + }, + { + "ask": 0.05, + "bid": 0.01, + "contract_symbol": "AAPL270115P00080000", + "last": 0.03, + "open_interest": 870, + "right": "put", + "strike": 80.0, + "volume": 1 + }, + { + "ask": 0.18, + "bid": 0.0, + "contract_symbol": "AAPL270115P00085000", + "last": 0.02, + "open_interest": 351, + "right": "put", + "strike": 85.0, + "volume": 12 + }, + { + "ask": 0.18, + "bid": 0.0, + "contract_symbol": "AAPL270115P00090000", + "last": 0.04, + "open_interest": 1755, + "right": "put", + "strike": 90.0, + "volume": 7 + }, + { + "ask": 0.09, + "bid": 0.01, + "contract_symbol": "AAPL270115P00095000", + "last": 0.09, + "open_interest": 927, + "right": "put", + "strike": 95.0, + "volume": 3 + }, + { + "ask": 0.05, + "bid": 0.01, + "contract_symbol": "AAPL270115P00100000", + "last": 0.03, + "open_interest": 3195, + "right": "put", + "strike": 100.0, + "volume": 5 + }, + { + "ask": 0.4, + "bid": 0.01, + "contract_symbol": "AAPL270115P00105000", + "last": 0.07, + "open_interest": 1526, + "right": "put", + "strike": 105.0, + "volume": 3 + }, + { + "ask": 0.08, + "bid": 0.02, + "contract_symbol": "AAPL270115P00110000", + "last": 0.03, + "open_interest": 791, + "right": "put", + "strike": 110.0, + "volume": 3 + }, + { + "ask": 0.09, + "bid": 0.01, + "contract_symbol": "AAPL270115P00115000", + "last": 0.03, + "open_interest": 759, + "right": "put", + "strike": 115.0, + "volume": 7 + }, + { + "ask": 0.1, + "bid": 0.02, + "contract_symbol": "AAPL270115P00120000", + "last": 0.06, + "open_interest": 3081, + "right": "put", + "strike": 120.0, + "volume": 1 + }, + { + "ask": 0.13, + "bid": 0.0, + "contract_symbol": "AAPL270115P00125000", + "last": 0.03, + "open_interest": 858, + "right": "put", + "strike": 125.0, + "volume": 7 + }, + { + "ask": 0.27, + "bid": 0.0, + "contract_symbol": "AAPL270115P00130000", + "last": 0.04, + "open_interest": 4928, + "right": "put", + "strike": 130.0, + "volume": 8 + }, + { + "ask": 0.22, + "bid": 0.01, + "contract_symbol": "AAPL270115P00135000", + "last": 0.05, + "open_interest": 1580, + "right": "put", + "strike": 135.0, + "volume": 12 + }, + { + "ask": 0.15, + "bid": 0.04, + "contract_symbol": "AAPL270115P00140000", + "last": 0.06, + "open_interest": 2117, + "right": "put", + "strike": 140.0, + "volume": 1 + }, + { + "ask": 0.11, + "bid": 0.05, + "contract_symbol": "AAPL270115P00145000", + "last": 0.09, + "open_interest": 8849, + "right": "put", + "strike": 145.0, + "volume": 1 + }, + { + "ask": 0.12, + "bid": 0.01, + "contract_symbol": "AAPL270115P00150000", + "last": 0.13, + "open_interest": 5254, + "right": "put", + "strike": 150.0, + "volume": 1 + }, + { + "ask": 0.13, + "bid": 0.02, + "contract_symbol": "AAPL270115P00155000", + "last": 0.14, + "open_interest": 1240, + "right": "put", + "strike": 155.0, + "volume": 1 + }, + { + "ask": 0.14, + "bid": 0.06, + "contract_symbol": "AAPL270115P00160000", + "last": 0.11, + "open_interest": 5171, + "right": "put", + "strike": 160.0, + "volume": 14 + }, + { + "ask": 0.16, + "bid": 0.05, + "contract_symbol": "AAPL270115P00165000", + "last": 0.1, + "open_interest": 3799, + "right": "put", + "strike": 165.0, + "volume": 90 + }, + { + "ask": 0.18, + "bid": 0.07, + "contract_symbol": "AAPL270115P00170000", + "last": 0.12, + "open_interest": 7670, + "right": "put", + "strike": 170.0, + "volume": 98 + }, + { + "ask": 0.21, + "bid": 0.09, + "contract_symbol": "AAPL270115P00175000", + "last": 0.14, + "open_interest": 3675, + "right": "put", + "strike": 175.0, + "volume": 1 + }, + { + "ask": 0.24, + "bid": 0.16, + "contract_symbol": "AAPL270115P00180000", + "last": 0.2, + "open_interest": 9106, + "right": "put", + "strike": 180.0, + "volume": 5 + }, + { + "ask": 0.28, + "bid": 0.14, + "contract_symbol": "AAPL270115P00185000", + "last": 0.21, + "open_interest": 5493, + "right": "put", + "strike": 185.0, + "volume": 14 + }, + { + "ask": 0.31, + "bid": 0.18, + "contract_symbol": "AAPL270115P00190000", + "last": 0.27, + "open_interest": 7365, + "right": "put", + "strike": 190.0, + "volume": 287 + }, + { + "ask": 0.36, + "bid": 0.26, + "contract_symbol": "AAPL270115P00195000", + "last": 0.29, + "open_interest": 5819, + "right": "put", + "strike": 195.0, + "volume": 323 + }, + { + "ask": 0.36, + "bid": 0.3, + "contract_symbol": "AAPL270115P00200000", + "last": 0.36, + "open_interest": 21495, + "right": "put", + "strike": 200.0, + "volume": 8 + }, + { + "ask": 0.42, + "bid": 0.35, + "contract_symbol": "AAPL270115P00205000", + "last": 0.42, + "open_interest": 195, + "right": "put", + "strike": 205.0, + "volume": 1 + }, + { + "ask": 0.51, + "bid": 0.43, + "contract_symbol": "AAPL270115P00210000", + "last": 0.47, + "open_interest": 15904, + "right": "put", + "strike": 210.0, + "volume": 130 + }, + { + "ask": 0.62, + "bid": 0.5, + "contract_symbol": "AAPL270115P00215000", + "last": 0.43, + "open_interest": 568, + "right": "put", + "strike": 215.0, + "volume": 5 + }, + { + "ask": 0.64, + "bid": 0.58, + "contract_symbol": "AAPL270115P00220000", + "last": 0.63, + "open_interest": 17423, + "right": "put", + "strike": 220.0, + "volume": 101 + }, + { + "ask": 0.79, + "bid": 0.67, + "contract_symbol": "AAPL270115P00225000", + "last": 0.72, + "open_interest": 1883, + "right": "put", + "strike": 225.0, + "volume": 59 + }, + { + "ask": 0.86, + "bid": 0.81, + "contract_symbol": "AAPL270115P00230000", + "last": 0.84, + "open_interest": 25737, + "right": "put", + "strike": 230.0, + "volume": 71 + }, + { + "ask": 1.02, + "bid": 0.94, + "contract_symbol": "AAPL270115P00235000", + "last": 0.96, + "open_interest": 1906, + "right": "put", + "strike": 235.0, + "volume": 5 + }, + { + "ask": 1.18, + "bid": 1.1, + "contract_symbol": "AAPL270115P00240000", + "last": 1.15, + "open_interest": 59720, + "right": "put", + "strike": 240.0, + "volume": 81 + }, + { + "ask": 1.41, + "bid": 1.28, + "contract_symbol": "AAPL270115P00245000", + "last": 1.32, + "open_interest": 4705, + "right": "put", + "strike": 245.0, + "volume": 2 + }, + { + "ask": 1.59, + "bid": 1.52, + "contract_symbol": "AAPL270115P00250000", + "last": 1.55, + "open_interest": 24941, + "right": "put", + "strike": 250.0, + "volume": 218 + }, + { + "ask": 1.9, + "bid": 1.74, + "contract_symbol": "AAPL270115P00255000", + "last": 1.84, + "open_interest": 5310, + "right": "put", + "strike": 255.0, + "volume": 13 + }, + { + "ask": 2.18, + "bid": 2.07, + "contract_symbol": "AAPL270115P00260000", + "last": 2.11, + "open_interest": 13005, + "right": "put", + "strike": 260.0, + "volume": 119 + }, + { + "ask": 2.55, + "bid": 2.45, + "contract_symbol": "AAPL270115P00265000", + "last": 2.51, + "open_interest": 3847, + "right": "put", + "strike": 265.0, + "volume": 36 + }, + { + "ask": 2.98, + "bid": 2.87, + "contract_symbol": "AAPL270115P00270000", + "last": 2.93, + "open_interest": 17444, + "right": "put", + "strike": 270.0, + "volume": 276 + }, + { + "ask": 3.55, + "bid": 3.3, + "contract_symbol": "AAPL270115P00275000", + "last": 3.38, + "open_interest": 6707, + "right": "put", + "strike": 275.0, + "volume": 109 + }, + { + "ask": 4.15, + "bid": 3.9, + "contract_symbol": "AAPL270115P00280000", + "last": 4.02, + "open_interest": 21278, + "right": "put", + "strike": 280.0, + "volume": 154 + }, + { + "ask": 4.85, + "bid": 4.6, + "contract_symbol": "AAPL270115P00285000", + "last": 4.75, + "open_interest": 4040, + "right": "put", + "strike": 285.0, + "volume": 293 + }, + { + "ask": 5.6, + "bid": 5.35, + "contract_symbol": "AAPL270115P00290000", + "last": 5.49, + "open_interest": 10341, + "right": "put", + "strike": 290.0, + "volume": 96 + }, + { + "ask": 6.55, + "bid": 6.3, + "contract_symbol": "AAPL270115P00295000", + "last": 6.43, + "open_interest": 11125, + "right": "put", + "strike": 295.0, + "volume": 138 + }, + { + "ask": 7.55, + "bid": 7.35, + "contract_symbol": "AAPL270115P00300000", + "last": 7.42, + "open_interest": 13387, + "right": "put", + "strike": 300.0, + "volume": 1620 + }, + { + "ask": 8.8, + "bid": 8.5, + "contract_symbol": "AAPL270115P00305000", + "last": 8.69, + "open_interest": 8735, + "right": "put", + "strike": 305.0, + "volume": 175 + }, + { + "ask": 10.15, + "bid": 9.85, + "contract_symbol": "AAPL270115P00310000", + "last": 10.08, + "open_interest": 24473, + "right": "put", + "strike": 310.0, + "volume": 792 + }, + { + "ask": 11.7, + "bid": 11.4, + "contract_symbol": "AAPL270115P00315000", + "last": 11.52, + "open_interest": 13958, + "right": "put", + "strike": 315.0, + "volume": 950 + }, + { + "ask": 13.45, + "bid": 13.15, + "contract_symbol": "AAPL270115P00320000", + "last": 13.27, + "open_interest": 10722, + "right": "put", + "strike": 320.0, + "volume": 352 + }, + { + "ask": 15.4, + "bid": 15.05, + "contract_symbol": "AAPL270115P00325000", + "last": 15.2, + "open_interest": 5597, + "right": "put", + "strike": 325.0, + "volume": 197 + }, + { + "ask": 17.6, + "bid": 17.2, + "contract_symbol": "AAPL270115P00330000", + "last": 17.35, + "open_interest": 1864, + "right": "put", + "strike": 330.0, + "volume": 165 + }, + { + "ask": 19.85, + "bid": 19.6, + "contract_symbol": "AAPL270115P00335000", + "last": 19.75, + "open_interest": 1486, + "right": "put", + "strike": 335.0, + "volume": 340 + }, + { + "ask": 22.55, + "bid": 22.05, + "contract_symbol": "AAPL270115P00340000", + "last": 22.1, + "open_interest": 2643, + "right": "put", + "strike": 340.0, + "volume": 240 + }, + { + "ask": 25.3, + "bid": 24.75, + "contract_symbol": "AAPL270115P00345000", + "last": 24.3, + "open_interest": 551, + "right": "put", + "strike": 345.0, + "volume": 48 + }, + { + "ask": 28.6, + "bid": 27.25, + "contract_symbol": "AAPL270115P00350000", + "last": 27.3, + "open_interest": 666, + "right": "put", + "strike": 350.0, + "volume": 8 + }, + { + "ask": 31.9, + "bid": 30.35, + "contract_symbol": "AAPL270115P00355000", + "last": 30.1, + "open_interest": 124, + "right": "put", + "strike": 355.0, + "volume": 40 + }, + { + "ask": 35.3, + "bid": 34.05, + "contract_symbol": "AAPL270115P00360000", + "last": 34.65, + "open_interest": 191, + "right": "put", + "strike": 360.0, + "volume": 57 + }, + { + "ask": 39.7, + "bid": 36.95, + "contract_symbol": "AAPL270115P00365000", + "last": 38.2, + "open_interest": 82, + "right": "put", + "strike": 365.0, + "volume": 9 + }, + { + "ask": 43.1, + "bid": 40.8, + "contract_symbol": "AAPL270115P00370000", + "last": 41.84, + "open_interest": 99, + "right": "put", + "strike": 370.0, + "volume": 5 + }, + { + "ask": 46.45, + "bid": 44.1, + "contract_symbol": "AAPL270115P00375000", + "last": 44.4, + "open_interest": 24, + "right": "put", + "strike": 375.0, + "volume": 2 + }, + { + "ask": 50.6, + "bid": 49.1, + "contract_symbol": "AAPL270115P00380000", + "last": 50.01, + "open_interest": 21, + "right": "put", + "strike": 380.0, + "volume": 3 + }, + { + "ask": 55.5, + "bid": 53.35, + "contract_symbol": "AAPL270115P00385000", + "last": 66.05, + "open_interest": 5, + "right": "put", + "strike": 385.0, + "volume": 10 + }, + { + "ask": 60.3, + "bid": 57.7, + "contract_symbol": "AAPL270115P00390000", + "last": 70.75, + "open_interest": 28, + "right": "put", + "strike": 390.0, + "volume": 56 + }, + { + "ask": 76.25, + "bid": 73.65, + "contract_symbol": "AAPL270115P00395000", + "last": 91.63, + "open_interest": 0, + "right": "put", + "strike": 395.0, + "volume": 2 + }, + { + "ask": 68.8, + "bid": 66.15, + "contract_symbol": "AAPL270115P00400000", + "last": 67.15, + "open_interest": 6, + "right": "put", + "strike": 400.0, + "volume": 4 + }, + { + "ask": 79.55, + "bid": 75.85, + "contract_symbol": "AAPL270115P00410000", + "last": 94.0, + "open_interest": 1, + "right": "put", + "strike": 410.0, + "volume": 1 + }, + { + "ask": 101.4, + "bid": 98.35, + "contract_symbol": "AAPL270115P00420000", + "last": 81.55, + "open_interest": 0, + "right": "put", + "strike": 420.0, + "volume": 10 + }, + { + "ask": 111.4, + "bid": 108.1, + "contract_symbol": "AAPL270115P00430000", + "last": 124.69, + "open_interest": 0, + "right": "put", + "strike": 430.0, + "volume": 5 + }, + { + "ask": 179.2, + "bid": 175.6, + "contract_symbol": "AAPL270115P00440000", + "last": 162.08, + "open_interest": 0, + "right": "put", + "strike": 440.0, + "volume": 20 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "AAPL270115P00450000", + "last": 245.9, + "open_interest": 0, + "right": "put", + "strike": 450.0, + "volume": 1 + }, + { + "ask": 269.2, + "bid": 265.5, + "contract_symbol": "AAPL270115P00530000", + "last": 260.32, + "open_interest": 0, + "right": "put", + "strike": 530.0, + "volume": 10 + }, + { + "ask": 295.85, + "bid": 291.95, + "contract_symbol": "AAPL270115P00550000", + "last": 274.35, + "open_interest": 0, + "right": "put", + "strike": 550.0, + "volume": null + }, + { + "ask": 291.05, + "bid": 287.65, + "contract_symbol": "AAPL270115P00600000", + "last": 274.25, + "open_interest": 0, + "right": "put", + "strike": 600.0, + "volume": null + } + ], + "2028-12-15": [ + { + "ask": 331.5, + "bid": 322.95, + "contract_symbol": "AAPL281215C00005000", + "last": 327.8, + "open_interest": 58, + "right": "call", + "strike": 5.0, + "volume": 4 + }, + { + "ask": 326.65, + "bid": 318.15, + "contract_symbol": "AAPL281215C00010000", + "last": 301.0, + "open_interest": 20, + "right": "call", + "strike": 10.0, + "volume": 1 + }, + { + "ask": 298.0, + "bid": 293.0, + "contract_symbol": "AAPL281215C00015000", + "last": 318.15, + "open_interest": 74, + "right": "call", + "strike": 15.0, + "volume": 21 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "AAPL281215C00020000", + "last": 242.96, + "open_interest": 0, + "right": "call", + "strike": 20.0, + "volume": null + }, + { + "ask": 311.0, + "bid": 306.0, + "contract_symbol": "AAPL281215C00025000", + "last": 296.57, + "open_interest": 17, + "right": "call", + "strike": 25.0, + "volume": 10 + }, + { + "ask": 306.5, + "bid": 301.5, + "contract_symbol": "AAPL281215C00030000", + "last": 290.0, + "open_interest": 61, + "right": "call", + "strike": 30.0, + "volume": 31 + }, + { + "ask": 236.0, + "bid": 231.0, + "contract_symbol": "AAPL281215C00035000", + "last": 221.31, + "open_interest": 1, + "right": "call", + "strike": 35.0, + "volume": null + }, + { + "ask": 275.5, + "bid": 270.5, + "contract_symbol": "AAPL281215C00040000", + "last": 244.55, + "open_interest": 9, + "right": "call", + "strike": 40.0, + "volume": 1 + }, + { + "ask": 293.0, + "bid": 288.0, + "contract_symbol": "AAPL281215C00045000", + "last": 267.35, + "open_interest": 6, + "right": "call", + "strike": 45.0, + "volume": 6 + }, + { + "ask": 288.5, + "bid": 284.45, + "contract_symbol": "AAPL281215C00050000", + "last": 286.45, + "open_interest": 1357, + "right": "call", + "strike": 50.0, + "volume": 7 + }, + { + "ask": 279.5, + "bid": 274.5, + "contract_symbol": "AAPL281215C00060000", + "last": 250.75, + "open_interest": 201, + "right": "call", + "strike": 60.0, + "volume": 3 + }, + { + "ask": 271.0, + "bid": 266.0, + "contract_symbol": "AAPL281215C00070000", + "last": 248.49, + "open_interest": 257, + "right": "call", + "strike": 70.0, + "volume": 1 + }, + { + "ask": 262.0, + "bid": 257.0, + "contract_symbol": "AAPL281215C00080000", + "last": 242.45, + "open_interest": 265, + "right": "call", + "strike": 80.0, + "volume": 106 + }, + { + "ask": 253.0, + "bid": 248.0, + "contract_symbol": "AAPL281215C00090000", + "last": 234.45, + "open_interest": 198, + "right": "call", + "strike": 90.0, + "volume": 184 + }, + { + "ask": 244.5, + "bid": 239.5, + "contract_symbol": "AAPL281215C00100000", + "last": 242.45, + "open_interest": 2082, + "right": "call", + "strike": 100.0, + "volume": 24 + }, + { + "ask": 236.0, + "bid": 231.0, + "contract_symbol": "AAPL281215C00110000", + "last": 220.15, + "open_interest": 1267, + "right": "call", + "strike": 110.0, + "volume": 4 + }, + { + "ask": 227.0, + "bid": 222.0, + "contract_symbol": "AAPL281215C00120000", + "last": 207.75, + "open_interest": 201, + "right": "call", + "strike": 120.0, + "volume": 2 + }, + { + "ask": 223.0, + "bid": 218.0, + "contract_symbol": "AAPL281215C00125000", + "last": 211.75, + "open_interest": 87, + "right": "call", + "strike": 125.0, + "volume": 2 + }, + { + "ask": 218.5, + "bid": 213.5, + "contract_symbol": "AAPL281215C00130000", + "last": 208.0, + "open_interest": 108, + "right": "call", + "strike": 130.0, + "volume": 1 + }, + { + "ask": 214.5, + "bid": 209.5, + "contract_symbol": "AAPL281215C00135000", + "last": 186.95, + "open_interest": 54, + "right": "call", + "strike": 135.0, + "volume": 20 + }, + { + "ask": 210.0, + "bid": 205.0, + "contract_symbol": "AAPL281215C00140000", + "last": 187.85, + "open_interest": 158, + "right": "call", + "strike": 140.0, + "volume": 4 + }, + { + "ask": 206.0, + "bid": 201.0, + "contract_symbol": "AAPL281215C00145000", + "last": 198.09, + "open_interest": 216, + "right": "call", + "strike": 145.0, + "volume": 1 + }, + { + "ask": 201.5, + "bid": 196.5, + "contract_symbol": "AAPL281215C00150000", + "last": 202.0, + "open_interest": 635, + "right": "call", + "strike": 150.0, + "volume": 1 + }, + { + "ask": 197.5, + "bid": 192.5, + "contract_symbol": "AAPL281215C00155000", + "last": 190.95, + "open_interest": 133, + "right": "call", + "strike": 155.0, + "volume": 1 + }, + { + "ask": 193.0, + "bid": 188.5, + "contract_symbol": "AAPL281215C00160000", + "last": 191.03, + "open_interest": 279, + "right": "call", + "strike": 160.0, + "volume": 26 + }, + { + "ask": 189.0, + "bid": 184.0, + "contract_symbol": "AAPL281215C00165000", + "last": 179.03, + "open_interest": 304, + "right": "call", + "strike": 165.0, + "volume": 1 + }, + { + "ask": 185.0, + "bid": 180.0, + "contract_symbol": "AAPL281215C00170000", + "last": 170.0, + "open_interest": 123, + "right": "call", + "strike": 170.0, + "volume": 1 + }, + { + "ask": 181.0, + "bid": 176.4, + "contract_symbol": "AAPL281215C00175000", + "last": 181.0, + "open_interest": 562, + "right": "call", + "strike": 175.0, + "volume": 1 + }, + { + "ask": 176.85, + "bid": 172.95, + "contract_symbol": "AAPL281215C00180000", + "last": 175.0, + "open_interest": 492, + "right": "call", + "strike": 180.0, + "volume": 1 + }, + { + "ask": 173.0, + "bid": 168.5, + "contract_symbol": "AAPL281215C00185000", + "last": 154.09, + "open_interest": 174, + "right": "call", + "strike": 185.0, + "volume": 9 + }, + { + "ask": 168.75, + "bid": 164.5, + "contract_symbol": "AAPL281215C00190000", + "last": 168.67, + "open_interest": 1177, + "right": "call", + "strike": 190.0, + "volume": 6 + }, + { + "ask": 165.0, + "bid": 160.5, + "contract_symbol": "AAPL281215C00195000", + "last": 165.36, + "open_interest": 1797, + "right": "call", + "strike": 195.0, + "volume": 2 + }, + { + "ask": 160.55, + "bid": 157.45, + "contract_symbol": "AAPL281215C00200000", + "last": 159.6, + "open_interest": 2183, + "right": "call", + "strike": 200.0, + "volume": 17 + }, + { + "ask": 151.8, + "bid": 149.0, + "contract_symbol": "AAPL281215C00210000", + "last": 151.5, + "open_interest": 577, + "right": "call", + "strike": 210.0, + "volume": 2 + }, + { + "ask": 145.0, + "bid": 141.05, + "contract_symbol": "AAPL281215C00220000", + "last": 143.91, + "open_interest": 1534, + "right": "call", + "strike": 220.0, + "volume": 4 + }, + { + "ask": 137.55, + "bid": 134.35, + "contract_symbol": "AAPL281215C00230000", + "last": 136.88, + "open_interest": 947, + "right": "call", + "strike": 230.0, + "volume": 2 + }, + { + "ask": 130.85, + "bid": 127.1, + "contract_symbol": "AAPL281215C00240000", + "last": 130.02, + "open_interest": 1807, + "right": "call", + "strike": 240.0, + "volume": 11 + }, + { + "ask": 123.0, + "bid": 120.65, + "contract_symbol": "AAPL281215C00250000", + "last": 122.22, + "open_interest": 2936, + "right": "call", + "strike": 250.0, + "volume": 53 + }, + { + "ask": 116.7, + "bid": 113.95, + "contract_symbol": "AAPL281215C00260000", + "last": 115.45, + "open_interest": 1491, + "right": "call", + "strike": 260.0, + "volume": 36 + }, + { + "ask": 109.5, + "bid": 107.0, + "contract_symbol": "AAPL281215C00270000", + "last": 109.98, + "open_interest": 2278, + "right": "call", + "strike": 270.0, + "volume": 12 + }, + { + "ask": 103.85, + "bid": 100.45, + "contract_symbol": "AAPL281215C00280000", + "last": 103.6, + "open_interest": 2184, + "right": "call", + "strike": 280.0, + "volume": 30 + }, + { + "ask": 97.9, + "bid": 94.8, + "contract_symbol": "AAPL281215C00290000", + "last": 96.0, + "open_interest": 1930, + "right": "call", + "strike": 290.0, + "volume": 19 + }, + { + "ask": 91.85, + "bid": 88.6, + "contract_symbol": "AAPL281215C00300000", + "last": 90.5, + "open_interest": 3889, + "right": "call", + "strike": 300.0, + "volume": 70 + }, + { + "ask": 86.5, + "bid": 82.2, + "contract_symbol": "AAPL281215C00310000", + "last": 84.75, + "open_interest": 3046, + "right": "call", + "strike": 310.0, + "volume": 52 + }, + { + "ask": 80.6, + "bid": 77.9, + "contract_symbol": "AAPL281215C00320000", + "last": 79.8, + "open_interest": 3699, + "right": "call", + "strike": 320.0, + "volume": 45 + }, + { + "ask": 75.15, + "bid": 73.45, + "contract_symbol": "AAPL281215C00330000", + "last": 74.65, + "open_interest": 3085, + "right": "call", + "strike": 330.0, + "volume": 162 + }, + { + "ask": 70.25, + "bid": 68.75, + "contract_symbol": "AAPL281215C00340000", + "last": 69.75, + "open_interest": 8056, + "right": "call", + "strike": 340.0, + "volume": 117 + }, + { + "ask": 65.5, + "bid": 64.3, + "contract_symbol": "AAPL281215C00350000", + "last": 64.81, + "open_interest": 4260, + "right": "call", + "strike": 350.0, + "volume": 98 + }, + { + "ask": 61.25, + "bid": 59.9, + "contract_symbol": "AAPL281215C00360000", + "last": 60.71, + "open_interest": 1605, + "right": "call", + "strike": 360.0, + "volume": 66 + }, + { + "ask": 57.2, + "bid": 55.1, + "contract_symbol": "AAPL281215C00370000", + "last": 56.8, + "open_interest": 1322, + "right": "call", + "strike": 370.0, + "volume": 598 + }, + { + "ask": 53.35, + "bid": 51.2, + "contract_symbol": "AAPL281215C00380000", + "last": 53.53, + "open_interest": 1131, + "right": "call", + "strike": 380.0, + "volume": 54 + }, + { + "ask": 49.6, + "bid": 47.55, + "contract_symbol": "AAPL281215C00390000", + "last": 49.3, + "open_interest": 2927, + "right": "call", + "strike": 390.0, + "volume": 11 + }, + { + "ask": 46.8, + "bid": 45.0, + "contract_symbol": "AAPL281215C00400000", + "last": 45.66, + "open_interest": 4866, + "right": "call", + "strike": 400.0, + "volume": 123 + }, + { + "ask": 42.95, + "bid": 41.9, + "contract_symbol": "AAPL281215C00410000", + "last": 42.41, + "open_interest": 1075, + "right": "call", + "strike": 410.0, + "volume": 36 + }, + { + "ask": 40.45, + "bid": 37.0, + "contract_symbol": "AAPL281215C00420000", + "last": 40.5, + "open_interest": 902, + "right": "call", + "strike": 420.0, + "volume": 19 + }, + { + "ask": 38.15, + "bid": 34.0, + "contract_symbol": "AAPL281215C00430000", + "last": 33.39, + "open_interest": 1518, + "right": "call", + "strike": 430.0, + "volume": 96 + }, + { + "ask": 34.6, + "bid": 31.5, + "contract_symbol": "AAPL281215C00440000", + "last": 34.1, + "open_interest": 1192, + "right": "call", + "strike": 440.0, + "volume": 63 + }, + { + "ask": 32.0, + "bid": 30.5, + "contract_symbol": "AAPL281215C00450000", + "last": 31.7, + "open_interest": 4045, + "right": "call", + "strike": 450.0, + "volume": 526 + }, + { + "ask": 30.3, + "bid": 27.0, + "contract_symbol": "AAPL281215C00460000", + "last": 28.3, + "open_interest": 592, + "right": "call", + "strike": 460.0, + "volume": 1 + }, + { + "ask": 28.05, + "bid": 25.65, + "contract_symbol": "AAPL281215C00470000", + "last": 27.58, + "open_interest": 426, + "right": "call", + "strike": 470.0, + "volume": 5 + }, + { + "ask": 26.2, + "bid": 24.4, + "contract_symbol": "AAPL281215C00480000", + "last": 21.5, + "open_interest": 396, + "right": "call", + "strike": 480.0, + "volume": 7 + }, + { + "ask": 24.1, + "bid": 22.55, + "contract_symbol": "AAPL281215C00490000", + "last": 18.57, + "open_interest": 322, + "right": "call", + "strike": 490.0, + "volume": 1 + }, + { + "ask": 22.3, + "bid": 21.0, + "contract_symbol": "AAPL281215C00500000", + "last": 22.0, + "open_interest": 2180, + "right": "call", + "strike": 500.0, + "volume": 68 + }, + { + "ask": 20.7, + "bid": 18.7, + "contract_symbol": "AAPL281215C00510000", + "last": 20.6, + "open_interest": 313, + "right": "call", + "strike": 510.0, + "volume": 1 + }, + { + "ask": 19.25, + "bid": 17.65, + "contract_symbol": "AAPL281215C00520000", + "last": 18.73, + "open_interest": 1903, + "right": "call", + "strike": 520.0, + "volume": 106 + }, + { + "ask": 17.8, + "bid": 16.5, + "contract_symbol": "AAPL281215C00530000", + "last": 17.49, + "open_interest": 161, + "right": "call", + "strike": 530.0, + "volume": 15 + }, + { + "ask": 16.5, + "bid": 15.2, + "contract_symbol": "AAPL281215C00540000", + "last": 16.35, + "open_interest": 80, + "right": "call", + "strike": 540.0, + "volume": 3 + }, + { + "ask": 15.05, + "bid": 14.2, + "contract_symbol": "AAPL281215C00550000", + "last": 15.32, + "open_interest": 363, + "right": "call", + "strike": 550.0, + "volume": 58 + }, + { + "ask": 14.0, + "bid": 12.75, + "contract_symbol": "AAPL281215C00560000", + "last": 11.91, + "open_interest": 94, + "right": "call", + "strike": 560.0, + "volume": 1 + }, + { + "ask": 13.0, + "bid": 11.95, + "contract_symbol": "AAPL281215C00570000", + "last": 13.35, + "open_interest": 48, + "right": "call", + "strike": 570.0, + "volume": 1 + }, + { + "ask": 12.35, + "bid": 11.15, + "contract_symbol": "AAPL281215C00580000", + "last": 11.97, + "open_interest": 153, + "right": "call", + "strike": 580.0, + "volume": 10 + }, + { + "ask": 11.7, + "bid": 10.15, + "contract_symbol": "AAPL281215C00590000", + "last": 11.09, + "open_interest": 205, + "right": "call", + "strike": 590.0, + "volume": 11 + }, + { + "ask": 10.4, + "bid": 9.8, + "contract_symbol": "AAPL281215C00600000", + "last": 10.21, + "open_interest": 4706, + "right": "call", + "strike": 600.0, + "volume": 141 + }, + { + "ask": 0.06, + "bid": 0.0, + "contract_symbol": "AAPL281215P00005000", + "last": 0.01, + "open_interest": 855, + "right": "put", + "strike": 5.0, + "volume": 1 + }, + { + "ask": 0.2, + "bid": 0.0, + "contract_symbol": "AAPL281215P00010000", + "last": 0.02, + "open_interest": 26, + "right": "put", + "strike": 10.0, + "volume": 1 + }, + { + "ask": 2.74, + "bid": 0.0, + "contract_symbol": "AAPL281215P00015000", + "last": 0.04, + "open_interest": 6, + "right": "put", + "strike": 15.0, + "volume": 28 + }, + { + "ask": 1.85, + "bid": 0.0, + "contract_symbol": "AAPL281215P00020000", + "last": 0.06, + "open_interest": 54, + "right": "put", + "strike": 20.0, + "volume": 2 + }, + { + "ask": 1.5, + "bid": 0.0, + "contract_symbol": "AAPL281215P00025000", + "last": 0.05, + "open_interest": 92, + "right": "put", + "strike": 25.0, + "volume": 2 + }, + { + "ask": 1.0, + "bid": 0.0, + "contract_symbol": "AAPL281215P00030000", + "last": 0.39, + "open_interest": 76, + "right": "put", + "strike": 30.0, + "volume": 1 + }, + { + "ask": 0.35, + "bid": 0.0, + "contract_symbol": "AAPL281215P00035000", + "last": 0.07, + "open_interest": 426, + "right": "put", + "strike": 35.0, + "volume": 10 + }, + { + "ask": 1.83, + "bid": 0.0, + "contract_symbol": "AAPL281215P00040000", + "last": 0.1, + "open_interest": 1107, + "right": "put", + "strike": 40.0, + "volume": 2 + }, + { + "ask": 4.35, + "bid": 0.0, + "contract_symbol": "AAPL281215P00045000", + "last": 0.11, + "open_interest": 74, + "right": "put", + "strike": 45.0, + "volume": 50 + }, + { + "ask": 1.0, + "bid": 0.0, + "contract_symbol": "AAPL281215P00050000", + "last": 0.15, + "open_interest": 356, + "right": "put", + "strike": 50.0, + "volume": 50 + }, + { + "ask": 1.25, + "bid": 0.01, + "contract_symbol": "AAPL281215P00060000", + "last": 0.27, + "open_interest": 741, + "right": "put", + "strike": 60.0, + "volume": 31 + }, + { + "ask": 0.73, + "bid": 0.15, + "contract_symbol": "AAPL281215P00070000", + "last": 0.43, + "open_interest": 63, + "right": "put", + "strike": 70.0, + "volume": 1 + }, + { + "ask": 0.72, + "bid": 0.01, + "contract_symbol": "AAPL281215P00080000", + "last": 0.54, + "open_interest": 136, + "right": "put", + "strike": 80.0, + "volume": 5 + }, + { + "ask": 0.79, + "bid": 0.18, + "contract_symbol": "AAPL281215P00090000", + "last": 0.56, + "open_interest": 184, + "right": "put", + "strike": 90.0, + "volume": 1 + }, + { + "ask": 0.85, + "bid": 0.55, + "contract_symbol": "AAPL281215P00100000", + "last": 0.7, + "open_interest": 1114, + "right": "put", + "strike": 100.0, + "volume": 77 + }, + { + "ask": 1.1, + "bid": 0.94, + "contract_symbol": "AAPL281215P00110000", + "last": 0.96, + "open_interest": 723, + "right": "put", + "strike": 110.0, + "volume": 1 + }, + { + "ask": 1.45, + "bid": 1.21, + "contract_symbol": "AAPL281215P00120000", + "last": 1.35, + "open_interest": 168, + "right": "put", + "strike": 120.0, + "volume": 1 + }, + { + "ask": 1.56, + "bid": 0.67, + "contract_symbol": "AAPL281215P00125000", + "last": 1.5, + "open_interest": 49, + "right": "put", + "strike": 125.0, + "volume": 1 + }, + { + "ask": 1.74, + "bid": 0.83, + "contract_symbol": "AAPL281215P00130000", + "last": 1.98, + "open_interest": 323, + "right": "put", + "strike": 130.0, + "volume": 1 + }, + { + "ask": 3.45, + "bid": 1.09, + "contract_symbol": "AAPL281215P00135000", + "last": 1.95, + "open_interest": 295, + "right": "put", + "strike": 135.0, + "volume": 1 + }, + { + "ask": 2.45, + "bid": 1.56, + "contract_symbol": "AAPL281215P00140000", + "last": 2.25, + "open_interest": 498, + "right": "put", + "strike": 140.0, + "volume": 1 + }, + { + "ask": 2.9, + "bid": 1.35, + "contract_symbol": "AAPL281215P00145000", + "last": 2.8, + "open_interest": 447, + "right": "put", + "strike": 145.0, + "volume": 3 + }, + { + "ask": 3.35, + "bid": 2.1, + "contract_symbol": "AAPL281215P00150000", + "last": 2.57, + "open_interest": 847, + "right": "put", + "strike": 150.0, + "volume": 3 + }, + { + "ask": 4.05, + "bid": 1.63, + "contract_symbol": "AAPL281215P00155000", + "last": 3.1, + "open_interest": 482, + "right": "put", + "strike": 155.0, + "volume": 1 + }, + { + "ask": 4.5, + "bid": 2.64, + "contract_symbol": "AAPL281215P00160000", + "last": 3.35, + "open_interest": 595, + "right": "put", + "strike": 160.0, + "volume": 102 + }, + { + "ask": 4.9, + "bid": 2.47, + "contract_symbol": "AAPL281215P00165000", + "last": 3.55, + "open_interest": 559, + "right": "put", + "strike": 165.0, + "volume": 1 + }, + { + "ask": 5.45, + "bid": 2.9, + "contract_symbol": "AAPL281215P00170000", + "last": 3.8, + "open_interest": 813, + "right": "put", + "strike": 170.0, + "volume": 5 + }, + { + "ask": 4.85, + "bid": 3.4, + "contract_symbol": "AAPL281215P00175000", + "last": 4.8, + "open_interest": 325, + "right": "put", + "strike": 175.0, + "volume": 1 + }, + { + "ask": 5.85, + "bid": 4.0, + "contract_symbol": "AAPL281215P00180000", + "last": 5.02, + "open_interest": 2202, + "right": "put", + "strike": 180.0, + "volume": 10 + }, + { + "ask": 6.2, + "bid": 3.75, + "contract_symbol": "AAPL281215P00185000", + "last": 5.27, + "open_interest": 430, + "right": "put", + "strike": 185.0, + "volume": 1 + }, + { + "ask": 6.75, + "bid": 5.25, + "contract_symbol": "AAPL281215P00190000", + "last": 5.6, + "open_interest": 2019, + "right": "put", + "strike": 190.0, + "volume": 10 + }, + { + "ask": 6.9, + "bid": 5.6, + "contract_symbol": "AAPL281215P00195000", + "last": 6.47, + "open_interest": 798, + "right": "put", + "strike": 195.0, + "volume": 1 + }, + { + "ask": 6.9, + "bid": 6.35, + "contract_symbol": "AAPL281215P00200000", + "last": 6.55, + "open_interest": 3994, + "right": "put", + "strike": 200.0, + "volume": 25 + }, + { + "ask": 10.05, + "bid": 7.25, + "contract_symbol": "AAPL281215P00210000", + "last": 7.8, + "open_interest": 2093, + "right": "put", + "strike": 210.0, + "volume": 28 + }, + { + "ask": 11.55, + "bid": 8.95, + "contract_symbol": "AAPL281215P00220000", + "last": 9.2, + "open_interest": 2091, + "right": "put", + "strike": 220.0, + "volume": 10 + }, + { + "ask": 11.25, + "bid": 10.7, + "contract_symbol": "AAPL281215P00230000", + "last": 11.1, + "open_interest": 1102, + "right": "put", + "strike": 230.0, + "volume": 13 + }, + { + "ask": 13.2, + "bid": 12.45, + "contract_symbol": "AAPL281215P00240000", + "last": 12.86, + "open_interest": 1565, + "right": "put", + "strike": 240.0, + "volume": 15 + }, + { + "ask": 15.5, + "bid": 14.6, + "contract_symbol": "AAPL281215P00250000", + "last": 14.9, + "open_interest": 1819, + "right": "put", + "strike": 250.0, + "volume": 60 + }, + { + "ask": 18.65, + "bid": 16.9, + "contract_symbol": "AAPL281215P00260000", + "last": 17.23, + "open_interest": 10014, + "right": "put", + "strike": 260.0, + "volume": 83 + }, + { + "ask": 20.5, + "bid": 19.6, + "contract_symbol": "AAPL281215P00270000", + "last": 19.88, + "open_interest": 775, + "right": "put", + "strike": 270.0, + "volume": 19 + }, + { + "ask": 23.05, + "bid": 22.45, + "contract_symbol": "AAPL281215P00280000", + "last": 22.76, + "open_interest": 2788, + "right": "put", + "strike": 280.0, + "volume": 18 + }, + { + "ask": 27.45, + "bid": 25.4, + "contract_symbol": "AAPL281215P00290000", + "last": 25.93, + "open_interest": 889, + "right": "put", + "strike": 290.0, + "volume": 20 + }, + { + "ask": 30.7, + "bid": 28.55, + "contract_symbol": "AAPL281215P00300000", + "last": 29.6, + "open_interest": 2428, + "right": "put", + "strike": 300.0, + "volume": 24 + }, + { + "ask": 34.8, + "bid": 32.55, + "contract_symbol": "AAPL281215P00310000", + "last": 32.46, + "open_interest": 1254, + "right": "put", + "strike": 310.0, + "volume": 22 + }, + { + "ask": 38.2, + "bid": 36.9, + "contract_symbol": "AAPL281215P00320000", + "last": 37.6, + "open_interest": 946, + "right": "put", + "strike": 320.0, + "volume": 52 + }, + { + "ask": 42.85, + "bid": 41.05, + "contract_symbol": "AAPL281215P00330000", + "last": 41.9, + "open_interest": 1286, + "right": "put", + "strike": 330.0, + "volume": 82 + }, + { + "ask": 49.15, + "bid": 46.25, + "contract_symbol": "AAPL281215P00340000", + "last": 46.4, + "open_interest": 285, + "right": "put", + "strike": 340.0, + "volume": 45 + }, + { + "ask": 52.4, + "bid": 51.1, + "contract_symbol": "AAPL281215P00350000", + "last": 51.56, + "open_interest": 580, + "right": "put", + "strike": 350.0, + "volume": 11 + }, + { + "ask": 57.8, + "bid": 56.75, + "contract_symbol": "AAPL281215P00360000", + "last": 57.2, + "open_interest": 1554, + "right": "put", + "strike": 360.0, + "volume": 67 + }, + { + "ask": 64.55, + "bid": 60.65, + "contract_symbol": "AAPL281215P00370000", + "last": 63.1, + "open_interest": 86, + "right": "put", + "strike": 370.0, + "volume": 91 + }, + { + "ask": 71.15, + "bid": 67.65, + "contract_symbol": "AAPL281215P00380000", + "last": 69.0, + "open_interest": 297, + "right": "put", + "strike": 380.0, + "volume": 98 + }, + { + "ask": 76.35, + "bid": 74.5, + "contract_symbol": "AAPL281215P00390000", + "last": 74.45, + "open_interest": 64, + "right": "put", + "strike": 390.0, + "volume": 42 + }, + { + "ask": 83.6, + "bid": 80.8, + "contract_symbol": "AAPL281215P00400000", + "last": 82.2, + "open_interest": 91, + "right": "put", + "strike": 400.0, + "volume": 47 + }, + { + "ask": 90.65, + "bid": 87.65, + "contract_symbol": "AAPL281215P00410000", + "last": 88.75, + "open_interest": 17, + "right": "put", + "strike": 410.0, + "volume": 4 + }, + { + "ask": 99.5, + "bid": 94.8, + "contract_symbol": "AAPL281215P00420000", + "last": 96.03, + "open_interest": 19, + "right": "put", + "strike": 420.0, + "volume": 3 + }, + { + "ask": 107.0, + "bid": 102.0, + "contract_symbol": "AAPL281215P00430000", + "last": 106.85, + "open_interest": 10, + "right": "put", + "strike": 430.0, + "volume": 2 + }, + { + "ask": 115.0, + "bid": 110.5, + "contract_symbol": "AAPL281215P00440000", + "last": 129.98, + "open_interest": 7, + "right": "put", + "strike": 440.0, + "volume": 2 + }, + { + "ask": 122.85, + "bid": 118.5, + "contract_symbol": "AAPL281215P00450000", + "last": 118.41, + "open_interest": 5, + "right": "put", + "strike": 450.0, + "volume": 3 + }, + { + "ask": 132.5, + "bid": 127.5, + "contract_symbol": "AAPL281215P00460000", + "last": 148.54, + "open_interest": 1, + "right": "put", + "strike": 460.0, + "volume": 2 + }, + { + "ask": 140.0, + "bid": 136.5, + "contract_symbol": "AAPL281215P00470000", + "last": 158.23, + "open_interest": 0, + "right": "put", + "strike": 470.0, + "volume": 2 + }, + { + "ask": 191.0, + "bid": 186.0, + "contract_symbol": "AAPL281215P00490000", + "last": 216.8, + "open_interest": 0, + "right": "put", + "strike": 490.0, + "volume": null + }, + { + "ask": 170.5, + "bid": 165.5, + "contract_symbol": "AAPL281215P00500000", + "last": 196.8, + "open_interest": 0, + "right": "put", + "strike": 500.0, + "volume": 50 + }, + { + "ask": 180.5, + "bid": 175.5, + "contract_symbol": "AAPL281215P00510000", + "last": 177.95, + "open_interest": 0, + "right": "put", + "strike": 510.0, + "volume": 5 + }, + { + "ask": 190.5, + "bid": 185.5, + "contract_symbol": "AAPL281215P00520000", + "last": 191.8, + "open_interest": 0, + "right": "put", + "strike": 520.0, + "volume": 3 + }, + { + "ask": 200.5, + "bid": 195.5, + "contract_symbol": "AAPL281215P00530000", + "last": 205.0, + "open_interest": 0, + "right": "put", + "strike": 530.0, + "volume": 2 + }, + { + "ask": 210.5, + "bid": 205.5, + "contract_symbol": "AAPL281215P00540000", + "last": 207.0, + "open_interest": 0, + "right": "put", + "strike": 540.0, + "volume": 2 + }, + { + "ask": 220.5, + "bid": 215.5, + "contract_symbol": "AAPL281215P00550000", + "last": 224.2, + "open_interest": 0, + "right": "put", + "strike": 550.0, + "volume": 1 + }, + { + "ask": 262.0, + "bid": 257.0, + "contract_symbol": "AAPL281215P00570000", + "last": 241.93, + "open_interest": 0, + "right": "put", + "strike": 570.0, + "volume": null + }, + { + "ask": 272.0, + "bid": 267.0, + "contract_symbol": "AAPL281215P00580000", + "last": 252.6, + "open_interest": 0, + "right": "put", + "strike": 580.0, + "volume": 6 + }, + { + "ask": 282.0, + "bid": 277.0, + "contract_symbol": "AAPL281215P00590000", + "last": 264.5, + "open_interest": 0, + "right": "put", + "strike": 590.0, + "volume": null + }, + { + "ask": 292.0, + "bid": 287.0, + "contract_symbol": "AAPL281215P00600000", + "last": 318.86, + "open_interest": 0, + "right": "put", + "strike": 600.0, + "volume": 1 + } + ] + }, + "currency": "USD", + "previous_close": 325.7986, + "spot": 332.2699890136719 + }, + "SPY": { + "chains": { + "2026-09-14": [ + { + "ask": 66.36, + "bid": 62.79, + "contract_symbol": "SPY260914C00700000", + "last": 65.12, + "open_interest": 20, + "right": "call", + "strike": 700.0, + "volume": 638 + }, + { + "ask": 49.54, + "bid": 49.39, + "contract_symbol": "SPY260914C00715000", + "last": 50.23, + "open_interest": 14, + "right": "call", + "strike": 715.0, + "volume": 27 + }, + { + "ask": 44.55, + "bid": 44.39, + "contract_symbol": "SPY260914C00720000", + "last": 45.29, + "open_interest": 50, + "right": "call", + "strike": 720.0, + "volume": 33 + }, + { + "ask": 39.55, + "bid": 39.4, + "contract_symbol": "SPY260914C00725000", + "last": 39.28, + "open_interest": 59, + "right": "call", + "strike": 725.0, + "volume": 15 + }, + { + "ask": 34.56, + "bid": 34.4, + "contract_symbol": "SPY260914C00730000", + "last": 35.72, + "open_interest": 150, + "right": "call", + "strike": 730.0, + "volume": 5 + }, + { + "ask": 29.57, + "bid": 29.41, + "contract_symbol": "SPY260914C00735000", + "last": 29.32, + "open_interest": 193, + "right": "call", + "strike": 735.0, + "volume": 63 + }, + { + "ask": 24.57, + "bid": 24.42, + "contract_symbol": "SPY260914C00740000", + "last": 24.42, + "open_interest": 271, + "right": "call", + "strike": 740.0, + "volume": 113 + }, + { + "ask": 19.59, + "bid": 19.44, + "contract_symbol": "SPY260914C00745000", + "last": 19.77, + "open_interest": 556, + "right": "call", + "strike": 745.0, + "volume": 122 + }, + { + "ask": 14.63, + "bid": 14.49, + "contract_symbol": "SPY260914C00750000", + "last": 14.62, + "open_interest": 1133, + "right": "call", + "strike": 750.0, + "volume": 828 + }, + { + "ask": 9.77, + "bid": 9.64, + "contract_symbol": "SPY260914C00755000", + "last": 9.64, + "open_interest": 1101, + "right": "call", + "strike": 755.0, + "volume": 552 + }, + { + "ask": 5.2, + "bid": 5.13, + "contract_symbol": "SPY260914C00760000", + "last": 5.17, + "open_interest": 2780, + "right": "call", + "strike": 760.0, + "volume": 4110 + }, + { + "ask": 1.65, + "bid": 1.63, + "contract_symbol": "SPY260914C00765000", + "last": 1.64, + "open_interest": 6907, + "right": "call", + "strike": 765.0, + "volume": 85046 + }, + { + "ask": 0.23, + "bid": 0.22, + "contract_symbol": "SPY260914C00770000", + "last": 0.23, + "open_interest": 4915, + "right": "call", + "strike": 770.0, + "volume": 29884 + }, + { + "ask": 0.03, + "bid": 0.02, + "contract_symbol": "SPY260914C00775000", + "last": 0.03, + "open_interest": 5403, + "right": "call", + "strike": 775.0, + "volume": 9525 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00780000", + "last": 0.01, + "open_interest": 3792, + "right": "call", + "strike": 780.0, + "volume": 995 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00785000", + "last": 0.01, + "open_interest": 3801, + "right": "call", + "strike": 785.0, + "volume": 566 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00790000", + "last": 0.01, + "open_interest": 2306, + "right": "call", + "strike": 790.0, + "volume": 105 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00795000", + "last": 0.01, + "open_interest": 3710, + "right": "call", + "strike": 795.0, + "volume": 6 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00800000", + "last": 0.01, + "open_interest": 5619, + "right": "call", + "strike": 800.0, + "volume": 50 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00805000", + "last": 0.01, + "open_interest": 534, + "right": "call", + "strike": 805.0, + "volume": 56 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00810000", + "last": 0.01, + "open_interest": 218, + "right": "call", + "strike": 810.0, + "volume": 258 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00815000", + "last": 0.01, + "open_interest": 242, + "right": "call", + "strike": 815.0, + "volume": 5 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00820000", + "last": 0.01, + "open_interest": 373, + "right": "call", + "strike": 820.0, + "volume": 360 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00825000", + "last": 0.01, + "open_interest": 45, + "right": "call", + "strike": 825.0, + "volume": 40 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00830000", + "last": 0.02, + "open_interest": 1013, + "right": "call", + "strike": 830.0, + "volume": 4 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00835000", + "last": 0.01, + "open_interest": 22, + "right": "call", + "strike": 835.0, + "volume": 13 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00840000", + "last": 0.01, + "open_interest": 2344, + "right": "call", + "strike": 840.0, + "volume": 40 + }, + { + "ask": 0.01, + "bid": 0.0, + "contract_symbol": "SPY260914C00845000", + "last": 0.01, + "open_interest": 120, + "right": "call", + "strike": 845.0, + "volume": 7 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY260914P00700000", + "last": 0.03, + "open_interest": 20060, + "right": "put", + "strike": 700.0, + "volume": 3877 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY260914P00705000", + "last": 0.03, + "open_interest": 577, + "right": "put", + "strike": 705.0, + "volume": 315 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY260914P00710000", + "last": 0.04, + "open_interest": 1220, + "right": "put", + "strike": 710.0, + "volume": 268 + }, + { + "ask": 0.05, + "bid": 0.04, + "contract_symbol": "SPY260914P00715000", + "last": 0.04, + "open_interest": 592, + "right": "put", + "strike": 715.0, + "volume": 1513 + }, + { + "ask": 0.05, + "bid": 0.04, + "contract_symbol": "SPY260914P00720000", + "last": 0.04, + "open_interest": 2107, + "right": "put", + "strike": 720.0, + "volume": 6767 + }, + { + "ask": 0.06, + "bid": 0.05, + "contract_symbol": "SPY260914P00725000", + "last": 0.04, + "open_interest": 2394, + "right": "put", + "strike": 725.0, + "volume": 3996 + }, + { + "ask": 0.06, + "bid": 0.05, + "contract_symbol": "SPY260914P00730000", + "last": 0.06, + "open_interest": 5273, + "right": "put", + "strike": 730.0, + "volume": 1664 + }, + { + "ask": 0.07, + "bid": 0.06, + "contract_symbol": "SPY260914P00735000", + "last": 0.06, + "open_interest": 2672, + "right": "put", + "strike": 735.0, + "volume": 2395 + }, + { + "ask": 0.08, + "bid": 0.07, + "contract_symbol": "SPY260914P00740000", + "last": 0.06, + "open_interest": 2703, + "right": "put", + "strike": 740.0, + "volume": 890 + }, + { + "ask": 0.09, + "bid": 0.08, + "contract_symbol": "SPY260914P00745000", + "last": 0.09, + "open_interest": 12002, + "right": "put", + "strike": 745.0, + "volume": 2554 + }, + { + "ask": 0.14, + "bid": 0.13, + "contract_symbol": "SPY260914P00750000", + "last": 0.14, + "open_interest": 5835, + "right": "put", + "strike": 750.0, + "volume": 6903 + }, + { + "ask": 0.29, + "bid": 0.28, + "contract_symbol": "SPY260914P00755000", + "last": 0.29, + "open_interest": 4101, + "right": "put", + "strike": 755.0, + "volume": 13465 + }, + { + "ask": 0.74, + "bid": 0.73, + "contract_symbol": "SPY260914P00760000", + "last": 0.74, + "open_interest": 3086, + "right": "put", + "strike": 760.0, + "volume": 35158 + }, + { + "ask": 2.23, + "bid": 2.2, + "contract_symbol": "SPY260914P00765000", + "last": 2.21, + "open_interest": 2511, + "right": "put", + "strike": 765.0, + "volume": 129614 + }, + { + "ask": 5.84, + "bid": 5.76, + "contract_symbol": "SPY260914P00770000", + "last": 5.8, + "open_interest": 1582, + "right": "put", + "strike": 770.0, + "volume": 3436 + }, + { + "ask": 10.73, + "bid": 10.58, + "contract_symbol": "SPY260914P00775000", + "last": 10.79, + "open_interest": 13, + "right": "put", + "strike": 775.0, + "volume": 596 + }, + { + "ask": 15.74, + "bid": 15.58, + "contract_symbol": "SPY260914P00780000", + "last": 15.69, + "open_interest": 15, + "right": "put", + "strike": 780.0, + "volume": 462 + }, + { + "ask": 22.3, + "bid": 19.37, + "contract_symbol": "SPY260914P00785000", + "last": 19.32, + "open_interest": 10, + "right": "put", + "strike": 785.0, + "volume": 106 + }, + { + "ask": 27.31, + "bid": 24.57, + "contract_symbol": "SPY260914P00790000", + "last": 25.48, + "open_interest": 8, + "right": "put", + "strike": 790.0, + "volume": 184 + }, + { + "ask": 32.31, + "bid": 28.81, + "contract_symbol": "SPY260914P00795000", + "last": 36.99, + "open_interest": 0, + "right": "put", + "strike": 795.0, + "volume": 34 + }, + { + "ask": 36.0, + "bid": 34.21, + "contract_symbol": "SPY260914P00800000", + "last": 35.6, + "open_interest": 52, + "right": "put", + "strike": 800.0, + "volume": 450 + } + ], + "2026-12-18": [ + { + "ask": 615.8, + "bid": 612.55, + "contract_symbol": "SPY261218C00150000", + "last": 596.0, + "open_interest": 6, + "right": "call", + "strike": 150.0, + "volume": 2 + }, + { + "ask": 566.59, + "bid": 563.15, + "contract_symbol": "SPY261218C00200000", + "last": 566.9, + "open_interest": 753, + "right": "call", + "strike": 200.0, + "volume": 1 + }, + { + "ask": 556.71, + "bid": 553.27, + "contract_symbol": "SPY261218C00210000", + "last": 557.33, + "open_interest": 382, + "right": "call", + "strike": 210.0, + "volume": 2 + }, + { + "ask": 522.72, + "bid": 519.23, + "contract_symbol": "SPY261218C00220000", + "last": 515.9, + "open_interest": 82, + "right": "call", + "strike": 220.0, + "volume": 71 + }, + { + "ask": 536.95, + "bid": 533.5, + "contract_symbol": "SPY261218C00230000", + "last": 536.7, + "open_interest": 14, + "right": "call", + "strike": 230.0, + "volume": 2 + }, + { + "ask": 526.89, + "bid": 523.79, + "contract_symbol": "SPY261218C00240000", + "last": 511.4, + "open_interest": 27, + "right": "call", + "strike": 240.0, + "volume": 1 + }, + { + "ask": 428.79, + "bid": 424.91, + "contract_symbol": "SPY261218C00245000", + "last": 422.0, + "open_interest": 21, + "right": "call", + "strike": 245.0, + "volume": 4 + }, + { + "ask": 517.19, + "bid": 513.75, + "contract_symbol": "SPY261218C00250000", + "last": 512.19, + "open_interest": 145, + "right": "call", + "strike": 250.0, + "volume": 2 + }, + { + "ask": 512.25, + "bid": 508.82, + "contract_symbol": "SPY261218C00255000", + "last": 512.73, + "open_interest": 4, + "right": "call", + "strike": 255.0, + "volume": 2 + }, + { + "ask": 507.36, + "bid": 503.87, + "contract_symbol": "SPY261218C00260000", + "last": 504.85, + "open_interest": 15, + "right": "call", + "strike": 260.0, + "volume": 1 + }, + { + "ask": 434.44, + "bid": 430.6, + "contract_symbol": "SPY261218C00265000", + "last": 427.58, + "open_interest": 120, + "right": "call", + "strike": 265.0, + "volume": 2 + }, + { + "ask": 489.87, + "bid": 486.36, + "contract_symbol": "SPY261218C00270000", + "last": 486.29, + "open_interest": 65, + "right": "call", + "strike": 270.0, + "volume": 2 + }, + { + "ask": 492.55, + "bid": 489.05, + "contract_symbol": "SPY261218C00275000", + "last": 490.09, + "open_interest": 124, + "right": "call", + "strike": 275.0, + "volume": 2 + }, + { + "ask": 487.56, + "bid": 484.11, + "contract_symbol": "SPY261218C00280000", + "last": 488.33, + "open_interest": 56, + "right": "call", + "strike": 280.0, + "volume": 2 + }, + { + "ask": 389.46, + "bid": 385.58, + "contract_symbol": "SPY261218C00285000", + "last": 353.65, + "open_interest": 103, + "right": "call", + "strike": 285.0, + "volume": 2 + }, + { + "ask": 477.69, + "bid": 474.23, + "contract_symbol": "SPY261218C00290000", + "last": 478.48, + "open_interest": 16, + "right": "call", + "strike": 290.0, + "volume": 2 + }, + { + "ask": 397.73, + "bid": 393.0, + "contract_symbol": "SPY261218C00295000", + "last": 404.0, + "open_interest": 78, + "right": "call", + "strike": 295.0, + "volume": 1 + }, + { + "ask": 467.86, + "bid": 464.35, + "contract_symbol": "SPY261218C00300000", + "last": 480.12, + "open_interest": 240, + "right": "call", + "strike": 300.0, + "volume": 1 + }, + { + "ask": 468.19, + "bid": 464.74, + "contract_symbol": "SPY261218C00305000", + "last": 469.7, + "open_interest": 6, + "right": "call", + "strike": 305.0, + "volume": 1 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY261218C00310000", + "last": 446.05, + "open_interest": 0, + "right": "call", + "strike": 310.0, + "volume": 1 + }, + { + "ask": 453.01, + "bid": 449.56, + "contract_symbol": "SPY261218C00315000", + "last": 449.87, + "open_interest": 147, + "right": "call", + "strike": 315.0, + "volume": 1 + }, + { + "ask": 448.07, + "bid": 444.59, + "contract_symbol": "SPY261218C00320000", + "last": 445.4, + "open_interest": 29, + "right": "call", + "strike": 320.0, + "volume": 18 + }, + { + "ask": 436.05, + "bid": 432.54, + "contract_symbol": "SPY261218C00325000", + "last": 411.16, + "open_interest": 58, + "right": "call", + "strike": 325.0, + "volume": 1 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY261218C00330000", + "last": 415.5, + "open_interest": 0, + "right": "call", + "strike": 330.0, + "volume": 1 + }, + { + "ask": 426.28, + "bid": 422.77, + "contract_symbol": "SPY261218C00335000", + "last": 414.01, + "open_interest": 11, + "right": "call", + "strike": 335.0, + "volume": 1 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY261218C00340000", + "last": 308.13, + "open_interest": 0, + "right": "call", + "strike": 340.0, + "volume": 11 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY261218C00345000", + "last": 416.84, + "open_interest": 0, + "right": "call", + "strike": 345.0, + "volume": 2 + }, + { + "ask": 418.47, + "bid": 415.0, + "contract_symbol": "SPY261218C00350000", + "last": 417.89, + "open_interest": 74, + "right": "call", + "strike": 350.0, + "volume": 2 + }, + { + "ask": 413.66, + "bid": 410.56, + "contract_symbol": "SPY261218C00355000", + "last": 394.98, + "open_interest": 13, + "right": "call", + "strike": 355.0, + "volume": 1 + }, + { + "ask": 408.74, + "bid": 405.64, + "contract_symbol": "SPY261218C00360000", + "last": 396.38, + "open_interest": 21, + "right": "call", + "strike": 360.0, + "volume": 10 + }, + { + "ask": 403.68, + "bid": 400.21, + "contract_symbol": "SPY261218C00365000", + "last": 414.5, + "open_interest": 75, + "right": "call", + "strike": 365.0, + "volume": 1 + }, + { + "ask": 398.75, + "bid": 395.29, + "contract_symbol": "SPY261218C00370000", + "last": 405.46, + "open_interest": 109, + "right": "call", + "strike": 370.0, + "volume": 3 + }, + { + "ask": 399.16, + "bid": 395.67, + "contract_symbol": "SPY261218C00375000", + "last": 405.25, + "open_interest": 15, + "right": "call", + "strike": 375.0, + "volume": 1 + }, + { + "ask": 388.89, + "bid": 385.41, + "contract_symbol": "SPY261218C00380000", + "last": 396.6, + "open_interest": 37, + "right": "call", + "strike": 380.0, + "volume": 2 + }, + { + "ask": 383.96, + "bid": 380.5, + "contract_symbol": "SPY261218C00385000", + "last": 375.75, + "open_interest": 26, + "right": "call", + "strike": 385.0, + "volume": 7 + }, + { + "ask": 379.03, + "bid": 375.55, + "contract_symbol": "SPY261218C00390000", + "last": 377.47, + "open_interest": 143, + "right": "call", + "strike": 390.0, + "volume": 2 + }, + { + "ask": 374.1, + "bid": 370.64, + "contract_symbol": "SPY261218C00395000", + "last": 375.82, + "open_interest": 93, + "right": "call", + "strike": 395.0, + "volume": 100 + }, + { + "ask": 369.18, + "bid": 365.71, + "contract_symbol": "SPY261218C00400000", + "last": 366.93, + "open_interest": 13718, + "right": "call", + "strike": 400.0, + "volume": 18 + }, + { + "ask": 364.25, + "bid": 360.79, + "contract_symbol": "SPY261218C00405000", + "last": 362.09, + "open_interest": 254, + "right": "call", + "strike": 405.0, + "volume": 4 + }, + { + "ask": 359.33, + "bid": 355.88, + "contract_symbol": "SPY261218C00410000", + "last": 357.69, + "open_interest": 183, + "right": "call", + "strike": 410.0, + "volume": 2 + }, + { + "ask": 354.4, + "bid": 350.92, + "contract_symbol": "SPY261218C00415000", + "last": 354.91, + "open_interest": 329, + "right": "call", + "strike": 415.0, + "volume": 4 + }, + { + "ask": 349.48, + "bid": 346.01, + "contract_symbol": "SPY261218C00420000", + "last": 348.95, + "open_interest": 174, + "right": "call", + "strike": 420.0, + "volume": 1 + }, + { + "ask": 344.55, + "bid": 341.08, + "contract_symbol": "SPY261218C00425000", + "last": 338.75, + "open_interest": 283, + "right": "call", + "strike": 425.0, + "volume": 1 + }, + { + "ask": 339.63, + "bid": 336.14, + "contract_symbol": "SPY261218C00430000", + "last": 340.18, + "open_interest": 216, + "right": "call", + "strike": 430.0, + "volume": 54 + }, + { + "ask": 334.71, + "bid": 331.24, + "contract_symbol": "SPY261218C00435000", + "last": 325.92, + "open_interest": 145, + "right": "call", + "strike": 435.0, + "volume": 7 + }, + { + "ask": 329.79, + "bid": 326.3, + "contract_symbol": "SPY261218C00440000", + "last": 329.36, + "open_interest": 336, + "right": "call", + "strike": 440.0, + "volume": 1 + }, + { + "ask": 324.87, + "bid": 321.4, + "contract_symbol": "SPY261218C00445000", + "last": 320.58, + "open_interest": 123, + "right": "call", + "strike": 445.0, + "volume": 1 + }, + { + "ask": 319.95, + "bid": 316.47, + "contract_symbol": "SPY261218C00450000", + "last": 315.7, + "open_interest": 463, + "right": "call", + "strike": 450.0, + "volume": 8 + }, + { + "ask": 315.07, + "bid": 311.55, + "contract_symbol": "SPY261218C00455000", + "last": 314.78, + "open_interest": 87, + "right": "call", + "strike": 455.0, + "volume": 2 + }, + { + "ask": 310.12, + "bid": 306.63, + "contract_symbol": "SPY261218C00460000", + "last": 310.16, + "open_interest": 224, + "right": "call", + "strike": 460.0, + "volume": 2 + }, + { + "ask": 305.21, + "bid": 301.73, + "contract_symbol": "SPY261218C00465000", + "last": 297.1, + "open_interest": 92, + "right": "call", + "strike": 465.0, + "volume": 2 + }, + { + "ask": 300.29, + "bid": 296.81, + "contract_symbol": "SPY261218C00470000", + "last": 292.17, + "open_interest": 246, + "right": "call", + "strike": 470.0, + "volume": 2 + }, + { + "ask": 295.38, + "bid": 291.89, + "contract_symbol": "SPY261218C00475000", + "last": 294.03, + "open_interest": 1083, + "right": "call", + "strike": 475.0, + "volume": 2 + }, + { + "ask": 290.47, + "bid": 286.96, + "contract_symbol": "SPY261218C00480000", + "last": 292.27, + "open_interest": 204, + "right": "call", + "strike": 480.0, + "volume": 1 + }, + { + "ask": 285.56, + "bid": 282.07, + "contract_symbol": "SPY261218C00485000", + "last": 292.93, + "open_interest": 868, + "right": "call", + "strike": 485.0, + "volume": 12 + }, + { + "ask": 280.65, + "bid": 277.15, + "contract_symbol": "SPY261218C00490000", + "last": 281.28, + "open_interest": 821, + "right": "call", + "strike": 490.0, + "volume": 2 + }, + { + "ask": 275.78, + "bid": 272.24, + "contract_symbol": "SPY261218C00495000", + "last": 267.79, + "open_interest": 1977, + "right": "call", + "strike": 495.0, + "volume": 1 + }, + { + "ask": 270.84, + "bid": 267.36, + "contract_symbol": "SPY261218C00500000", + "last": 269.72, + "open_interest": 3728, + "right": "call", + "strike": 500.0, + "volume": 13 + }, + { + "ask": 265.94, + "bid": 262.44, + "contract_symbol": "SPY261218C00505000", + "last": 264.78, + "open_interest": 1444, + "right": "call", + "strike": 505.0, + "volume": 13 + }, + { + "ask": 261.04, + "bid": 257.41, + "contract_symbol": "SPY261218C00510000", + "last": 253.0, + "open_interest": 1957, + "right": "call", + "strike": 510.0, + "volume": 10 + }, + { + "ask": 256.18, + "bid": 252.64, + "contract_symbol": "SPY261218C00515000", + "last": 255.56, + "open_interest": 2064, + "right": "call", + "strike": 515.0, + "volume": 49 + }, + { + "ask": 251.29, + "bid": 247.6, + "contract_symbol": "SPY261218C00520000", + "last": 252.59, + "open_interest": 3031, + "right": "call", + "strike": 520.0, + "volume": 1 + }, + { + "ask": 246.36, + "bid": 242.72, + "contract_symbol": "SPY261218C00525000", + "last": 245.53, + "open_interest": 828, + "right": "call", + "strike": 525.0, + "volume": 1 + }, + { + "ask": 241.47, + "bid": 237.81, + "contract_symbol": "SPY261218C00530000", + "last": 233.15, + "open_interest": 2113, + "right": "call", + "strike": 530.0, + "volume": 1 + }, + { + "ask": 236.58, + "bid": 232.99, + "contract_symbol": "SPY261218C00535000", + "last": 229.5, + "open_interest": 2369, + "right": "call", + "strike": 535.0, + "volume": 1 + }, + { + "ask": 231.69, + "bid": 228.16, + "contract_symbol": "SPY261218C00540000", + "last": 230.31, + "open_interest": 3482, + "right": "call", + "strike": 540.0, + "volume": 1 + }, + { + "ask": 226.84, + "bid": 223.25, + "contract_symbol": "SPY261218C00545000", + "last": 224.5, + "open_interest": 1698, + "right": "call", + "strike": 545.0, + "volume": 1 + }, + { + "ask": 221.93, + "bid": 218.36, + "contract_symbol": "SPY261218C00550000", + "last": 214.85, + "open_interest": 3972, + "right": "call", + "strike": 550.0, + "volume": 8 + }, + { + "ask": 217.09, + "bid": 213.52, + "contract_symbol": "SPY261218C00555000", + "last": 223.67, + "open_interest": 1678, + "right": "call", + "strike": 555.0, + "volume": 1 + }, + { + "ask": 212.18, + "bid": 208.6, + "contract_symbol": "SPY261218C00560000", + "last": 212.0, + "open_interest": 1924, + "right": "call", + "strike": 560.0, + "volume": 1 + }, + { + "ask": 207.31, + "bid": 203.78, + "contract_symbol": "SPY261218C00565000", + "last": 200.57, + "open_interest": 1623, + "right": "call", + "strike": 565.0, + "volume": 1 + }, + { + "ask": 202.48, + "bid": 198.89, + "contract_symbol": "SPY261218C00570000", + "last": 195.77, + "open_interest": 5220, + "right": "call", + "strike": 570.0, + "volume": 1 + }, + { + "ask": 197.61, + "bid": 194.01, + "contract_symbol": "SPY261218C00575000", + "last": 198.9, + "open_interest": 848, + "right": "call", + "strike": 575.0, + "volume": 12 + }, + { + "ask": 192.73, + "bid": 189.16, + "contract_symbol": "SPY261218C00580000", + "last": 192.23, + "open_interest": 1994, + "right": "call", + "strike": 580.0, + "volume": 1 + }, + { + "ask": 187.88, + "bid": 184.31, + "contract_symbol": "SPY261218C00585000", + "last": 186.25, + "open_interest": 968, + "right": "call", + "strike": 585.0, + "volume": 1 + }, + { + "ask": 183.07, + "bid": 179.51, + "contract_symbol": "SPY261218C00590000", + "last": 182.66, + "open_interest": 2453, + "right": "call", + "strike": 590.0, + "volume": 3 + }, + { + "ask": 178.23, + "bid": 174.63, + "contract_symbol": "SPY261218C00595000", + "last": 176.0, + "open_interest": 1513, + "right": "call", + "strike": 595.0, + "volume": 1 + }, + { + "ask": 173.4, + "bid": 169.83, + "contract_symbol": "SPY261218C00600000", + "last": 172.29, + "open_interest": 4511, + "right": "call", + "strike": 600.0, + "volume": 12 + }, + { + "ask": 168.55, + "bid": 165.01, + "contract_symbol": "SPY261218C00605000", + "last": 167.61, + "open_interest": 2761, + "right": "call", + "strike": 605.0, + "volume": 5 + }, + { + "ask": 163.72, + "bid": 160.17, + "contract_symbol": "SPY261218C00610000", + "last": 162.68, + "open_interest": 1356, + "right": "call", + "strike": 610.0, + "volume": 16 + }, + { + "ask": 158.91, + "bid": 155.38, + "contract_symbol": "SPY261218C00615000", + "last": 157.3, + "open_interest": 1456, + "right": "call", + "strike": 615.0, + "volume": 4 + }, + { + "ask": 154.11, + "bid": 150.58, + "contract_symbol": "SPY261218C00620000", + "last": 152.55, + "open_interest": 2745, + "right": "call", + "strike": 620.0, + "volume": 4 + }, + { + "ask": 149.31, + "bid": 145.79, + "contract_symbol": "SPY261218C00625000", + "last": 148.38, + "open_interest": 1569, + "right": "call", + "strike": 625.0, + "volume": 46 + }, + { + "ask": 144.53, + "bid": 141.02, + "contract_symbol": "SPY261218C00630000", + "last": 140.97, + "open_interest": 905, + "right": "call", + "strike": 630.0, + "volume": 6 + }, + { + "ask": 139.75, + "bid": 136.25, + "contract_symbol": "SPY261218C00635000", + "last": 136.2, + "open_interest": 1767, + "right": "call", + "strike": 635.0, + "volume": 1 + }, + { + "ask": 134.99, + "bid": 131.51, + "contract_symbol": "SPY261218C00640000", + "last": 133.44, + "open_interest": 2238, + "right": "call", + "strike": 640.0, + "volume": 2 + }, + { + "ask": 129.0, + "bid": 126.77, + "contract_symbol": "SPY261218C00645000", + "last": 123.26, + "open_interest": 984, + "right": "call", + "strike": 645.0, + "volume": 10 + }, + { + "ask": 125.02, + "bid": 122.04, + "contract_symbol": "SPY261218C00650000", + "last": 124.3, + "open_interest": 3500, + "right": "call", + "strike": 650.0, + "volume": 9 + }, + { + "ask": 120.79, + "bid": 117.34, + "contract_symbol": "SPY261218C00655000", + "last": 113.52, + "open_interest": 1087, + "right": "call", + "strike": 655.0, + "volume": 5 + }, + { + "ask": 116.09, + "bid": 112.64, + "contract_symbol": "SPY261218C00660000", + "last": 115.31, + "open_interest": 1378, + "right": "call", + "strike": 660.0, + "volume": 4 + }, + { + "ask": 111.41, + "bid": 107.97, + "contract_symbol": "SPY261218C00665000", + "last": 110.94, + "open_interest": 1662, + "right": "call", + "strike": 665.0, + "volume": 2 + }, + { + "ask": 106.75, + "bid": 103.32, + "contract_symbol": "SPY261218C00670000", + "last": 106.59, + "open_interest": 2369, + "right": "call", + "strike": 670.0, + "volume": 4 + }, + { + "ask": 102.12, + "bid": 98.7, + "contract_symbol": "SPY261218C00675000", + "last": 101.22, + "open_interest": 4312, + "right": "call", + "strike": 675.0, + "volume": 19 + }, + { + "ask": 97.51, + "bid": 94.09, + "contract_symbol": "SPY261218C00680000", + "last": 96.02, + "open_interest": 4743, + "right": "call", + "strike": 680.0, + "volume": 8 + }, + { + "ask": 92.93, + "bid": 89.51, + "contract_symbol": "SPY261218C00685000", + "last": 92.55, + "open_interest": 2836, + "right": "call", + "strike": 685.0, + "volume": 3 + }, + { + "ask": 88.37, + "bid": 84.97, + "contract_symbol": "SPY261218C00690000", + "last": 86.47, + "open_interest": 3428, + "right": "call", + "strike": 690.0, + "volume": 8 + }, + { + "ask": 83.85, + "bid": 80.45, + "contract_symbol": "SPY261218C00695000", + "last": 82.54, + "open_interest": 2173, + "right": "call", + "strike": 695.0, + "volume": 15 + }, + { + "ask": 79.37, + "bid": 75.99, + "contract_symbol": "SPY261218C00700000", + "last": 78.36, + "open_interest": 9980, + "right": "call", + "strike": 700.0, + "volume": 15 + }, + { + "ask": 74.93, + "bid": 71.55, + "contract_symbol": "SPY261218C00705000", + "last": 73.72, + "open_interest": 3575, + "right": "call", + "strike": 705.0, + "volume": 2 + }, + { + "ask": 70.53, + "bid": 67.16, + "contract_symbol": "SPY261218C00710000", + "last": 69.51, + "open_interest": 7662, + "right": "call", + "strike": 710.0, + "volume": 4 + }, + { + "ask": 66.19, + "bid": 62.82, + "contract_symbol": "SPY261218C00715000", + "last": 64.94, + "open_interest": 2514, + "right": "call", + "strike": 715.0, + "volume": 9 + }, + { + "ask": 61.91, + "bid": 58.55, + "contract_symbol": "SPY261218C00720000", + "last": 60.48, + "open_interest": 4395, + "right": "call", + "strike": 720.0, + "volume": 41 + }, + { + "ask": 57.69, + "bid": 54.33, + "contract_symbol": "SPY261218C00725000", + "last": 56.61, + "open_interest": 4834, + "right": "call", + "strike": 725.0, + "volume": 11 + }, + { + "ask": 53.55, + "bid": 50.19, + "contract_symbol": "SPY261218C00730000", + "last": 47.5, + "open_interest": 8463, + "right": "call", + "strike": 730.0, + "volume": 11 + }, + { + "ask": 49.5, + "bid": 46.14, + "contract_symbol": "SPY261218C00735000", + "last": 47.84, + "open_interest": 4567, + "right": "call", + "strike": 735.0, + "volume": 3 + }, + { + "ask": 45.46, + "bid": 42.17, + "contract_symbol": "SPY261218C00740000", + "last": 44.82, + "open_interest": 6865, + "right": "call", + "strike": 740.0, + "volume": 8 + }, + { + "ask": 40.4, + "bid": 39.13, + "contract_symbol": "SPY261218C00745000", + "last": 40.33, + "open_interest": 8251, + "right": "call", + "strike": 745.0, + "volume": 12 + }, + { + "ask": 36.26, + "bid": 35.48, + "contract_symbol": "SPY261218C00750000", + "last": 37.16, + "open_interest": 29716, + "right": "call", + "strike": 750.0, + "volume": 45 + }, + { + "ask": 32.61, + "bid": 31.9, + "contract_symbol": "SPY261218C00755000", + "last": 32.56, + "open_interest": 6696, + "right": "call", + "strike": 755.0, + "volume": 65 + }, + { + "ask": 29.19, + "bid": 28.97, + "contract_symbol": "SPY261218C00760000", + "last": 29.69, + "open_interest": 10018, + "right": "call", + "strike": 760.0, + "volume": 3370 + }, + { + "ask": 25.8, + "bid": 25.72, + "contract_symbol": "SPY261218C00765000", + "last": 25.81, + "open_interest": 7622, + "right": "call", + "strike": 765.0, + "volume": 3224 + }, + { + "ask": 22.7, + "bid": 22.62, + "contract_symbol": "SPY261218C00770000", + "last": 22.81, + "open_interest": 11236, + "right": "call", + "strike": 770.0, + "volume": 282 + }, + { + "ask": 19.79, + "bid": 19.7, + "contract_symbol": "SPY261218C00775000", + "last": 20.15, + "open_interest": 9744, + "right": "call", + "strike": 775.0, + "volume": 439 + }, + { + "ask": 17.11, + "bid": 17.02, + "contract_symbol": "SPY261218C00780000", + "last": 17.09, + "open_interest": 28422, + "right": "call", + "strike": 780.0, + "volume": 807 + }, + { + "ask": 14.65, + "bid": 14.56, + "contract_symbol": "SPY261218C00785000", + "last": 14.62, + "open_interest": 15225, + "right": "call", + "strike": 785.0, + "volume": 539 + }, + { + "ask": 12.43, + "bid": 12.36, + "contract_symbol": "SPY261218C00790000", + "last": 12.62, + "open_interest": 13908, + "right": "call", + "strike": 790.0, + "volume": 188 + }, + { + "ask": 10.45, + "bid": 10.38, + "contract_symbol": "SPY261218C00795000", + "last": 10.42, + "open_interest": 9816, + "right": "call", + "strike": 795.0, + "volume": 32 + }, + { + "ask": 8.72, + "bid": 8.68, + "contract_symbol": "SPY261218C00800000", + "last": 8.68, + "open_interest": 22483, + "right": "call", + "strike": 800.0, + "volume": 1213 + }, + { + "ask": 7.21, + "bid": 7.17, + "contract_symbol": "SPY261218C00805000", + "last": 7.19, + "open_interest": 12497, + "right": "call", + "strike": 805.0, + "volume": 60 + }, + { + "ask": 5.92, + "bid": 5.88, + "contract_symbol": "SPY261218C00810000", + "last": 5.94, + "open_interest": 22417, + "right": "call", + "strike": 810.0, + "volume": 86 + }, + { + "ask": 4.84, + "bid": 4.8, + "contract_symbol": "SPY261218C00815000", + "last": 4.84, + "open_interest": 14596, + "right": "call", + "strike": 815.0, + "volume": 219 + }, + { + "ask": 3.92, + "bid": 3.89, + "contract_symbol": "SPY261218C00820000", + "last": 3.99, + "open_interest": 14210, + "right": "call", + "strike": 820.0, + "volume": 231 + }, + { + "ask": 3.16, + "bid": 3.13, + "contract_symbol": "SPY261218C00825000", + "last": 3.17, + "open_interest": 14003, + "right": "call", + "strike": 825.0, + "volume": 90 + }, + { + "ask": 2.53, + "bid": 2.5, + "contract_symbol": "SPY261218C00830000", + "last": 2.52, + "open_interest": 12066, + "right": "call", + "strike": 830.0, + "volume": 94 + }, + { + "ask": 2.02, + "bid": 1.99, + "contract_symbol": "SPY261218C00835000", + "last": 2.0, + "open_interest": 10184, + "right": "call", + "strike": 835.0, + "volume": 1548 + }, + { + "ask": 1.6, + "bid": 1.59, + "contract_symbol": "SPY261218C00840000", + "last": 1.59, + "open_interest": 8599, + "right": "call", + "strike": 840.0, + "volume": 702 + }, + { + "ask": 1.27, + "bid": 1.26, + "contract_symbol": "SPY261218C00845000", + "last": 1.26, + "open_interest": 5018, + "right": "call", + "strike": 845.0, + "volume": 29 + }, + { + "ask": 1.01, + "bid": 1.0, + "contract_symbol": "SPY261218C00850000", + "last": 1.01, + "open_interest": 11880, + "right": "call", + "strike": 850.0, + "volume": 386 + }, + { + "ask": 0.81, + "bid": 0.8, + "contract_symbol": "SPY261218C00855000", + "last": 0.81, + "open_interest": 4058, + "right": "call", + "strike": 855.0, + "volume": 889 + }, + { + "ask": 0.65, + "bid": 0.64, + "contract_symbol": "SPY261218C00860000", + "last": 0.68, + "open_interest": 5140, + "right": "call", + "strike": 860.0, + "volume": 14 + }, + { + "ask": 0.53, + "bid": 0.52, + "contract_symbol": "SPY261218C00865000", + "last": 0.57, + "open_interest": 1708, + "right": "call", + "strike": 865.0, + "volume": 10 + }, + { + "ask": 0.43, + "bid": 0.42, + "contract_symbol": "SPY261218C00870000", + "last": 0.39, + "open_interest": 2579, + "right": "call", + "strike": 870.0, + "volume": 3 + }, + { + "ask": 0.36, + "bid": 0.35, + "contract_symbol": "SPY261218C00875000", + "last": 0.37, + "open_interest": 3462, + "right": "call", + "strike": 875.0, + "volume": 67 + }, + { + "ask": 0.3, + "bid": 0.29, + "contract_symbol": "SPY261218C00880000", + "last": 0.31, + "open_interest": 3924, + "right": "call", + "strike": 880.0, + "volume": 1587 + }, + { + "ask": 0.26, + "bid": 0.25, + "contract_symbol": "SPY261218C00885000", + "last": 0.25, + "open_interest": 3947, + "right": "call", + "strike": 885.0, + "volume": 1 + }, + { + "ask": 0.22, + "bid": 0.21, + "contract_symbol": "SPY261218C00890000", + "last": 0.23, + "open_interest": 2336, + "right": "call", + "strike": 890.0, + "volume": 4 + }, + { + "ask": 0.2, + "bid": 0.19, + "contract_symbol": "SPY261218C00895000", + "last": 0.21, + "open_interest": 1370, + "right": "call", + "strike": 895.0, + "volume": 25 + }, + { + "ask": 0.17, + "bid": 0.16, + "contract_symbol": "SPY261218C00900000", + "last": 0.18, + "open_interest": 8389, + "right": "call", + "strike": 900.0, + "volume": 52 + }, + { + "ask": 0.15, + "bid": 0.14, + "contract_symbol": "SPY261218C00905000", + "last": 0.22, + "open_interest": 2621, + "right": "call", + "strike": 905.0, + "volume": 2 + }, + { + "ask": 0.14, + "bid": 0.13, + "contract_symbol": "SPY261218C00910000", + "last": 0.13, + "open_interest": 3931, + "right": "call", + "strike": 910.0, + "volume": 64 + }, + { + "ask": 0.12, + "bid": 0.11, + "contract_symbol": "SPY261218C00915000", + "last": 0.16, + "open_interest": 2367, + "right": "call", + "strike": 915.0, + "volume": 1 + }, + { + "ask": 0.11, + "bid": 0.1, + "contract_symbol": "SPY261218C00920000", + "last": 0.12, + "open_interest": 702, + "right": "call", + "strike": 920.0, + "volume": 16 + }, + { + "ask": 0.1, + "bid": 0.09, + "contract_symbol": "SPY261218C00925000", + "last": 0.15, + "open_interest": 1231, + "right": "call", + "strike": 925.0, + "volume": 5 + }, + { + "ask": 0.09, + "bid": 0.08, + "contract_symbol": "SPY261218C00930000", + "last": 0.1, + "open_interest": 1713, + "right": "call", + "strike": 930.0, + "volume": 4 + }, + { + "ask": 0.08, + "bid": 0.07, + "contract_symbol": "SPY261218C00935000", + "last": 0.09, + "open_interest": 1991, + "right": "call", + "strike": 935.0, + "volume": 1 + }, + { + "ask": 0.08, + "bid": 0.07, + "contract_symbol": "SPY261218C00940000", + "last": 0.07, + "open_interest": 433, + "right": "call", + "strike": 940.0, + "volume": 6 + }, + { + "ask": 0.07, + "bid": 0.06, + "contract_symbol": "SPY261218C00945000", + "last": 0.12, + "open_interest": 56, + "right": "call", + "strike": 945.0, + "volume": 3 + }, + { + "ask": 0.07, + "bid": 0.06, + "contract_symbol": "SPY261218C00950000", + "last": 0.07, + "open_interest": 11406, + "right": "call", + "strike": 950.0, + "volume": 4 + }, + { + "ask": 0.06, + "bid": 0.05, + "contract_symbol": "SPY261218C00955000", + "last": 0.07, + "open_interest": 77, + "right": "call", + "strike": 955.0, + "volume": 1 + }, + { + "ask": 0.06, + "bid": 0.05, + "contract_symbol": "SPY261218C00960000", + "last": 0.06, + "open_interest": 942, + "right": "call", + "strike": 960.0, + "volume": 1 + }, + { + "ask": 0.05, + "bid": 0.04, + "contract_symbol": "SPY261218C00965000", + "last": 0.09, + "open_interest": 570, + "right": "call", + "strike": 965.0, + "volume": 1 + }, + { + "ask": 0.05, + "bid": 0.04, + "contract_symbol": "SPY261218C00970000", + "last": 0.07, + "open_interest": 1019, + "right": "call", + "strike": 970.0, + "volume": 2 + }, + { + "ask": 0.05, + "bid": 0.04, + "contract_symbol": "SPY261218C00975000", + "last": 0.1, + "open_interest": 1070, + "right": "call", + "strike": 975.0, + "volume": 5 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY261218C00980000", + "last": 0.07, + "open_interest": 1297, + "right": "call", + "strike": 980.0, + "volume": 1 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY261218C00985000", + "last": 0.04, + "open_interest": 650, + "right": "call", + "strike": 985.0, + "volume": 287 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY261218C00990000", + "last": 0.05, + "open_interest": 868, + "right": "call", + "strike": 990.0, + "volume": 3 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY261218C00995000", + "last": 0.05, + "open_interest": 2272, + "right": "call", + "strike": 995.0, + "volume": 6 + }, + { + "ask": 0.03, + "bid": 0.02, + "contract_symbol": "SPY261218C01000000", + "last": 0.03, + "open_interest": 2900, + "right": "call", + "strike": 1000.0, + "volume": 20 + }, + { + "ask": 0.02, + "bid": 0.01, + "contract_symbol": "SPY261218P00150000", + "last": 0.02, + "open_interest": 32671, + "right": "put", + "strike": 150.0, + "volume": 72 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY261218P00200000", + "last": 0.03, + "open_interest": 32597, + "right": "put", + "strike": 200.0, + "volume": 410 + }, + { + "ask": 0.04, + "bid": 0.03, + "contract_symbol": "SPY261218P00210000", + "last": 0.05, + "open_interest": 4816, + "right": "put", + "strike": 210.0, + "volume": 600 + }, + { + "ask": 0.05, + "bid": 0.04, + "contract_symbol": "SPY261218P00220000", + "last": 0.06, + "open_interest": 2482, + "right": "put", + "strike": 220.0, + "volume": 20 + }, + { + "ask": 0.06, + "bid": 0.05, + "contract_symbol": "SPY261218P00230000", + "last": 0.05, + "open_interest": 8359, + "right": "put", + "strike": 230.0, + "volume": 1 + }, + { + "ask": 0.06, + "bid": 0.05, + "contract_symbol": "SPY261218P00240000", + "last": 0.05, + "open_interest": 48970, + "right": "put", + "strike": 240.0, + "volume": 1 + }, + { + "ask": 0.07, + "bid": 0.06, + "contract_symbol": "SPY261218P00245000", + "last": 0.05, + "open_interest": 523, + "right": "put", + "strike": 245.0, + "volume": 2 + }, + { + "ask": 0.07, + "bid": 0.06, + "contract_symbol": "SPY261218P00250000", + "last": 0.08, + "open_interest": 5023, + "right": "put", + "strike": 250.0, + "volume": 7 + }, + { + "ask": 0.07, + "bid": 0.06, + "contract_symbol": "SPY261218P00255000", + "last": 0.08, + "open_interest": 1440, + "right": "put", + "strike": 255.0, + "volume": 2 + }, + { + "ask": 0.08, + "bid": 0.07, + "contract_symbol": "SPY261218P00260000", + "last": 0.09, + "open_interest": 5248, + "right": "put", + "strike": 260.0, + "volume": 1 + }, + { + "ask": 0.08, + "bid": 0.07, + "contract_symbol": "SPY261218P00265000", + "last": 0.08, + "open_interest": 256, + "right": "put", + "strike": 265.0, + "volume": 12 + }, + { + "ask": 0.09, + "bid": 0.08, + "contract_symbol": "SPY261218P00270000", + "last": 0.09, + "open_interest": 690, + "right": "put", + "strike": 270.0, + "volume": 1 + }, + { + "ask": 0.09, + "bid": 0.08, + "contract_symbol": "SPY261218P00275000", + "last": 0.09, + "open_interest": 6352, + "right": "put", + "strike": 275.0, + "volume": 1 + }, + { + "ask": 0.1, + "bid": 0.09, + "contract_symbol": "SPY261218P00280000", + "last": 0.1, + "open_interest": 5313, + "right": "put", + "strike": 280.0, + "volume": 250 + }, + { + "ask": 0.1, + "bid": 0.09, + "contract_symbol": "SPY261218P00285000", + "last": 0.11, + "open_interest": 1885, + "right": "put", + "strike": 285.0, + "volume": 18 + }, + { + "ask": 0.11, + "bid": 0.1, + "contract_symbol": "SPY261218P00290000", + "last": 0.1, + "open_interest": 15168, + "right": "put", + "strike": 290.0, + "volume": 11 + }, + { + "ask": 0.12, + "bid": 0.11, + "contract_symbol": "SPY261218P00295000", + "last": 0.11, + "open_interest": 2503, + "right": "put", + "strike": 295.0, + "volume": 64 + }, + { + "ask": 0.12, + "bid": 0.11, + "contract_symbol": "SPY261218P00300000", + "last": 0.13, + "open_interest": 9197, + "right": "put", + "strike": 300.0, + "volume": 15 + }, + { + "ask": 0.13, + "bid": 0.12, + "contract_symbol": "SPY261218P00305000", + "last": 0.13, + "open_interest": 418, + "right": "put", + "strike": 305.0, + "volume": 10 + }, + { + "ask": 0.14, + "bid": 0.13, + "contract_symbol": "SPY261218P00310000", + "last": 0.14, + "open_interest": 1956, + "right": "put", + "strike": 310.0, + "volume": 136 + }, + { + "ask": 0.14, + "bid": 0.13, + "contract_symbol": "SPY261218P00315000", + "last": 0.15, + "open_interest": 914, + "right": "put", + "strike": 315.0, + "volume": 18 + }, + { + "ask": 0.15, + "bid": 0.14, + "contract_symbol": "SPY261218P00320000", + "last": 0.15, + "open_interest": 1493, + "right": "put", + "strike": 320.0, + "volume": 19 + }, + { + "ask": 0.16, + "bid": 0.15, + "contract_symbol": "SPY261218P00325000", + "last": 0.15, + "open_interest": 3192, + "right": "put", + "strike": 325.0, + "volume": 10 + }, + { + "ask": 0.17, + "bid": 0.16, + "contract_symbol": "SPY261218P00330000", + "last": 0.16, + "open_interest": 1016, + "right": "put", + "strike": 330.0, + "volume": 6 + }, + { + "ask": 0.18, + "bid": 0.17, + "contract_symbol": "SPY261218P00335000", + "last": 0.17, + "open_interest": 263, + "right": "put", + "strike": 335.0, + "volume": 6 + }, + { + "ask": 0.19, + "bid": 0.18, + "contract_symbol": "SPY261218P00340000", + "last": 0.18, + "open_interest": 2204, + "right": "put", + "strike": 340.0, + "volume": 5 + }, + { + "ask": 0.2, + "bid": 0.19, + "contract_symbol": "SPY261218P00345000", + "last": 0.19, + "open_interest": 1459, + "right": "put", + "strike": 345.0, + "volume": 5 + }, + { + "ask": 0.21, + "bid": 0.2, + "contract_symbol": "SPY261218P00350000", + "last": 0.2, + "open_interest": 157158, + "right": "put", + "strike": 350.0, + "volume": 177 + }, + { + "ask": 0.22, + "bid": 0.21, + "contract_symbol": "SPY261218P00355000", + "last": 0.22, + "open_interest": 1311, + "right": "put", + "strike": 355.0, + "volume": 15 + }, + { + "ask": 0.23, + "bid": 0.22, + "contract_symbol": "SPY261218P00360000", + "last": 0.22, + "open_interest": 3440, + "right": "put", + "strike": 360.0, + "volume": 2 + }, + { + "ask": 0.24, + "bid": 0.23, + "contract_symbol": "SPY261218P00365000", + "last": 0.25, + "open_interest": 1899, + "right": "put", + "strike": 365.0, + "volume": 6 + }, + { + "ask": 0.25, + "bid": 0.24, + "contract_symbol": "SPY261218P00370000", + "last": 0.25, + "open_interest": 2827, + "right": "put", + "strike": 370.0, + "volume": 1 + }, + { + "ask": 0.27, + "bid": 0.26, + "contract_symbol": "SPY261218P00375000", + "last": 0.28, + "open_interest": 7728, + "right": "put", + "strike": 375.0, + "volume": 2 + }, + { + "ask": 0.28, + "bid": 0.27, + "contract_symbol": "SPY261218P00380000", + "last": 0.32, + "open_interest": 2439, + "right": "put", + "strike": 380.0, + "volume": 2 + }, + { + "ask": 0.3, + "bid": 0.29, + "contract_symbol": "SPY261218P00385000", + "last": 0.28, + "open_interest": 3407, + "right": "put", + "strike": 385.0, + "volume": 26 + }, + { + "ask": 0.31, + "bid": 0.3, + "contract_symbol": "SPY261218P00390000", + "last": 0.3, + "open_interest": 4334, + "right": "put", + "strike": 390.0, + "volume": 1 + }, + { + "ask": 0.33, + "bid": 0.32, + "contract_symbol": "SPY261218P00395000", + "last": 0.4, + "open_interest": 7583, + "right": "put", + "strike": 395.0, + "volume": 1 + }, + { + "ask": 0.34, + "bid": 0.33, + "contract_symbol": "SPY261218P00400000", + "last": 0.33, + "open_interest": 28656, + "right": "put", + "strike": 400.0, + "volume": 4 + }, + { + "ask": 0.36, + "bid": 0.35, + "contract_symbol": "SPY261218P00405000", + "last": 0.36, + "open_interest": 1239, + "right": "put", + "strike": 405.0, + "volume": 8 + }, + { + "ask": 0.38, + "bid": 0.37, + "contract_symbol": "SPY261218P00410000", + "last": 0.37, + "open_interest": 16334, + "right": "put", + "strike": 410.0, + "volume": 1 + }, + { + "ask": 0.4, + "bid": 0.39, + "contract_symbol": "SPY261218P00415000", + "last": 0.41, + "open_interest": 5037, + "right": "put", + "strike": 415.0, + "volume": 43 + }, + { + "ask": 0.42, + "bid": 0.41, + "contract_symbol": "SPY261218P00420000", + "last": 0.42, + "open_interest": 4662, + "right": "put", + "strike": 420.0, + "volume": 3 + }, + { + "ask": 0.44, + "bid": 0.43, + "contract_symbol": "SPY261218P00425000", + "last": 0.41, + "open_interest": 8567, + "right": "put", + "strike": 425.0, + "volume": 1 + }, + { + "ask": 0.46, + "bid": 0.45, + "contract_symbol": "SPY261218P00430000", + "last": 0.45, + "open_interest": 2390, + "right": "put", + "strike": 430.0, + "volume": 2 + }, + { + "ask": 0.48, + "bid": 0.47, + "contract_symbol": "SPY261218P00435000", + "last": 0.55, + "open_interest": 1116, + "right": "put", + "strike": 435.0, + "volume": 1 + }, + { + "ask": 0.5, + "bid": 0.49, + "contract_symbol": "SPY261218P00440000", + "last": 0.5, + "open_interest": 646, + "right": "put", + "strike": 440.0, + "volume": 2 + }, + { + "ask": 0.53, + "bid": 0.52, + "contract_symbol": "SPY261218P00445000", + "last": 0.5, + "open_interest": 1850, + "right": "put", + "strike": 445.0, + "volume": 3 + }, + { + "ask": 0.55, + "bid": 0.54, + "contract_symbol": "SPY261218P00450000", + "last": 0.55, + "open_interest": 27719, + "right": "put", + "strike": 450.0, + "volume": 3 + }, + { + "ask": 0.58, + "bid": 0.57, + "contract_symbol": "SPY261218P00455000", + "last": 0.6, + "open_interest": 851, + "right": "put", + "strike": 455.0, + "volume": 1 + }, + { + "ask": 0.61, + "bid": 0.6, + "contract_symbol": "SPY261218P00460000", + "last": 0.58, + "open_interest": 2788, + "right": "put", + "strike": 460.0, + "volume": 615 + }, + { + "ask": 0.64, + "bid": 0.63, + "contract_symbol": "SPY261218P00465000", + "last": 0.62, + "open_interest": 1232, + "right": "put", + "strike": 465.0, + "volume": 3 + }, + { + "ask": 0.67, + "bid": 0.66, + "contract_symbol": "SPY261218P00470000", + "last": 0.65, + "open_interest": 6939, + "right": "put", + "strike": 470.0, + "volume": 62 + }, + { + "ask": 0.7, + "bid": 0.69, + "contract_symbol": "SPY261218P00475000", + "last": 0.8, + "open_interest": 4562, + "right": "put", + "strike": 475.0, + "volume": 1 + }, + { + "ask": 0.73, + "bid": 0.72, + "contract_symbol": "SPY261218P00480000", + "last": 0.72, + "open_interest": 311450, + "right": "put", + "strike": 480.0, + "volume": 125 + }, + { + "ask": 0.77, + "bid": 0.76, + "contract_symbol": "SPY261218P00485000", + "last": 0.74, + "open_interest": 3671, + "right": "put", + "strike": 485.0, + "volume": 25 + }, + { + "ask": 0.8, + "bid": 0.79, + "contract_symbol": "SPY261218P00490000", + "last": 0.77, + "open_interest": 10983, + "right": "put", + "strike": 490.0, + "volume": 20 + }, + { + "ask": 0.84, + "bid": 0.83, + "contract_symbol": "SPY261218P00495000", + "last": 0.83, + "open_interest": 1787, + "right": "put", + "strike": 495.0, + "volume": 1 + }, + { + "ask": 0.88, + "bid": 0.87, + "contract_symbol": "SPY261218P00500000", + "last": 0.87, + "open_interest": 18756, + "right": "put", + "strike": 500.0, + "volume": 255 + }, + { + "ask": 0.92, + "bid": 0.91, + "contract_symbol": "SPY261218P00505000", + "last": 0.94, + "open_interest": 1847, + "right": "put", + "strike": 505.0, + "volume": 3 + }, + { + "ask": 0.97, + "bid": 0.96, + "contract_symbol": "SPY261218P00510000", + "last": 1.16, + "open_interest": 3900, + "right": "put", + "strike": 510.0, + "volume": 3 + }, + { + "ask": 1.01, + "bid": 1.0, + "contract_symbol": "SPY261218P00515000", + "last": 0.99, + "open_interest": 4587, + "right": "put", + "strike": 515.0, + "volume": 25 + }, + { + "ask": 1.06, + "bid": 1.05, + "contract_symbol": "SPY261218P00520000", + "last": 1.06, + "open_interest": 12193, + "right": "put", + "strike": 520.0, + "volume": 65 + }, + { + "ask": 1.11, + "bid": 1.1, + "contract_symbol": "SPY261218P00525000", + "last": 1.11, + "open_interest": 5150, + "right": "put", + "strike": 525.0, + "volume": 20 + }, + { + "ask": 1.17, + "bid": 1.16, + "contract_symbol": "SPY261218P00530000", + "last": 1.14, + "open_interest": 6061, + "right": "put", + "strike": 530.0, + "volume": 3 + }, + { + "ask": 1.22, + "bid": 1.21, + "contract_symbol": "SPY261218P00535000", + "last": 1.22, + "open_interest": 3289, + "right": "put", + "strike": 535.0, + "volume": 7 + }, + { + "ask": 1.28, + "bid": 1.27, + "contract_symbol": "SPY261218P00540000", + "last": 1.26, + "open_interest": 4665, + "right": "put", + "strike": 540.0, + "volume": 9 + }, + { + "ask": 1.34, + "bid": 1.33, + "contract_symbol": "SPY261218P00545000", + "last": 1.34, + "open_interest": 5167, + "right": "put", + "strike": 545.0, + "volume": 35 + }, + { + "ask": 1.41, + "bid": 1.4, + "contract_symbol": "SPY261218P00550000", + "last": 1.4, + "open_interest": 14952, + "right": "put", + "strike": 550.0, + "volume": 255 + }, + { + "ask": 1.47, + "bid": 1.46, + "contract_symbol": "SPY261218P00555000", + "last": 1.67, + "open_interest": 6015, + "right": "put", + "strike": 555.0, + "volume": 2 + }, + { + "ask": 1.55, + "bid": 1.54, + "contract_symbol": "SPY261218P00560000", + "last": 1.88, + "open_interest": 11262, + "right": "put", + "strike": 560.0, + "volume": 5 + }, + { + "ask": 1.62, + "bid": 1.61, + "contract_symbol": "SPY261218P00565000", + "last": 1.67, + "open_interest": 2723, + "right": "put", + "strike": 565.0, + "volume": 3 + }, + { + "ask": 1.7, + "bid": 1.69, + "contract_symbol": "SPY261218P00570000", + "last": 1.69, + "open_interest": 5027, + "right": "put", + "strike": 570.0, + "volume": 12 + }, + { + "ask": 1.79, + "bid": 1.78, + "contract_symbol": "SPY261218P00575000", + "last": 1.76, + "open_interest": 7335, + "right": "put", + "strike": 575.0, + "volume": 11 + }, + { + "ask": 1.88, + "bid": 1.87, + "contract_symbol": "SPY261218P00580000", + "last": 1.84, + "open_interest": 10045, + "right": "put", + "strike": 580.0, + "volume": 2 + }, + { + "ask": 1.97, + "bid": 1.96, + "contract_symbol": "SPY261218P00585000", + "last": 1.98, + "open_interest": 5758, + "right": "put", + "strike": 585.0, + "volume": 12 + }, + { + "ask": 2.07, + "bid": 2.06, + "contract_symbol": "SPY261218P00590000", + "last": 2.08, + "open_interest": 11228, + "right": "put", + "strike": 590.0, + "volume": 14 + }, + { + "ask": 2.18, + "bid": 2.17, + "contract_symbol": "SPY261218P00595000", + "last": 2.64, + "open_interest": 4221, + "right": "put", + "strike": 595.0, + "volume": 192 + }, + { + "ask": 2.3, + "bid": 2.28, + "contract_symbol": "SPY261218P00600000", + "last": 2.29, + "open_interest": 25506, + "right": "put", + "strike": 600.0, + "volume": 294 + }, + { + "ask": 2.42, + "bid": 2.41, + "contract_symbol": "SPY261218P00605000", + "last": 2.4, + "open_interest": 8596, + "right": "put", + "strike": 605.0, + "volume": 78 + }, + { + "ask": 2.55, + "bid": 2.54, + "contract_symbol": "SPY261218P00610000", + "last": 2.54, + "open_interest": 158064, + "right": "put", + "strike": 610.0, + "volume": 455 + }, + { + "ask": 2.69, + "bid": 2.67, + "contract_symbol": "SPY261218P00615000", + "last": 2.7, + "open_interest": 5992, + "right": "put", + "strike": 615.0, + "volume": 26 + }, + { + "ask": 2.83, + "bid": 2.82, + "contract_symbol": "SPY261218P00620000", + "last": 2.84, + "open_interest": 18619, + "right": "put", + "strike": 620.0, + "volume": 142 + }, + { + "ask": 2.99, + "bid": 2.98, + "contract_symbol": "SPY261218P00625000", + "last": 2.99, + "open_interest": 23866, + "right": "put", + "strike": 625.0, + "volume": 304 + }, + { + "ask": 3.16, + "bid": 3.15, + "contract_symbol": "SPY261218P00630000", + "last": 3.17, + "open_interest": 40249, + "right": "put", + "strike": 630.0, + "volume": 47 + }, + { + "ask": 3.34, + "bid": 3.33, + "contract_symbol": "SPY261218P00635000", + "last": 3.31, + "open_interest": 5456, + "right": "put", + "strike": 635.0, + "volume": 5 + }, + { + "ask": 3.54, + "bid": 3.52, + "contract_symbol": "SPY261218P00640000", + "last": 3.56, + "open_interest": 11135, + "right": "put", + "strike": 640.0, + "volume": 507 + }, + { + "ask": 3.75, + "bid": 3.73, + "contract_symbol": "SPY261218P00645000", + "last": 3.72, + "open_interest": 8045, + "right": "put", + "strike": 645.0, + "volume": 40 + }, + { + "ask": 3.98, + "bid": 3.95, + "contract_symbol": "SPY261218P00650000", + "last": 3.97, + "open_interest": 13648, + "right": "put", + "strike": 650.0, + "volume": 10191 + }, + { + "ask": 4.22, + "bid": 4.19, + "contract_symbol": "SPY261218P00655000", + "last": 4.16, + "open_interest": 5619, + "right": "put", + "strike": 655.0, + "volume": 166 + }, + { + "ask": 4.48, + "bid": 4.45, + "contract_symbol": "SPY261218P00660000", + "last": 4.48, + "open_interest": 135090, + "right": "put", + "strike": 660.0, + "volume": 105 + }, + { + "ask": 4.75, + "bid": 4.73, + "contract_symbol": "SPY261218P00665000", + "last": 4.74, + "open_interest": 16890, + "right": "put", + "strike": 665.0, + "volume": 59 + }, + { + "ask": 5.05, + "bid": 5.03, + "contract_symbol": "SPY261218P00670000", + "last": 5.04, + "open_interest": 33494, + "right": "put", + "strike": 670.0, + "volume": 124 + }, + { + "ask": 5.38, + "bid": 5.35, + "contract_symbol": "SPY261218P00675000", + "last": 5.37, + "open_interest": 9926, + "right": "put", + "strike": 675.0, + "volume": 12 + }, + { + "ask": 5.73, + "bid": 5.7, + "contract_symbol": "SPY261218P00680000", + "last": 5.72, + "open_interest": 12523, + "right": "put", + "strike": 680.0, + "volume": 223 + }, + { + "ask": 6.11, + "bid": 6.08, + "contract_symbol": "SPY261218P00685000", + "last": 6.11, + "open_interest": 6479, + "right": "put", + "strike": 685.0, + "volume": 16 + }, + { + "ask": 6.52, + "bid": 6.49, + "contract_symbol": "SPY261218P00690000", + "last": 6.51, + "open_interest": 12025, + "right": "put", + "strike": 690.0, + "volume": 207 + }, + { + "ask": 6.97, + "bid": 6.94, + "contract_symbol": "SPY261218P00695000", + "last": 6.92, + "open_interest": 11989, + "right": "put", + "strike": 695.0, + "volume": 39 + }, + { + "ask": 7.46, + "bid": 7.43, + "contract_symbol": "SPY261218P00700000", + "last": 7.47, + "open_interest": 36981, + "right": "put", + "strike": 700.0, + "volume": 1511 + }, + { + "ask": 7.99, + "bid": 7.96, + "contract_symbol": "SPY261218P00705000", + "last": 8.01, + "open_interest": 3853, + "right": "put", + "strike": 705.0, + "volume": 514 + }, + { + "ask": 8.57, + "bid": 8.54, + "contract_symbol": "SPY261218P00710000", + "last": 8.59, + "open_interest": 17562, + "right": "put", + "strike": 710.0, + "volume": 267 + }, + { + "ask": 9.21, + "bid": 9.17, + "contract_symbol": "SPY261218P00715000", + "last": 9.21, + "open_interest": 4551, + "right": "put", + "strike": 715.0, + "volume": 110 + }, + { + "ask": 9.91, + "bid": 9.87, + "contract_symbol": "SPY261218P00720000", + "last": 9.89, + "open_interest": 6093, + "right": "put", + "strike": 720.0, + "volume": 800 + }, + { + "ask": 10.67, + "bid": 10.63, + "contract_symbol": "SPY261218P00725000", + "last": 10.65, + "open_interest": 6282, + "right": "put", + "strike": 725.0, + "volume": 143 + }, + { + "ask": 11.53, + "bid": 11.47, + "contract_symbol": "SPY261218P00730000", + "last": 11.46, + "open_interest": 16037, + "right": "put", + "strike": 730.0, + "volume": 342 + }, + { + "ask": 12.46, + "bid": 12.4, + "contract_symbol": "SPY261218P00735000", + "last": 12.41, + "open_interest": 33479, + "right": "put", + "strike": 735.0, + "volume": 131 + }, + { + "ask": 13.5, + "bid": 13.43, + "contract_symbol": "SPY261218P00740000", + "last": 13.49, + "open_interest": 15552, + "right": "put", + "strike": 740.0, + "volume": 1729 + }, + { + "ask": 14.64, + "bid": 14.58, + "contract_symbol": "SPY261218P00745000", + "last": 14.59, + "open_interest": 5829, + "right": "put", + "strike": 745.0, + "volume": 198 + }, + { + "ask": 15.92, + "bid": 15.85, + "contract_symbol": "SPY261218P00750000", + "last": 15.92, + "open_interest": 19460, + "right": "put", + "strike": 750.0, + "volume": 562 + }, + { + "ask": 17.34, + "bid": 17.27, + "contract_symbol": "SPY261218P00755000", + "last": 17.22, + "open_interest": 6075, + "right": "put", + "strike": 755.0, + "volume": 377 + }, + { + "ask": 18.93, + "bid": 18.85, + "contract_symbol": "SPY261218P00760000", + "last": 18.95, + "open_interest": 15471, + "right": "put", + "strike": 760.0, + "volume": 3499 + }, + { + "ask": 20.71, + "bid": 20.62, + "contract_symbol": "SPY261218P00765000", + "last": 20.69, + "open_interest": 5986, + "right": "put", + "strike": 765.0, + "volume": 763 + }, + { + "ask": 22.68, + "bid": 22.59, + "contract_symbol": "SPY261218P00770000", + "last": 22.59, + "open_interest": 7258, + "right": "put", + "strike": 770.0, + "volume": 807 + }, + { + "ask": 24.88, + "bid": 24.79, + "contract_symbol": "SPY261218P00775000", + "last": 24.62, + "open_interest": 3377, + "right": "put", + "strike": 775.0, + "volume": 23 + }, + { + "ask": 27.4, + "bid": 26.63, + "contract_symbol": "SPY261218P00780000", + "last": 27.34, + "open_interest": 11848, + "right": "put", + "strike": 780.0, + "volume": 23 + }, + { + "ask": 31.09, + "bid": 28.86, + "contract_symbol": "SPY261218P00785000", + "last": 29.38, + "open_interest": 1016, + "right": "put", + "strike": 785.0, + "volume": 14 + }, + { + "ask": 33.99, + "bid": 32.1, + "contract_symbol": "SPY261218P00790000", + "last": 32.76, + "open_interest": 1190, + "right": "put", + "strike": 790.0, + "volume": 16 + }, + { + "ask": 38.03, + "bid": 34.93, + "contract_symbol": "SPY261218P00795000", + "last": 34.52, + "open_interest": 748, + "right": "put", + "strike": 795.0, + "volume": 30 + }, + { + "ask": 41.57, + "bid": 38.85, + "contract_symbol": "SPY261218P00800000", + "last": 39.5, + "open_interest": 1898, + "right": "put", + "strike": 800.0, + "volume": 148 + }, + { + "ask": 45.45, + "bid": 42.05, + "contract_symbol": "SPY261218P00805000", + "last": 43.0, + "open_interest": 32, + "right": "put", + "strike": 805.0, + "volume": 20 + }, + { + "ask": 50.02, + "bid": 46.27, + "contract_symbol": "SPY261218P00810000", + "last": 42.8, + "open_interest": 112, + "right": "put", + "strike": 810.0, + "volume": 2 + }, + { + "ask": 54.49, + "bid": 50.74, + "contract_symbol": "SPY261218P00815000", + "last": 55.3, + "open_interest": 37, + "right": "put", + "strike": 815.0, + "volume": 1 + }, + { + "ask": 59.09, + "bid": 55.41, + "contract_symbol": "SPY261218P00820000", + "last": 49.23, + "open_interest": 645, + "right": "put", + "strike": 820.0, + "volume": 6 + }, + { + "ask": 63.72, + "bid": 60.22, + "contract_symbol": "SPY261218P00825000", + "last": 53.25, + "open_interest": 198, + "right": "put", + "strike": 825.0, + "volume": 88 + }, + { + "ask": 68.58, + "bid": 65.11, + "contract_symbol": "SPY261218P00830000", + "last": 61.88, + "open_interest": 626, + "right": "put", + "strike": 830.0, + "volume": 5 + }, + { + "ask": 73.49, + "bid": 70.06, + "contract_symbol": "SPY261218P00835000", + "last": 62.26, + "open_interest": 39, + "right": "put", + "strike": 835.0, + "volume": 52 + }, + { + "ask": 78.42, + "bid": 75.04, + "contract_symbol": "SPY261218P00840000", + "last": 70.12, + "open_interest": 1, + "right": "put", + "strike": 840.0, + "volume": 1 + }, + { + "ask": 83.45, + "bid": 80.06, + "contract_symbol": "SPY261218P00845000", + "last": 71.74, + "open_interest": 0, + "right": "put", + "strike": 845.0, + "volume": 2 + }, + { + "ask": 88.42, + "bid": 85.05, + "contract_symbol": "SPY261218P00850000", + "last": 92.89, + "open_interest": 14, + "right": "put", + "strike": 850.0, + "volume": 12 + }, + { + "ask": 93.49, + "bid": 90.01, + "contract_symbol": "SPY261218P00855000", + "last": 89.72, + "open_interest": 0, + "right": "put", + "strike": 855.0, + "volume": 2 + }, + { + "ask": 98.5, + "bid": 95.04, + "contract_symbol": "SPY261218P00860000", + "last": 94.7, + "open_interest": 0, + "right": "put", + "strike": 860.0, + "volume": 2 + }, + { + "ask": 103.5, + "bid": 100.0, + "contract_symbol": "SPY261218P00865000", + "last": 99.71, + "open_interest": 0, + "right": "put", + "strike": 865.0, + "volume": 2 + }, + { + "ask": 108.5, + "bid": 105.03, + "contract_symbol": "SPY261218P00870000", + "last": 104.69, + "open_interest": 0, + "right": "put", + "strike": 870.0, + "volume": 2 + }, + { + "ask": 113.5, + "bid": 109.99, + "contract_symbol": "SPY261218P00875000", + "last": 109.71, + "open_interest": 0, + "right": "put", + "strike": 875.0, + "volume": 2 + }, + { + "ask": 118.5, + "bid": 114.99, + "contract_symbol": "SPY261218P00880000", + "last": 114.71, + "open_interest": 0, + "right": "put", + "strike": 880.0, + "volume": 2 + }, + { + "ask": 123.49, + "bid": 120.02, + "contract_symbol": "SPY261218P00885000", + "last": 119.69, + "open_interest": 0, + "right": "put", + "strike": 885.0, + "volume": null + }, + { + "ask": 138.49, + "bid": 134.97, + "contract_symbol": "SPY261218P00900000", + "last": 129.64, + "open_interest": 22, + "right": "put", + "strike": 900.0, + "volume": 5 + }, + { + "ask": 143.51, + "bid": 139.97, + "contract_symbol": "SPY261218P00905000", + "last": 127.09, + "open_interest": 0, + "right": "put", + "strike": 905.0, + "volume": 1 + }, + { + "ask": 247.5, + "bid": 242.52, + "contract_symbol": "SPY261218P00910000", + "last": 252.32, + "open_interest": 0, + "right": "put", + "strike": 910.0, + "volume": 1 + }, + { + "ask": 235.46, + "bid": 230.21, + "contract_symbol": "SPY261218P00925000", + "last": 253.05, + "open_interest": 0, + "right": "put", + "strike": 925.0, + "volume": null + }, + { + "ask": 179.69, + "bid": 176.39, + "contract_symbol": "SPY261218P00930000", + "last": 191.5, + "open_interest": 0, + "right": "put", + "strike": 930.0, + "volume": 1 + }, + { + "ask": 245.25, + "bid": 241.25, + "contract_symbol": "SPY261218P00935000", + "last": 247.55, + "open_interest": 0, + "right": "put", + "strike": 935.0, + "volume": 1 + }, + { + "ask": 203.45, + "bid": 199.92, + "contract_symbol": "SPY261218P00965000", + "last": 198.43, + "open_interest": 0, + "right": "put", + "strike": 965.0, + "volume": 5 + }, + { + "ask": 208.44, + "bid": 204.92, + "contract_symbol": "SPY261218P00970000", + "last": 203.41, + "open_interest": 0, + "right": "put", + "strike": 970.0, + "volume": 10 + }, + { + "ask": 218.43, + "bid": 214.91, + "contract_symbol": "SPY261218P00980000", + "last": 213.07, + "open_interest": 0, + "right": "put", + "strike": 980.0, + "volume": null + }, + { + "ask": 262.51, + "bid": 259.33, + "contract_symbol": "SPY261218P01000000", + "last": 251.57, + "open_interest": 0, + "right": "put", + "strike": 1000.0, + "volume": 5 + } + ], + "2028-12-15": [ + { + "ask": 716.93, + "bid": 712.5, + "contract_symbol": "SPY281215C00050000", + "last": 718.25, + "open_interest": 3, + "right": "call", + "strike": 50.0, + "volume": 2 + }, + { + "ask": 682.0, + "bid": 677.3, + "contract_symbol": "SPY281215C00060000", + "last": 689.74, + "open_interest": 0, + "right": "call", + "strike": 60.0, + "volume": 3 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215C00070000", + "last": 619.49, + "open_interest": 0, + "right": "call", + "strike": 70.0, + "volume": null + }, + { + "ask": 650.0, + "bid": 647.5, + "contract_symbol": "SPY281215C00090000", + "last": 585.88, + "open_interest": 0, + "right": "call", + "strike": 90.0, + "volume": 1 + }, + { + "ask": 667.0, + "bid": 662.5, + "contract_symbol": "SPY281215C00100000", + "last": 668.58, + "open_interest": 4, + "right": "call", + "strike": 100.0, + "volume": 2 + }, + { + "ask": 662.5, + "bid": 657.57, + "contract_symbol": "SPY281215C00110000", + "last": 662.54, + "open_interest": 1, + "right": "call", + "strike": 110.0, + "volume": null + }, + { + "ask": 652.47, + "bid": 647.54, + "contract_symbol": "SPY281215C00120000", + "last": 652.72, + "open_interest": 0, + "right": "call", + "strike": 120.0, + "volume": 1 + }, + { + "ask": 636.9, + "bid": 632.5, + "contract_symbol": "SPY281215C00130000", + "last": 628.99, + "open_interest": 12, + "right": "call", + "strike": 130.0, + "volume": 10 + }, + { + "ask": 627.0, + "bid": 622.57, + "contract_symbol": "SPY281215C00140000", + "last": 618.77, + "open_interest": 18, + "right": "call", + "strike": 140.0, + "volume": 10 + }, + { + "ask": 618.0, + "bid": 613.75, + "contract_symbol": "SPY281215C00150000", + "last": 609.94, + "open_interest": 30, + "right": "call", + "strike": 150.0, + "volume": 2 + }, + { + "ask": 609.0, + "bid": 604.94, + "contract_symbol": "SPY281215C00160000", + "last": 601.25, + "open_interest": 1, + "right": "call", + "strike": 160.0, + "volume": 1 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215C00170000", + "last": 582.29, + "open_interest": 0, + "right": "call", + "strike": 170.0, + "volume": 2 + }, + { + "ask": 591.5, + "bid": 587.34, + "contract_symbol": "SPY281215C00180000", + "last": 587.0, + "open_interest": 4, + "right": "call", + "strike": 180.0, + "volume": 1 + }, + { + "ask": 582.5, + "bid": 578.56, + "contract_symbol": "SPY281215C00190000", + "last": 573.5, + "open_interest": 2, + "right": "call", + "strike": 190.0, + "volume": 1 + }, + { + "ask": 574.0, + "bid": 569.78, + "contract_symbol": "SPY281215C00200000", + "last": 568.25, + "open_interest": 96, + "right": "call", + "strike": 200.0, + "volume": 1 + }, + { + "ask": 565.0, + "bid": 560.5, + "contract_symbol": "SPY281215C00210000", + "last": 553.88, + "open_interest": 7, + "right": "call", + "strike": 210.0, + "volume": 1 + }, + { + "ask": 556.5, + "bid": 552.24, + "contract_symbol": "SPY281215C00220000", + "last": 552.0, + "open_interest": 20, + "right": "call", + "strike": 220.0, + "volume": 1 + }, + { + "ask": 547.5, + "bid": 543.48, + "contract_symbol": "SPY281215C00230000", + "last": 556.0, + "open_interest": 18, + "right": "call", + "strike": 230.0, + "volume": 1 + }, + { + "ask": 539.0, + "bid": 534.74, + "contract_symbol": "SPY281215C00240000", + "last": 529.5, + "open_interest": 7, + "right": "call", + "strike": 240.0, + "volume": 1 + }, + { + "ask": 530.0, + "bid": 526.0, + "contract_symbol": "SPY281215C00250000", + "last": 521.0, + "open_interest": 25, + "right": "call", + "strike": 250.0, + "volume": 1 + }, + { + "ask": 514.23, + "bid": 510.0, + "contract_symbol": "SPY281215C00260000", + "last": 483.0, + "open_interest": 14, + "right": "call", + "strike": 260.0, + "volume": 2 + }, + { + "ask": 512.5, + "bid": 508.55, + "contract_symbol": "SPY281215C00270000", + "last": 520.0, + "open_interest": 10, + "right": "call", + "strike": 270.0, + "volume": 1 + }, + { + "ask": 504.0, + "bid": 499.84, + "contract_symbol": "SPY281215C00280000", + "last": 499.5, + "open_interest": 4, + "right": "call", + "strike": 280.0, + "volume": 1 + }, + { + "ask": 495.0, + "bid": 491.14, + "contract_symbol": "SPY281215C00290000", + "last": 486.0, + "open_interest": 6, + "right": "call", + "strike": 290.0, + "volume": 1 + }, + { + "ask": 486.5, + "bid": 482.45, + "contract_symbol": "SPY281215C00300000", + "last": 482.75, + "open_interest": 69, + "right": "call", + "strike": 300.0, + "volume": 10 + }, + { + "ask": 470.83, + "bid": 466.5, + "contract_symbol": "SPY281215C00310000", + "last": 448.5, + "open_interest": 19, + "right": "call", + "strike": 310.0, + "volume": 10 + }, + { + "ask": 469.0, + "bid": 465.11, + "contract_symbol": "SPY281215C00320000", + "last": 469.66, + "open_interest": 121, + "right": "call", + "strike": 320.0, + "volume": 1 + }, + { + "ask": 465.0, + "bid": 460.78, + "contract_symbol": "SPY281215C00325000", + "last": 460.0, + "open_interest": 11, + "right": "call", + "strike": 325.0, + "volume": 1 + }, + { + "ask": 460.5, + "bid": 456.46, + "contract_symbol": "SPY281215C00330000", + "last": 464.5, + "open_interest": 26, + "right": "call", + "strike": 330.0, + "volume": 1 + }, + { + "ask": 449.26, + "bid": 445.0, + "contract_symbol": "SPY281215C00335000", + "last": 435.5, + "open_interest": 9, + "right": "call", + "strike": 335.0, + "volume": 1 + }, + { + "ask": 400.0, + "bid": 395.28, + "contract_symbol": "SPY281215C00340000", + "last": 360.81, + "open_interest": 22, + "right": "call", + "strike": 340.0, + "volume": 3 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215C00345000", + "last": 434.36, + "open_interest": 0, + "right": "call", + "strike": 345.0, + "volume": 7 + }, + { + "ask": 443.5, + "bid": 439.19, + "contract_symbol": "SPY281215C00350000", + "last": 434.0, + "open_interest": 83, + "right": "call", + "strike": 350.0, + "volume": 1 + }, + { + "ask": 439.0, + "bid": 434.88, + "contract_symbol": "SPY281215C00355000", + "last": 445.66, + "open_interest": 10, + "right": "call", + "strike": 355.0, + "volume": 2 + }, + { + "ask": 435.0, + "bid": 430.57, + "contract_symbol": "SPY281215C00360000", + "last": 438.5, + "open_interest": 11, + "right": "call", + "strike": 360.0, + "volume": 5 + }, + { + "ask": 409.0, + "bid": 404.5, + "contract_symbol": "SPY281215C00365000", + "last": 386.12, + "open_interest": 1, + "right": "call", + "strike": 365.0, + "volume": 1 + }, + { + "ask": 426.0, + "bid": 421.98, + "contract_symbol": "SPY281215C00370000", + "last": 422.76, + "open_interest": 8, + "right": "call", + "strike": 370.0, + "volume": 1 + }, + { + "ask": 422.0, + "bid": 417.69, + "contract_symbol": "SPY281215C00375000", + "last": 414.88, + "open_interest": 16, + "right": "call", + "strike": 375.0, + "volume": 1 + }, + { + "ask": 417.5, + "bid": 413.4, + "contract_symbol": "SPY281215C00380000", + "last": 410.58, + "open_interest": 19, + "right": "call", + "strike": 380.0, + "volume": 1 + }, + { + "ask": 404.5, + "bid": 400.5, + "contract_symbol": "SPY281215C00385000", + "last": 383.57, + "open_interest": 6, + "right": "call", + "strike": 385.0, + "volume": 1 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215C00390000", + "last": 339.84, + "open_interest": 0, + "right": "call", + "strike": 390.0, + "volume": 2 + }, + { + "ask": 383.84, + "bid": 379.5, + "contract_symbol": "SPY281215C00395000", + "last": 360.89, + "open_interest": 2, + "right": "call", + "strike": 395.0, + "volume": null + }, + { + "ask": 400.5, + "bid": 396.3, + "contract_symbol": "SPY281215C00400000", + "last": 399.75, + "open_interest": 97, + "right": "call", + "strike": 400.0, + "volume": 3 + }, + { + "ask": 396.5, + "bid": 392.04, + "contract_symbol": "SPY281215C00405000", + "last": 395.68, + "open_interest": 7, + "right": "call", + "strike": 405.0, + "volume": 1 + }, + { + "ask": 392.0, + "bid": 387.79, + "contract_symbol": "SPY281215C00410000", + "last": 391.79, + "open_interest": 101, + "right": "call", + "strike": 410.0, + "volume": 24 + }, + { + "ask": 388.0, + "bid": 383.54, + "contract_symbol": "SPY281215C00415000", + "last": 391.0, + "open_interest": 17, + "right": "call", + "strike": 415.0, + "volume": 2 + }, + { + "ask": 383.5, + "bid": 379.3, + "contract_symbol": "SPY281215C00420000", + "last": 386.39, + "open_interest": 31, + "right": "call", + "strike": 420.0, + "volume": 2 + }, + { + "ask": 379.5, + "bid": 375.07, + "contract_symbol": "SPY281215C00425000", + "last": 374.7, + "open_interest": 11, + "right": "call", + "strike": 425.0, + "volume": 1 + }, + { + "ask": 375.5, + "bid": 370.84, + "contract_symbol": "SPY281215C00430000", + "last": 378.5, + "open_interest": 3, + "right": "call", + "strike": 430.0, + "volume": 2 + }, + { + "ask": 371.0, + "bid": 366.62, + "contract_symbol": "SPY281215C00435000", + "last": 368.68, + "open_interest": 3, + "right": "call", + "strike": 435.0, + "volume": 18 + }, + { + "ask": 367.0, + "bid": 362.41, + "contract_symbol": "SPY281215C00440000", + "last": 359.48, + "open_interest": 10, + "right": "call", + "strike": 440.0, + "volume": 4 + }, + { + "ask": 363.0, + "bid": 358.21, + "contract_symbol": "SPY281215C00445000", + "last": 370.0, + "open_interest": 2, + "right": "call", + "strike": 445.0, + "volume": 1 + }, + { + "ask": 358.5, + "bid": 354.01, + "contract_symbol": "SPY281215C00450000", + "last": 351.5, + "open_interest": 82, + "right": "call", + "strike": 450.0, + "volume": 1 + }, + { + "ask": 334.16, + "bid": 330.0, + "contract_symbol": "SPY281215C00455000", + "last": 341.73, + "open_interest": 16, + "right": "call", + "strike": 455.0, + "volume": 1 + }, + { + "ask": 350.0, + "bid": 345.66, + "contract_symbol": "SPY281215C00460000", + "last": 354.35, + "open_interest": 83, + "right": "call", + "strike": 460.0, + "volume": 5 + }, + { + "ask": 346.0, + "bid": 341.49, + "contract_symbol": "SPY281215C00465000", + "last": 353.0, + "open_interest": 16, + "right": "call", + "strike": 465.0, + "volume": 1 + }, + { + "ask": 342.0, + "bid": 337.33, + "contract_symbol": "SPY281215C00470000", + "last": 339.6, + "open_interest": 4, + "right": "call", + "strike": 470.0, + "volume": 1 + }, + { + "ask": 337.5, + "bid": 333.18, + "contract_symbol": "SPY281215C00475000", + "last": 335.62, + "open_interest": 41, + "right": "call", + "strike": 475.0, + "volume": 1 + }, + { + "ask": 333.13, + "bid": 328.5, + "contract_symbol": "SPY281215C00480000", + "last": 321.35, + "open_interest": 5, + "right": "call", + "strike": 480.0, + "volume": 1 + }, + { + "ask": 309.9, + "bid": 305.5, + "contract_symbol": "SPY281215C00485000", + "last": 319.53, + "open_interest": 18, + "right": "call", + "strike": 485.0, + "volume": 1 + }, + { + "ask": 325.5, + "bid": 320.8, + "contract_symbol": "SPY281215C00490000", + "last": 331.0, + "open_interest": 7, + "right": "call", + "strike": 490.0, + "volume": 2 + }, + { + "ask": 321.0, + "bid": 316.69, + "contract_symbol": "SPY281215C00495000", + "last": 327.5, + "open_interest": 7, + "right": "call", + "strike": 495.0, + "volume": 1 + }, + { + "ask": 317.0, + "bid": 312.6, + "contract_symbol": "SPY281215C00500000", + "last": 314.79, + "open_interest": 169, + "right": "call", + "strike": 500.0, + "volume": 1 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215C00505000", + "last": 306.67, + "open_interest": 0, + "right": "call", + "strike": 505.0, + "volume": 14 + }, + { + "ask": 309.0, + "bid": 304.44, + "contract_symbol": "SPY281215C00510000", + "last": 301.35, + "open_interest": 31, + "right": "call", + "strike": 510.0, + "volume": 2 + }, + { + "ask": 304.5, + "bid": 299.62, + "contract_symbol": "SPY281215C00515000", + "last": 283.56, + "open_interest": 12, + "right": "call", + "strike": 515.0, + "volume": 1 + }, + { + "ask": 301.0, + "bid": 296.32, + "contract_symbol": "SPY281215C00520000", + "last": 301.38, + "open_interest": 65, + "right": "call", + "strike": 520.0, + "volume": 33 + }, + { + "ask": 297.0, + "bid": 292.28, + "contract_symbol": "SPY281215C00525000", + "last": 295.34, + "open_interest": 60, + "right": "call", + "strike": 525.0, + "volume": 1 + }, + { + "ask": 292.5, + "bid": 288.26, + "contract_symbol": "SPY281215C00530000", + "last": 298.5, + "open_interest": 20, + "right": "call", + "strike": 530.0, + "volume": 1 + }, + { + "ask": 288.5, + "bid": 284.25, + "contract_symbol": "SPY281215C00535000", + "last": 293.5, + "open_interest": 23, + "right": "call", + "strike": 535.0, + "volume": 1 + }, + { + "ask": 284.5, + "bid": 279.63, + "contract_symbol": "SPY281215C00540000", + "last": 270.0, + "open_interest": 7, + "right": "call", + "strike": 540.0, + "volume": 1 + }, + { + "ask": 280.5, + "bid": 276.25, + "contract_symbol": "SPY281215C00545000", + "last": 286.05, + "open_interest": 12, + "right": "call", + "strike": 545.0, + "volume": 1 + }, + { + "ask": 276.5, + "bid": 272.28, + "contract_symbol": "SPY281215C00550000", + "last": 283.61, + "open_interest": 103, + "right": "call", + "strike": 550.0, + "volume": 4 + }, + { + "ask": 273.0, + "bid": 268.32, + "contract_symbol": "SPY281215C00555000", + "last": 275.68, + "open_interest": 161, + "right": "call", + "strike": 555.0, + "volume": 1 + }, + { + "ask": 269.0, + "bid": 264.37, + "contract_symbol": "SPY281215C00560000", + "last": 268.72, + "open_interest": 50, + "right": "call", + "strike": 560.0, + "volume": 1 + }, + { + "ask": 265.0, + "bid": 260.44, + "contract_symbol": "SPY281215C00565000", + "last": 273.19, + "open_interest": 9, + "right": "call", + "strike": 565.0, + "volume": 1 + }, + { + "ask": 261.0, + "bid": 256.52, + "contract_symbol": "SPY281215C00570000", + "last": 266.48, + "open_interest": 34, + "right": "call", + "strike": 570.0, + "volume": 1 + }, + { + "ask": 257.0, + "bid": 252.61, + "contract_symbol": "SPY281215C00575000", + "last": 252.34, + "open_interest": 33, + "right": "call", + "strike": 575.0, + "volume": 4 + }, + { + "ask": 253.0, + "bid": 248.72, + "contract_symbol": "SPY281215C00580000", + "last": 250.66, + "open_interest": 55, + "right": "call", + "strike": 580.0, + "volume": 2 + }, + { + "ask": 249.0, + "bid": 244.85, + "contract_symbol": "SPY281215C00585000", + "last": 249.53, + "open_interest": 21, + "right": "call", + "strike": 585.0, + "volume": 1 + }, + { + "ask": 245.5, + "bid": 240.99, + "contract_symbol": "SPY281215C00590000", + "last": 245.19, + "open_interest": 55, + "right": "call", + "strike": 590.0, + "volume": 5 + }, + { + "ask": 241.5, + "bid": 237.14, + "contract_symbol": "SPY281215C00595000", + "last": 245.0, + "open_interest": 16, + "right": "call", + "strike": 595.0, + "volume": 12 + }, + { + "ask": 237.5, + "bid": 233.32, + "contract_symbol": "SPY281215C00600000", + "last": 236.39, + "open_interest": 268, + "right": "call", + "strike": 600.0, + "volume": 3 + }, + { + "ask": 234.0, + "bid": 229.51, + "contract_symbol": "SPY281215C00605000", + "last": 226.0, + "open_interest": 47, + "right": "call", + "strike": 605.0, + "volume": 1 + }, + { + "ask": 230.0, + "bid": 225.71, + "contract_symbol": "SPY281215C00610000", + "last": 223.75, + "open_interest": 30, + "right": "call", + "strike": 610.0, + "volume": 1 + }, + { + "ask": 226.5, + "bid": 221.94, + "contract_symbol": "SPY281215C00615000", + "last": 223.12, + "open_interest": 76, + "right": "call", + "strike": 615.0, + "volume": 2 + }, + { + "ask": 222.5, + "bid": 218.18, + "contract_symbol": "SPY281215C00620000", + "last": 227.25, + "open_interest": 88, + "right": "call", + "strike": 620.0, + "volume": 14 + }, + { + "ask": 219.0, + "bid": 214.43, + "contract_symbol": "SPY281215C00625000", + "last": 220.22, + "open_interest": 235, + "right": "call", + "strike": 625.0, + "volume": 1 + }, + { + "ask": 215.0, + "bid": 210.71, + "contract_symbol": "SPY281215C00630000", + "last": 214.5, + "open_interest": 929, + "right": "call", + "strike": 630.0, + "volume": 2 + }, + { + "ask": 211.5, + "bid": 207.01, + "contract_symbol": "SPY281215C00635000", + "last": 207.56, + "open_interest": 345, + "right": "call", + "strike": 635.0, + "volume": 1 + }, + { + "ask": 207.5, + "bid": 203.32, + "contract_symbol": "SPY281215C00640000", + "last": 207.0, + "open_interest": 713, + "right": "call", + "strike": 640.0, + "volume": 5 + }, + { + "ask": 204.0, + "bid": 199.65, + "contract_symbol": "SPY281215C00645000", + "last": 203.39, + "open_interest": 1038, + "right": "call", + "strike": 645.0, + "volume": 1 + }, + { + "ask": 200.5, + "bid": 196.01, + "contract_symbol": "SPY281215C00650000", + "last": 197.69, + "open_interest": 1016, + "right": "call", + "strike": 650.0, + "volume": 7 + }, + { + "ask": 196.5, + "bid": 192.38, + "contract_symbol": "SPY281215C00655000", + "last": 196.5, + "open_interest": 575, + "right": "call", + "strike": 655.0, + "volume": 1 + }, + { + "ask": 193.0, + "bid": 188.77, + "contract_symbol": "SPY281215C00660000", + "last": 191.27, + "open_interest": 829, + "right": "call", + "strike": 660.0, + "volume": 1 + }, + { + "ask": 189.5, + "bid": 185.18, + "contract_symbol": "SPY281215C00665000", + "last": 184.36, + "open_interest": 378, + "right": "call", + "strike": 665.0, + "volume": 5 + }, + { + "ask": 186.0, + "bid": 181.61, + "contract_symbol": "SPY281215C00670000", + "last": 179.0, + "open_interest": 546, + "right": "call", + "strike": 670.0, + "volume": 1 + }, + { + "ask": 182.5, + "bid": 178.07, + "contract_symbol": "SPY281215C00675000", + "last": 178.4, + "open_interest": 635, + "right": "call", + "strike": 675.0, + "volume": 2 + }, + { + "ask": 178.5, + "bid": 174.54, + "contract_symbol": "SPY281215C00680000", + "last": 172.1, + "open_interest": 654, + "right": "call", + "strike": 680.0, + "volume": 1 + }, + { + "ask": 175.0, + "bid": 171.04, + "contract_symbol": "SPY281215C00685000", + "last": 170.79, + "open_interest": 1455, + "right": "call", + "strike": 685.0, + "volume": 3 + }, + { + "ask": 171.5, + "bid": 167.55, + "contract_symbol": "SPY281215C00690000", + "last": 171.0, + "open_interest": 2112, + "right": "call", + "strike": 690.0, + "volume": 2 + }, + { + "ask": 168.5, + "bid": 164.09, + "contract_symbol": "SPY281215C00695000", + "last": 168.7, + "open_interest": 2465, + "right": "call", + "strike": 695.0, + "volume": 3 + }, + { + "ask": 164.7, + "bid": 160.65, + "contract_symbol": "SPY281215C00700000", + "last": 164.0, + "open_interest": 837, + "right": "call", + "strike": 700.0, + "volume": 1 + }, + { + "ask": 161.5, + "bid": 157.24, + "contract_symbol": "SPY281215C00705000", + "last": 157.63, + "open_interest": 427, + "right": "call", + "strike": 705.0, + "volume": 11 + }, + { + "ask": 158.0, + "bid": 153.85, + "contract_symbol": "SPY281215C00710000", + "last": 157.64, + "open_interest": 2015, + "right": "call", + "strike": 710.0, + "volume": 20 + }, + { + "ask": 154.5, + "bid": 150.47, + "contract_symbol": "SPY281215C00715000", + "last": 150.46, + "open_interest": 638, + "right": "call", + "strike": 715.0, + "volume": 2 + }, + { + "ask": 151.0, + "bid": 147.13, + "contract_symbol": "SPY281215C00720000", + "last": 150.73, + "open_interest": 1176, + "right": "call", + "strike": 720.0, + "volume": 1 + }, + { + "ask": 148.0, + "bid": 143.81, + "contract_symbol": "SPY281215C00725000", + "last": 144.0, + "open_interest": 255, + "right": "call", + "strike": 725.0, + "volume": 1 + }, + { + "ask": 144.5, + "bid": 140.51, + "contract_symbol": "SPY281215C00730000", + "last": 143.75, + "open_interest": 1275, + "right": "call", + "strike": 730.0, + "volume": 1 + }, + { + "ask": 141.5, + "bid": 137.24, + "contract_symbol": "SPY281215C00735000", + "last": 137.13, + "open_interest": 1189, + "right": "call", + "strike": 735.0, + "volume": 2 + }, + { + "ask": 138.0, + "bid": 133.99, + "contract_symbol": "SPY281215C00740000", + "last": 137.25, + "open_interest": 1468, + "right": "call", + "strike": 740.0, + "volume": 4 + }, + { + "ask": 135.0, + "bid": 130.78, + "contract_symbol": "SPY281215C00745000", + "last": 133.0, + "open_interest": 1468, + "right": "call", + "strike": 745.0, + "volume": 1 + }, + { + "ask": 131.5, + "bid": 127.58, + "contract_symbol": "SPY281215C00750000", + "last": 130.81, + "open_interest": 1382, + "right": "call", + "strike": 750.0, + "volume": 1 + }, + { + "ask": 128.5, + "bid": 124.41, + "contract_symbol": "SPY281215C00755000", + "last": 123.0, + "open_interest": 600, + "right": "call", + "strike": 755.0, + "volume": 18 + }, + { + "ask": 125.5, + "bid": 121.27, + "contract_symbol": "SPY281215C00760000", + "last": 125.25, + "open_interest": 2249, + "right": "call", + "strike": 760.0, + "volume": 4 + }, + { + "ask": 122.0, + "bid": 118.16, + "contract_symbol": "SPY281215C00765000", + "last": 121.14, + "open_interest": 549, + "right": "call", + "strike": 765.0, + "volume": 48 + }, + { + "ask": 119.0, + "bid": 117.0, + "contract_symbol": "SPY281215C00770000", + "last": 117.92, + "open_interest": 1113, + "right": "call", + "strike": 770.0, + "volume": 20 + }, + { + "ask": 116.0, + "bid": 112.02, + "contract_symbol": "SPY281215C00775000", + "last": 115.33, + "open_interest": 1743, + "right": "call", + "strike": 775.0, + "volume": 1 + }, + { + "ask": 113.0, + "bid": 109.0, + "contract_symbol": "SPY281215C00780000", + "last": 112.46, + "open_interest": 327, + "right": "call", + "strike": 780.0, + "volume": 1 + }, + { + "ask": 110.0, + "bid": 106.01, + "contract_symbol": "SPY281215C00785000", + "last": 104.36, + "open_interest": 320, + "right": "call", + "strike": 785.0, + "volume": 2 + }, + { + "ask": 107.5, + "bid": 103.06, + "contract_symbol": "SPY281215C00790000", + "last": 106.47, + "open_interest": 152, + "right": "call", + "strike": 790.0, + "volume": 1 + }, + { + "ask": 104.5, + "bid": 100.13, + "contract_symbol": "SPY281215C00795000", + "last": 98.76, + "open_interest": 93, + "right": "call", + "strike": 795.0, + "volume": 1 + }, + { + "ask": 101.5, + "bid": 97.24, + "contract_symbol": "SPY281215C00800000", + "last": 99.36, + "open_interest": 4480, + "right": "call", + "strike": 800.0, + "volume": 26 + }, + { + "ask": 98.5, + "bid": 94.4, + "contract_symbol": "SPY281215C00805000", + "last": 99.0, + "open_interest": 267, + "right": "call", + "strike": 805.0, + "volume": 1 + }, + { + "ask": 96.0, + "bid": 91.59, + "contract_symbol": "SPY281215C00810000", + "last": 95.56, + "open_interest": 1139, + "right": "call", + "strike": 810.0, + "volume": 7 + }, + { + "ask": 93.0, + "bid": 88.8, + "contract_symbol": "SPY281215C00815000", + "last": 93.01, + "open_interest": 275, + "right": "call", + "strike": 815.0, + "volume": 1 + }, + { + "ask": 90.5, + "bid": 86.07, + "contract_symbol": "SPY281215C00820000", + "last": 89.58, + "open_interest": 793, + "right": "call", + "strike": 820.0, + "volume": 1 + }, + { + "ask": 88.0, + "bid": 83.37, + "contract_symbol": "SPY281215C00825000", + "last": 88.14, + "open_interest": 310, + "right": "call", + "strike": 825.0, + "volume": 1 + }, + { + "ask": 85.5, + "bid": 80.72, + "contract_symbol": "SPY281215C00830000", + "last": 79.45, + "open_interest": 1213, + "right": "call", + "strike": 830.0, + "volume": 3 + }, + { + "ask": 82.5, + "bid": 78.09, + "contract_symbol": "SPY281215C00835000", + "last": 81.59, + "open_interest": 215, + "right": "call", + "strike": 835.0, + "volume": 3 + }, + { + "ask": 80.0, + "bid": 75.52, + "contract_symbol": "SPY281215C00840000", + "last": 78.24, + "open_interest": 498, + "right": "call", + "strike": 840.0, + "volume": 1 + }, + { + "ask": 75.61, + "bid": 75.28, + "contract_symbol": "SPY281215C00845000", + "last": 78.18, + "open_interest": 597, + "right": "call", + "strike": 845.0, + "volume": 117 + }, + { + "ask": 73.13, + "bid": 72.8, + "contract_symbol": "SPY281215C00850000", + "last": 73.3, + "open_interest": 758, + "right": "call", + "strike": 850.0, + "volume": 2 + }, + { + "ask": 70.67, + "bid": 70.35, + "contract_symbol": "SPY281215C00855000", + "last": 75.5, + "open_interest": 136, + "right": "call", + "strike": 855.0, + "volume": 1 + }, + { + "ask": 68.28, + "bid": 67.96, + "contract_symbol": "SPY281215C00860000", + "last": 70.4, + "open_interest": 235, + "right": "call", + "strike": 860.0, + "volume": 5 + }, + { + "ask": 65.94, + "bid": 65.61, + "contract_symbol": "SPY281215C00865000", + "last": 67.85, + "open_interest": 298, + "right": "call", + "strike": 865.0, + "volume": 54 + }, + { + "ask": 63.63, + "bid": 63.3, + "contract_symbol": "SPY281215C00870000", + "last": 64.44, + "open_interest": 218, + "right": "call", + "strike": 870.0, + "volume": 1 + }, + { + "ask": 61.37, + "bid": 61.04, + "contract_symbol": "SPY281215C00875000", + "last": 63.28, + "open_interest": 244, + "right": "call", + "strike": 875.0, + "volume": 27 + }, + { + "ask": 59.16, + "bid": 58.84, + "contract_symbol": "SPY281215C00880000", + "last": 58.38, + "open_interest": 194, + "right": "call", + "strike": 880.0, + "volume": 1 + }, + { + "ask": 57.0, + "bid": 56.68, + "contract_symbol": "SPY281215C00885000", + "last": 55.6, + "open_interest": 55, + "right": "call", + "strike": 885.0, + "volume": 5 + }, + { + "ask": 54.88, + "bid": 54.56, + "contract_symbol": "SPY281215C00890000", + "last": 51.8, + "open_interest": 422, + "right": "call", + "strike": 890.0, + "volume": 3 + }, + { + "ask": 52.83, + "bid": 52.51, + "contract_symbol": "SPY281215C00895000", + "last": 50.4, + "open_interest": 2131, + "right": "call", + "strike": 895.0, + "volume": 1 + }, + { + "ask": 50.82, + "bid": 50.51, + "contract_symbol": "SPY281215C00900000", + "last": 51.54, + "open_interest": 2382, + "right": "call", + "strike": 900.0, + "volume": 4 + }, + { + "ask": 48.85, + "bid": 48.54, + "contract_symbol": "SPY281215C00905000", + "last": 47.7, + "open_interest": 361, + "right": "call", + "strike": 905.0, + "volume": 1 + }, + { + "ask": 46.94, + "bid": 46.64, + "contract_symbol": "SPY281215C00910000", + "last": 47.13, + "open_interest": 417, + "right": "call", + "strike": 910.0, + "volume": 2 + }, + { + "ask": 45.1, + "bid": 44.79, + "contract_symbol": "SPY281215C00915000", + "last": 46.78, + "open_interest": 219, + "right": "call", + "strike": 915.0, + "volume": 10 + }, + { + "ask": 43.29, + "bid": 42.99, + "contract_symbol": "SPY281215C00920000", + "last": 43.33, + "open_interest": 286, + "right": "call", + "strike": 920.0, + "volume": 6 + }, + { + "ask": 41.53, + "bid": 41.26, + "contract_symbol": "SPY281215C00925000", + "last": 41.54, + "open_interest": 410, + "right": "call", + "strike": 925.0, + "volume": 19 + }, + { + "ask": 39.84, + "bid": 39.54, + "contract_symbol": "SPY281215C00930000", + "last": 39.94, + "open_interest": 428, + "right": "call", + "strike": 930.0, + "volume": 2 + }, + { + "ask": 38.19, + "bid": 37.93, + "contract_symbol": "SPY281215C00935000", + "last": 35.9, + "open_interest": 95, + "right": "call", + "strike": 935.0, + "volume": 1 + }, + { + "ask": 36.58, + "bid": 36.3, + "contract_symbol": "SPY281215C00940000", + "last": 37.46, + "open_interest": 256, + "right": "call", + "strike": 940.0, + "volume": 1 + }, + { + "ask": 35.04, + "bid": 34.79, + "contract_symbol": "SPY281215C00945000", + "last": 33.75, + "open_interest": 319, + "right": "call", + "strike": 945.0, + "volume": 1 + }, + { + "ask": 33.56, + "bid": 33.28, + "contract_symbol": "SPY281215C00950000", + "last": 34.15, + "open_interest": 525, + "right": "call", + "strike": 950.0, + "volume": 1 + }, + { + "ask": 32.1, + "bid": 31.86, + "contract_symbol": "SPY281215C00955000", + "last": 30.41, + "open_interest": 271, + "right": "call", + "strike": 955.0, + "volume": 1 + }, + { + "ask": 30.72, + "bid": 30.45, + "contract_symbol": "SPY281215C00960000", + "last": 28.81, + "open_interest": 345, + "right": "call", + "strike": 960.0, + "volume": 11 + }, + { + "ask": 29.38, + "bid": 29.15, + "contract_symbol": "SPY281215C00965000", + "last": 28.01, + "open_interest": 121, + "right": "call", + "strike": 965.0, + "volume": 2 + }, + { + "ask": 28.09, + "bid": 27.83, + "contract_symbol": "SPY281215C00970000", + "last": 28.89, + "open_interest": 243, + "right": "call", + "strike": 970.0, + "volume": 172 + }, + { + "ask": 26.84, + "bid": 26.59, + "contract_symbol": "SPY281215C00975000", + "last": 27.48, + "open_interest": 500, + "right": "call", + "strike": 975.0, + "volume": 5 + }, + { + "ask": 25.66, + "bid": 25.43, + "contract_symbol": "SPY281215C00980000", + "last": 25.75, + "open_interest": 257, + "right": "call", + "strike": 980.0, + "volume": 1 + }, + { + "ask": 24.51, + "bid": 24.28, + "contract_symbol": "SPY281215C00985000", + "last": 24.68, + "open_interest": 166, + "right": "call", + "strike": 985.0, + "volume": 6 + }, + { + "ask": 23.4, + "bid": 23.16, + "contract_symbol": "SPY281215C00990000", + "last": 21.93, + "open_interest": 362, + "right": "call", + "strike": 990.0, + "volume": 6 + }, + { + "ask": 22.35, + "bid": 22.14, + "contract_symbol": "SPY281215C00995000", + "last": 23.89, + "open_interest": 63, + "right": "call", + "strike": 995.0, + "volume": 1 + }, + { + "ask": 21.3, + "bid": 21.16, + "contract_symbol": "SPY281215C01000000", + "last": 21.25, + "open_interest": 8953, + "right": "call", + "strike": 1000.0, + "volume": 61 + }, + { + "ask": 20.33, + "bid": 20.19, + "contract_symbol": "SPY281215C01005000", + "last": 20.39, + "open_interest": 48, + "right": "call", + "strike": 1005.0, + "volume": 3 + }, + { + "ask": 19.4, + "bid": 19.26, + "contract_symbol": "SPY281215C01010000", + "last": 19.64, + "open_interest": 133, + "right": "call", + "strike": 1010.0, + "volume": 11 + }, + { + "ask": 18.52, + "bid": 18.36, + "contract_symbol": "SPY281215C01015000", + "last": 20.62, + "open_interest": 108, + "right": "call", + "strike": 1015.0, + "volume": 1 + }, + { + "ask": 17.66, + "bid": 17.52, + "contract_symbol": "SPY281215C01020000", + "last": 18.03, + "open_interest": 130, + "right": "call", + "strike": 1020.0, + "volume": 40 + }, + { + "ask": 16.84, + "bid": 16.7, + "contract_symbol": "SPY281215C01025000", + "last": 16.07, + "open_interest": 44, + "right": "call", + "strike": 1025.0, + "volume": 2 + }, + { + "ask": 16.07, + "bid": 15.92, + "contract_symbol": "SPY281215C01030000", + "last": 16.48, + "open_interest": 431, + "right": "call", + "strike": 1030.0, + "volume": 1 + }, + { + "ask": 15.32, + "bid": 15.18, + "contract_symbol": "SPY281215C01035000", + "last": 20.13, + "open_interest": 145, + "right": "call", + "strike": 1035.0, + "volume": 1 + }, + { + "ask": 14.61, + "bid": 14.48, + "contract_symbol": "SPY281215C01040000", + "last": 14.71, + "open_interest": 153, + "right": "call", + "strike": 1040.0, + "volume": 3 + }, + { + "ask": 13.93, + "bid": 13.81, + "contract_symbol": "SPY281215C01045000", + "last": 14.46, + "open_interest": 14, + "right": "call", + "strike": 1045.0, + "volume": 2 + }, + { + "ask": 13.29, + "bid": 13.16, + "contract_symbol": "SPY281215C01050000", + "last": 13.65, + "open_interest": 1528, + "right": "call", + "strike": 1050.0, + "volume": 19 + }, + { + "ask": 12.66, + "bid": 12.54, + "contract_symbol": "SPY281215C01055000", + "last": 12.79, + "open_interest": 108, + "right": "call", + "strike": 1055.0, + "volume": 10 + }, + { + "ask": 12.08, + "bid": 11.94, + "contract_symbol": "SPY281215C01060000", + "last": 11.48, + "open_interest": 81, + "right": "call", + "strike": 1060.0, + "volume": 1 + }, + { + "ask": 11.52, + "bid": 11.4, + "contract_symbol": "SPY281215C01065000", + "last": 11.54, + "open_interest": 25, + "right": "call", + "strike": 1065.0, + "volume": 24 + }, + { + "ask": 10.98, + "bid": 10.86, + "contract_symbol": "SPY281215C01070000", + "last": 10.36, + "open_interest": 825, + "right": "call", + "strike": 1070.0, + "volume": 100 + }, + { + "ask": 10.47, + "bid": 10.35, + "contract_symbol": "SPY281215C01075000", + "last": 10.47, + "open_interest": 40, + "right": "call", + "strike": 1075.0, + "volume": 3 + }, + { + "ask": 9.99, + "bid": 9.87, + "contract_symbol": "SPY281215C01080000", + "last": 10.53, + "open_interest": 84, + "right": "call", + "strike": 1080.0, + "volume": 2 + }, + { + "ask": 9.52, + "bid": 9.41, + "contract_symbol": "SPY281215C01085000", + "last": 9.64, + "open_interest": 23, + "right": "call", + "strike": 1085.0, + "volume": 6 + }, + { + "ask": 9.08, + "bid": 8.97, + "contract_symbol": "SPY281215C01090000", + "last": 9.08, + "open_interest": 100, + "right": "call", + "strike": 1090.0, + "volume": 21 + }, + { + "ask": 8.66, + "bid": 8.54, + "contract_symbol": "SPY281215C01095000", + "last": 9.04, + "open_interest": 364, + "right": "call", + "strike": 1095.0, + "volume": 1 + }, + { + "ask": 8.27, + "bid": 8.15, + "contract_symbol": "SPY281215C01100000", + "last": 8.36, + "open_interest": 3445, + "right": "call", + "strike": 1100.0, + "volume": 639 + }, + { + "ask": 7.88, + "bid": 7.77, + "contract_symbol": "SPY281215C01105000", + "last": 9.14, + "open_interest": 15, + "right": "call", + "strike": 1105.0, + "volume": 1 + }, + { + "ask": 7.52, + "bid": 7.41, + "contract_symbol": "SPY281215C01110000", + "last": 7.07, + "open_interest": 402, + "right": "call", + "strike": 1110.0, + "volume": 6 + }, + { + "ask": 7.18, + "bid": 7.08, + "contract_symbol": "SPY281215C01115000", + "last": 7.3, + "open_interest": 275, + "right": "call", + "strike": 1115.0, + "volume": 1 + }, + { + "ask": 6.86, + "bid": 6.75, + "contract_symbol": "SPY281215C01120000", + "last": 6.43, + "open_interest": 246, + "right": "call", + "strike": 1120.0, + "volume": 6 + }, + { + "ask": 6.54, + "bid": 6.44, + "contract_symbol": "SPY281215C01125000", + "last": 6.21, + "open_interest": 85, + "right": "call", + "strike": 1125.0, + "volume": 1 + }, + { + "ask": 6.25, + "bid": 6.14, + "contract_symbol": "SPY281215C01130000", + "last": 5.86, + "open_interest": 560, + "right": "call", + "strike": 1130.0, + "volume": 1 + }, + { + "ask": 5.97, + "bid": 5.87, + "contract_symbol": "SPY281215C01135000", + "last": 8.35, + "open_interest": 243, + "right": "call", + "strike": 1135.0, + "volume": 5 + }, + { + "ask": 5.71, + "bid": 5.61, + "contract_symbol": "SPY281215C01140000", + "last": 5.91, + "open_interest": 164, + "right": "call", + "strike": 1140.0, + "volume": 1 + }, + { + "ask": 5.18, + "bid": 5.13, + "contract_symbol": "SPY281215C01150000", + "last": 5.4, + "open_interest": 972, + "right": "call", + "strike": 1150.0, + "volume": 1 + }, + { + "ask": 4.74, + "bid": 4.7, + "contract_symbol": "SPY281215C01160000", + "last": 4.74, + "open_interest": 411, + "right": "call", + "strike": 1160.0, + "volume": 3 + }, + { + "ask": 4.34, + "bid": 4.3, + "contract_symbol": "SPY281215C01170000", + "last": 5.27, + "open_interest": 258, + "right": "call", + "strike": 1170.0, + "volume": 4 + }, + { + "ask": 3.98, + "bid": 3.94, + "contract_symbol": "SPY281215C01180000", + "last": 4.11, + "open_interest": 16, + "right": "call", + "strike": 1180.0, + "volume": 1 + }, + { + "ask": 3.65, + "bid": 3.62, + "contract_symbol": "SPY281215C01190000", + "last": 3.66, + "open_interest": 329, + "right": "call", + "strike": 1190.0, + "volume": 2 + }, + { + "ask": 3.36, + "bid": 3.35, + "contract_symbol": "SPY281215C01200000", + "last": 3.43, + "open_interest": 4222, + "right": "call", + "strike": 1200.0, + "volume": 315 + }, + { + "ask": 3.09, + "bid": 3.06, + "contract_symbol": "SPY281215C01210000", + "last": 3.18, + "open_interest": 748, + "right": "call", + "strike": 1210.0, + "volume": 98 + }, + { + "ask": 2.87, + "bid": 2.82, + "contract_symbol": "SPY281215C01220000", + "last": 2.78, + "open_interest": 477, + "right": "call", + "strike": 1220.0, + "volume": 1 + }, + { + "ask": 2.64, + "bid": 2.61, + "contract_symbol": "SPY281215C01230000", + "last": 3.93, + "open_interest": 242, + "right": "call", + "strike": 1230.0, + "volume": 4 + }, + { + "ask": 2.44, + "bid": 2.41, + "contract_symbol": "SPY281215C01240000", + "last": 2.32, + "open_interest": 561, + "right": "call", + "strike": 1240.0, + "volume": 10 + }, + { + "ask": 2.26, + "bid": 2.24, + "contract_symbol": "SPY281215C01250000", + "last": 2.14, + "open_interest": 3618, + "right": "call", + "strike": 1250.0, + "volume": 8 + }, + { + "ask": 2.1, + "bid": 2.08, + "contract_symbol": "SPY281215C01260000", + "last": 2.0, + "open_interest": 590, + "right": "call", + "strike": 1260.0, + "volume": 1 + }, + { + "ask": 1.96, + "bid": 1.93, + "contract_symbol": "SPY281215C01270000", + "last": 1.85, + "open_interest": 74, + "right": "call", + "strike": 1270.0, + "volume": 1 + }, + { + "ask": 1.82, + "bid": 1.8, + "contract_symbol": "SPY281215C01280000", + "last": 2.05, + "open_interest": 1145, + "right": "call", + "strike": 1280.0, + "volume": 1 + }, + { + "ask": 1.7, + "bid": 1.68, + "contract_symbol": "SPY281215C01290000", + "last": 1.64, + "open_interest": 476, + "right": "call", + "strike": 1290.0, + "volume": 1 + }, + { + "ask": 1.59, + "bid": 1.57, + "contract_symbol": "SPY281215C01300000", + "last": 1.63, + "open_interest": 2726, + "right": "call", + "strike": 1300.0, + "volume": 1 + }, + { + "ask": 1.49, + "bid": 1.47, + "contract_symbol": "SPY281215C01310000", + "last": 1.56, + "open_interest": 719, + "right": "call", + "strike": 1310.0, + "volume": 2 + }, + { + "ask": 1.4, + "bid": 1.38, + "contract_symbol": "SPY281215C01320000", + "last": 1.64, + "open_interest": 849, + "right": "call", + "strike": 1320.0, + "volume": 1 + }, + { + "ask": 1.31, + "bid": 1.29, + "contract_symbol": "SPY281215C01330000", + "last": 1.32, + "open_interest": 294, + "right": "call", + "strike": 1330.0, + "volume": 5 + }, + { + "ask": 1.24, + "bid": 1.22, + "contract_symbol": "SPY281215C01340000", + "last": 1.19, + "open_interest": 664, + "right": "call", + "strike": 1340.0, + "volume": 1 + }, + { + "ask": 1.17, + "bid": 1.15, + "contract_symbol": "SPY281215C01350000", + "last": 1.2, + "open_interest": 5263, + "right": "call", + "strike": 1350.0, + "volume": 80 + }, + { + "ask": 1.11, + "bid": 1.09, + "contract_symbol": "SPY281215C01360000", + "last": 1.27, + "open_interest": 12, + "right": "call", + "strike": 1360.0, + "volume": 1 + }, + { + "ask": 1.06, + "bid": 1.04, + "contract_symbol": "SPY281215C01370000", + "last": 1.06, + "open_interest": 1027, + "right": "call", + "strike": 1370.0, + "volume": 5 + }, + { + "ask": 1.01, + "bid": 0.99, + "contract_symbol": "SPY281215C01380000", + "last": 1.02, + "open_interest": 171, + "right": "call", + "strike": 1380.0, + "volume": 30 + }, + { + "ask": 0.97, + "bid": 0.95, + "contract_symbol": "SPY281215C01390000", + "last": 0.97, + "open_interest": 44, + "right": "call", + "strike": 1390.0, + "volume": 15 + }, + { + "ask": 0.93, + "bid": 0.91, + "contract_symbol": "SPY281215C01400000", + "last": 0.93, + "open_interest": 259, + "right": "call", + "strike": 1400.0, + "volume": 9 + }, + { + "ask": 0.89, + "bid": 0.87, + "contract_symbol": "SPY281215C01410000", + "last": 1.12, + "open_interest": 12, + "right": "call", + "strike": 1410.0, + "volume": 1 + }, + { + "ask": 0.86, + "bid": 0.84, + "contract_symbol": "SPY281215C01420000", + "last": 0.87, + "open_interest": 17, + "right": "call", + "strike": 1420.0, + "volume": 2 + }, + { + "ask": 0.83, + "bid": 0.81, + "contract_symbol": "SPY281215C01430000", + "last": 1.0, + "open_interest": 113, + "right": "call", + "strike": 1430.0, + "volume": 110 + }, + { + "ask": 0.8, + "bid": 0.78, + "contract_symbol": "SPY281215C01440000", + "last": 0.99, + "open_interest": 12, + "right": "call", + "strike": 1440.0, + "volume": 6 + }, + { + "ask": 0.78, + "bid": 0.76, + "contract_symbol": "SPY281215C01450000", + "last": 0.79, + "open_interest": 73, + "right": "call", + "strike": 1450.0, + "volume": 1 + }, + { + "ask": 0.75, + "bid": 0.73, + "contract_symbol": "SPY281215C01460000", + "last": 1.02, + "open_interest": 57, + "right": "call", + "strike": 1460.0, + "volume": 1 + }, + { + "ask": 0.73, + "bid": 0.71, + "contract_symbol": "SPY281215C01470000", + "last": 0.72, + "open_interest": 562, + "right": "call", + "strike": 1470.0, + "volume": 1 + }, + { + "ask": 0.71, + "bid": 0.69, + "contract_symbol": "SPY281215C01480000", + "last": 0.69, + "open_interest": 10853, + "right": "call", + "strike": 1480.0, + "volume": 16 + }, + { + "ask": 0.07, + "bid": 0.06, + "contract_symbol": "SPY281215P00050000", + "last": 0.07, + "open_interest": 7075, + "right": "put", + "strike": 50.0, + "volume": 8 + }, + { + "ask": 0.1, + "bid": 0.08, + "contract_symbol": "SPY281215P00060000", + "last": 0.08, + "open_interest": 93, + "right": "put", + "strike": 60.0, + "volume": 2 + }, + { + "ask": 0.13, + "bid": 0.12, + "contract_symbol": "SPY281215P00070000", + "last": 0.12, + "open_interest": 19, + "right": "put", + "strike": 70.0, + "volume": 1 + }, + { + "ask": 0.18, + "bid": 0.16, + "contract_symbol": "SPY281215P00080000", + "last": 0.18, + "open_interest": 18, + "right": "put", + "strike": 80.0, + "volume": 1 + }, + { + "ask": 0.23, + "bid": 0.21, + "contract_symbol": "SPY281215P00090000", + "last": 0.25, + "open_interest": 58, + "right": "put", + "strike": 90.0, + "volume": 2 + }, + { + "ask": 0.29, + "bid": 0.28, + "contract_symbol": "SPY281215P00100000", + "last": 0.29, + "open_interest": 997, + "right": "put", + "strike": 100.0, + "volume": 20 + }, + { + "ask": 0.36, + "bid": 0.34, + "contract_symbol": "SPY281215P00110000", + "last": 0.38, + "open_interest": 97, + "right": "put", + "strike": 110.0, + "volume": 42 + }, + { + "ask": 0.44, + "bid": 0.43, + "contract_symbol": "SPY281215P00120000", + "last": 0.51, + "open_interest": 135, + "right": "put", + "strike": 120.0, + "volume": 49 + }, + { + "ask": 0.53, + "bid": 0.52, + "contract_symbol": "SPY281215P00130000", + "last": 0.63, + "open_interest": 523, + "right": "put", + "strike": 130.0, + "volume": 26 + }, + { + "ask": 0.64, + "bid": 0.62, + "contract_symbol": "SPY281215P00140000", + "last": 0.67, + "open_interest": 105, + "right": "put", + "strike": 140.0, + "volume": 50 + }, + { + "ask": 0.74, + "bid": 0.73, + "contract_symbol": "SPY281215P00150000", + "last": 0.79, + "open_interest": 157, + "right": "put", + "strike": 150.0, + "volume": 6 + }, + { + "ask": 0.86, + "bid": 0.85, + "contract_symbol": "SPY281215P00160000", + "last": 0.85, + "open_interest": 61, + "right": "put", + "strike": 160.0, + "volume": 50 + }, + { + "ask": 0.98, + "bid": 0.97, + "contract_symbol": "SPY281215P00170000", + "last": 0.98, + "open_interest": 9, + "right": "put", + "strike": 170.0, + "volume": 1 + }, + { + "ask": 1.12, + "bid": 1.1, + "contract_symbol": "SPY281215P00180000", + "last": 1.28, + "open_interest": 115, + "right": "put", + "strike": 180.0, + "volume": 15 + }, + { + "ask": 1.43, + "bid": 1.4, + "contract_symbol": "SPY281215P00190000", + "last": 1.62, + "open_interest": 286, + "right": "put", + "strike": 190.0, + "volume": 2 + }, + { + "ask": 1.4, + "bid": 1.38, + "contract_symbol": "SPY281215P00200000", + "last": 1.48, + "open_interest": 2816, + "right": "put", + "strike": 200.0, + "volume": 1 + }, + { + "ask": 1.55, + "bid": 1.54, + "contract_symbol": "SPY281215P00210000", + "last": 1.53, + "open_interest": 135, + "right": "put", + "strike": 210.0, + "volume": 1 + }, + { + "ask": 1.73, + "bid": 1.7, + "contract_symbol": "SPY281215P00220000", + "last": 1.97, + "open_interest": 35, + "right": "put", + "strike": 220.0, + "volume": 20 + }, + { + "ask": 1.88, + "bid": 1.87, + "contract_symbol": "SPY281215P00230000", + "last": 1.86, + "open_interest": 205, + "right": "put", + "strike": 230.0, + "volume": 1 + }, + { + "ask": 2.06, + "bid": 2.05, + "contract_symbol": "SPY281215P00240000", + "last": 2.07, + "open_interest": 87, + "right": "put", + "strike": 240.0, + "volume": 1 + }, + { + "ask": 2.25, + "bid": 2.23, + "contract_symbol": "SPY281215P00250000", + "last": 2.38, + "open_interest": 624, + "right": "put", + "strike": 250.0, + "volume": 5 + }, + { + "ask": 2.45, + "bid": 2.43, + "contract_symbol": "SPY281215P00260000", + "last": 2.57, + "open_interest": 60, + "right": "put", + "strike": 260.0, + "volume": 6 + }, + { + "ask": 2.66, + "bid": 2.64, + "contract_symbol": "SPY281215P00270000", + "last": 2.76, + "open_interest": 23, + "right": "put", + "strike": 270.0, + "volume": 2 + }, + { + "ask": 2.87, + "bid": 2.86, + "contract_symbol": "SPY281215P00280000", + "last": 3.24, + "open_interest": 48, + "right": "put", + "strike": 280.0, + "volume": 1 + }, + { + "ask": 3.1, + "bid": 3.08, + "contract_symbol": "SPY281215P00290000", + "last": 3.14, + "open_interest": 450, + "right": "put", + "strike": 290.0, + "volume": 8 + }, + { + "ask": 3.34, + "bid": 3.32, + "contract_symbol": "SPY281215P00300000", + "last": 3.33, + "open_interest": 1352, + "right": "put", + "strike": 300.0, + "volume": 1 + }, + { + "ask": 4.06, + "bid": 4.01, + "contract_symbol": "SPY281215P00310000", + "last": 4.76, + "open_interest": 563, + "right": "put", + "strike": 310.0, + "volume": 5 + }, + { + "ask": 3.86, + "bid": 3.84, + "contract_symbol": "SPY281215P00320000", + "last": 4.11, + "open_interest": 230, + "right": "put", + "strike": 320.0, + "volume": 2 + }, + { + "ask": 3.99, + "bid": 3.97, + "contract_symbol": "SPY281215P00325000", + "last": 4.5, + "open_interest": 66, + "right": "put", + "strike": 325.0, + "volume": 10 + }, + { + "ask": 4.13, + "bid": 4.11, + "contract_symbol": "SPY281215P00330000", + "last": 4.1, + "open_interest": 125, + "right": "put", + "strike": 330.0, + "volume": 1 + }, + { + "ask": 4.28, + "bid": 4.25, + "contract_symbol": "SPY281215P00335000", + "last": 4.37, + "open_interest": 30, + "right": "put", + "strike": 335.0, + "volume": 1 + }, + { + "ask": 4.42, + "bid": 4.4, + "contract_symbol": "SPY281215P00340000", + "last": 5.08, + "open_interest": 63, + "right": "put", + "strike": 340.0, + "volume": 6 + }, + { + "ask": 4.57, + "bid": 4.55, + "contract_symbol": "SPY281215P00345000", + "last": 4.82, + "open_interest": 67, + "right": "put", + "strike": 345.0, + "volume": 2 + }, + { + "ask": 4.73, + "bid": 4.7, + "contract_symbol": "SPY281215P00350000", + "last": 4.99, + "open_interest": 345, + "right": "put", + "strike": 350.0, + "volume": 17 + }, + { + "ask": 4.89, + "bid": 4.86, + "contract_symbol": "SPY281215P00355000", + "last": 4.83, + "open_interest": 22, + "right": "put", + "strike": 355.0, + "volume": 1 + }, + { + "ask": 5.05, + "bid": 5.02, + "contract_symbol": "SPY281215P00360000", + "last": 5.59, + "open_interest": 39, + "right": "put", + "strike": 360.0, + "volume": 1 + }, + { + "ask": 5.22, + "bid": 5.19, + "contract_symbol": "SPY281215P00365000", + "last": 5.42, + "open_interest": 21, + "right": "put", + "strike": 365.0, + "volume": 1 + }, + { + "ask": 5.39, + "bid": 5.36, + "contract_symbol": "SPY281215P00370000", + "last": 5.4, + "open_interest": 104, + "right": "put", + "strike": 370.0, + "volume": 2 + }, + { + "ask": 5.56, + "bid": 5.54, + "contract_symbol": "SPY281215P00375000", + "last": 5.54, + "open_interest": 78, + "right": "put", + "strike": 375.0, + "volume": 4 + }, + { + "ask": 5.74, + "bid": 5.72, + "contract_symbol": "SPY281215P00380000", + "last": 5.82, + "open_interest": 211, + "right": "put", + "strike": 380.0, + "volume": 1 + }, + { + "ask": 5.93, + "bid": 5.9, + "contract_symbol": "SPY281215P00385000", + "last": 5.86, + "open_interest": 83, + "right": "put", + "strike": 385.0, + "volume": 21 + }, + { + "ask": 6.12, + "bid": 6.09, + "contract_symbol": "SPY281215P00390000", + "last": 6.17, + "open_interest": 72, + "right": "put", + "strike": 390.0, + "volume": 3 + }, + { + "ask": 6.32, + "bid": 6.29, + "contract_symbol": "SPY281215P00395000", + "last": 6.63, + "open_interest": 20, + "right": "put", + "strike": 395.0, + "volume": 2 + }, + { + "ask": 6.52, + "bid": 6.49, + "contract_symbol": "SPY281215P00400000", + "last": 6.51, + "open_interest": 1482, + "right": "put", + "strike": 400.0, + "volume": 6 + }, + { + "ask": 6.73, + "bid": 6.7, + "contract_symbol": "SPY281215P00405000", + "last": 6.94, + "open_interest": 330, + "right": "put", + "strike": 405.0, + "volume": 1 + }, + { + "ask": 6.95, + "bid": 6.92, + "contract_symbol": "SPY281215P00410000", + "last": 7.31, + "open_interest": 1249, + "right": "put", + "strike": 410.0, + "volume": 1 + }, + { + "ask": 7.17, + "bid": 7.14, + "contract_symbol": "SPY281215P00415000", + "last": 7.39, + "open_interest": 27, + "right": "put", + "strike": 415.0, + "volume": 1 + }, + { + "ask": 7.41, + "bid": 7.37, + "contract_symbol": "SPY281215P00420000", + "last": 7.49, + "open_interest": 399, + "right": "put", + "strike": 420.0, + "volume": 217 + }, + { + "ask": 7.64, + "bid": 7.61, + "contract_symbol": "SPY281215P00425000", + "last": 7.61, + "open_interest": 131, + "right": "put", + "strike": 425.0, + "volume": 31 + }, + { + "ask": 7.89, + "bid": 7.85, + "contract_symbol": "SPY281215P00430000", + "last": 8.0, + "open_interest": 312, + "right": "put", + "strike": 430.0, + "volume": 193 + }, + { + "ask": 8.14, + "bid": 8.11, + "contract_symbol": "SPY281215P00435000", + "last": 9.27, + "open_interest": 20, + "right": "put", + "strike": 435.0, + "volume": 1 + }, + { + "ask": 8.41, + "bid": 8.37, + "contract_symbol": "SPY281215P00440000", + "last": 8.49, + "open_interest": 62, + "right": "put", + "strike": 440.0, + "volume": 20 + }, + { + "ask": 8.68, + "bid": 8.64, + "contract_symbol": "SPY281215P00445000", + "last": 9.12, + "open_interest": 312, + "right": "put", + "strike": 445.0, + "volume": 1 + }, + { + "ask": 8.96, + "bid": 8.92, + "contract_symbol": "SPY281215P00450000", + "last": 8.96, + "open_interest": 473, + "right": "put", + "strike": 450.0, + "volume": 2 + }, + { + "ask": 9.25, + "bid": 9.21, + "contract_symbol": "SPY281215P00455000", + "last": 10.03, + "open_interest": 148, + "right": "put", + "strike": 455.0, + "volume": 7 + }, + { + "ask": 9.55, + "bid": 9.51, + "contract_symbol": "SPY281215P00460000", + "last": 10.21, + "open_interest": 242, + "right": "put", + "strike": 460.0, + "volume": 2 + }, + { + "ask": 9.86, + "bid": 9.82, + "contract_symbol": "SPY281215P00465000", + "last": 10.52, + "open_interest": 52, + "right": "put", + "strike": 465.0, + "volume": 40 + }, + { + "ask": 10.18, + "bid": 10.14, + "contract_symbol": "SPY281215P00470000", + "last": 10.21, + "open_interest": 155, + "right": "put", + "strike": 470.0, + "volume": 2 + }, + { + "ask": 10.51, + "bid": 10.47, + "contract_symbol": "SPY281215P00475000", + "last": 10.63, + "open_interest": 110, + "right": "put", + "strike": 475.0, + "volume": 5 + }, + { + "ask": 10.85, + "bid": 10.81, + "contract_symbol": "SPY281215P00480000", + "last": 10.92, + "open_interest": 144, + "right": "put", + "strike": 480.0, + "volume": 7 + }, + { + "ask": 11.2, + "bid": 11.16, + "contract_symbol": "SPY281215P00485000", + "last": 11.61, + "open_interest": 276, + "right": "put", + "strike": 485.0, + "volume": 3 + }, + { + "ask": 11.57, + "bid": 11.53, + "contract_symbol": "SPY281215P00490000", + "last": 11.77, + "open_interest": 72, + "right": "put", + "strike": 490.0, + "volume": 5 + }, + { + "ask": 11.94, + "bid": 11.9, + "contract_symbol": "SPY281215P00495000", + "last": 11.87, + "open_interest": 19, + "right": "put", + "strike": 495.0, + "volume": 3 + }, + { + "ask": 12.33, + "bid": 12.29, + "contract_symbol": "SPY281215P00500000", + "last": 12.29, + "open_interest": 1165, + "right": "put", + "strike": 500.0, + "volume": 33 + }, + { + "ask": 12.73, + "bid": 12.68, + "contract_symbol": "SPY281215P00505000", + "last": 12.74, + "open_interest": 238, + "right": "put", + "strike": 505.0, + "volume": 3 + }, + { + "ask": 13.14, + "bid": 13.09, + "contract_symbol": "SPY281215P00510000", + "last": 12.99, + "open_interest": 29, + "right": "put", + "strike": 510.0, + "volume": 2 + }, + { + "ask": 13.6, + "bid": 13.47, + "contract_symbol": "SPY281215P00515000", + "last": 15.37, + "open_interest": 79, + "right": "put", + "strike": 515.0, + "volume": 1 + }, + { + "ask": 14.04, + "bid": 13.91, + "contract_symbol": "SPY281215P00520000", + "last": 14.75, + "open_interest": 240, + "right": "put", + "strike": 520.0, + "volume": 1 + }, + { + "ask": 14.49, + "bid": 14.36, + "contract_symbol": "SPY281215P00525000", + "last": 14.7, + "open_interest": 726, + "right": "put", + "strike": 525.0, + "volume": 1 + }, + { + "ask": 14.95, + "bid": 14.82, + "contract_symbol": "SPY281215P00530000", + "last": 14.84, + "open_interest": 342, + "right": "put", + "strike": 530.0, + "volume": 8 + }, + { + "ask": 15.42, + "bid": 15.29, + "contract_symbol": "SPY281215P00535000", + "last": 15.39, + "open_interest": 5180, + "right": "put", + "strike": 535.0, + "volume": 8 + }, + { + "ask": 15.91, + "bid": 15.78, + "contract_symbol": "SPY281215P00540000", + "last": 15.95, + "open_interest": 344, + "right": "put", + "strike": 540.0, + "volume": 1 + }, + { + "ask": 16.42, + "bid": 16.28, + "contract_symbol": "SPY281215P00545000", + "last": 16.41, + "open_interest": 270, + "right": "put", + "strike": 545.0, + "volume": 1 + }, + { + "ask": 16.94, + "bid": 16.8, + "contract_symbol": "SPY281215P00550000", + "last": 17.09, + "open_interest": 3454, + "right": "put", + "strike": 550.0, + "volume": 2 + }, + { + "ask": 17.48, + "bid": 17.33, + "contract_symbol": "SPY281215P00555000", + "last": 18.42, + "open_interest": 173, + "right": "put", + "strike": 555.0, + "volume": 1 + }, + { + "ask": 18.03, + "bid": 17.88, + "contract_symbol": "SPY281215P00560000", + "last": 18.01, + "open_interest": 238, + "right": "put", + "strike": 560.0, + "volume": 1 + }, + { + "ask": 18.6, + "bid": 18.45, + "contract_symbol": "SPY281215P00565000", + "last": 19.61, + "open_interest": 254, + "right": "put", + "strike": 565.0, + "volume": 8 + }, + { + "ask": 19.18, + "bid": 19.03, + "contract_symbol": "SPY281215P00570000", + "last": 19.12, + "open_interest": 209, + "right": "put", + "strike": 570.0, + "volume": 21 + }, + { + "ask": 19.78, + "bid": 19.63, + "contract_symbol": "SPY281215P00575000", + "last": 19.68, + "open_interest": 461, + "right": "put", + "strike": 575.0, + "volume": 5 + }, + { + "ask": 20.41, + "bid": 20.24, + "contract_symbol": "SPY281215P00580000", + "last": 20.2, + "open_interest": 1007, + "right": "put", + "strike": 580.0, + "volume": 31 + }, + { + "ask": 21.04, + "bid": 20.87, + "contract_symbol": "SPY281215P00585000", + "last": 21.43, + "open_interest": 395, + "right": "put", + "strike": 585.0, + "volume": 4 + }, + { + "ask": 21.7, + "bid": 21.53, + "contract_symbol": "SPY281215P00590000", + "last": 21.74, + "open_interest": 1209, + "right": "put", + "strike": 590.0, + "volume": 54 + }, + { + "ask": 22.37, + "bid": 22.19, + "contract_symbol": "SPY281215P00595000", + "last": 22.74, + "open_interest": 1007, + "right": "put", + "strike": 595.0, + "volume": 3 + }, + { + "ask": 23.06, + "bid": 22.9, + "contract_symbol": "SPY281215P00600000", + "last": 22.94, + "open_interest": 2652, + "right": "put", + "strike": 600.0, + "volume": 3 + }, + { + "ask": 23.77, + "bid": 23.59, + "contract_symbol": "SPY281215P00605000", + "last": 23.7, + "open_interest": 1275, + "right": "put", + "strike": 605.0, + "volume": 2 + }, + { + "ask": 24.51, + "bid": 24.32, + "contract_symbol": "SPY281215P00610000", + "last": 24.36, + "open_interest": 1025, + "right": "put", + "strike": 610.0, + "volume": 37 + }, + { + "ask": 25.26, + "bid": 25.07, + "contract_symbol": "SPY281215P00615000", + "last": 25.64, + "open_interest": 129, + "right": "put", + "strike": 615.0, + "volume": 2 + }, + { + "ask": 26.04, + "bid": 25.83, + "contract_symbol": "SPY281215P00620000", + "last": 26.36, + "open_interest": 906, + "right": "put", + "strike": 620.0, + "volume": 7 + }, + { + "ask": 26.83, + "bid": 26.63, + "contract_symbol": "SPY281215P00625000", + "last": 28.35, + "open_interest": 766, + "right": "put", + "strike": 625.0, + "volume": 1 + }, + { + "ask": 27.65, + "bid": 27.44, + "contract_symbol": "SPY281215P00630000", + "last": 27.43, + "open_interest": 978, + "right": "put", + "strike": 630.0, + "volume": 1 + }, + { + "ask": 28.49, + "bid": 28.27, + "contract_symbol": "SPY281215P00635000", + "last": 28.39, + "open_interest": 1921, + "right": "put", + "strike": 635.0, + "volume": 1 + }, + { + "ask": 29.35, + "bid": 29.13, + "contract_symbol": "SPY281215P00640000", + "last": 29.18, + "open_interest": 631, + "right": "put", + "strike": 640.0, + "volume": 7 + }, + { + "ask": 30.24, + "bid": 30.01, + "contract_symbol": "SPY281215P00645000", + "last": 30.15, + "open_interest": 259, + "right": "put", + "strike": 645.0, + "volume": 1 + }, + { + "ask": 31.15, + "bid": 30.92, + "contract_symbol": "SPY281215P00650000", + "last": 30.99, + "open_interest": 1916, + "right": "put", + "strike": 650.0, + "volume": 5 + }, + { + "ask": 32.08, + "bid": 31.85, + "contract_symbol": "SPY281215P00655000", + "last": 32.4, + "open_interest": 383, + "right": "put", + "strike": 655.0, + "volume": 2 + }, + { + "ask": 33.04, + "bid": 32.8, + "contract_symbol": "SPY281215P00660000", + "last": 32.9, + "open_interest": 1988, + "right": "put", + "strike": 660.0, + "volume": 6 + }, + { + "ask": 34.02, + "bid": 33.78, + "contract_symbol": "SPY281215P00665000", + "last": 33.81, + "open_interest": 1549, + "right": "put", + "strike": 665.0, + "volume": 2 + }, + { + "ask": 35.03, + "bid": 34.79, + "contract_symbol": "SPY281215P00670000", + "last": 35.52, + "open_interest": 1349, + "right": "put", + "strike": 670.0, + "volume": 1 + }, + { + "ask": 36.07, + "bid": 35.82, + "contract_symbol": "SPY281215P00675000", + "last": 38.03, + "open_interest": 1167, + "right": "put", + "strike": 675.0, + "volume": 11 + }, + { + "ask": 37.14, + "bid": 36.89, + "contract_symbol": "SPY281215P00680000", + "last": 37.1, + "open_interest": 1099, + "right": "put", + "strike": 680.0, + "volume": 11 + }, + { + "ask": 38.24, + "bid": 37.98, + "contract_symbol": "SPY281215P00685000", + "last": 38.14, + "open_interest": 1539, + "right": "put", + "strike": 685.0, + "volume": 21 + }, + { + "ask": 39.38, + "bid": 39.1, + "contract_symbol": "SPY281215P00690000", + "last": 39.22, + "open_interest": 1780, + "right": "put", + "strike": 690.0, + "volume": 20 + }, + { + "ask": 40.54, + "bid": 40.25, + "contract_symbol": "SPY281215P00695000", + "last": 40.4, + "open_interest": 1357, + "right": "put", + "strike": 695.0, + "volume": 1 + }, + { + "ask": 41.73, + "bid": 41.43, + "contract_symbol": "SPY281215P00700000", + "last": 41.5, + "open_interest": 3343, + "right": "put", + "strike": 700.0, + "volume": 13 + }, + { + "ask": 42.95, + "bid": 42.65, + "contract_symbol": "SPY281215P00705000", + "last": 43.4, + "open_interest": 746, + "right": "put", + "strike": 705.0, + "volume": 3 + }, + { + "ask": 44.2, + "bid": 43.89, + "contract_symbol": "SPY281215P00710000", + "last": 44.1, + "open_interest": 1322, + "right": "put", + "strike": 710.0, + "volume": 109 + }, + { + "ask": 45.57, + "bid": 45.1, + "contract_symbol": "SPY281215P00715000", + "last": 45.17, + "open_interest": 483, + "right": "put", + "strike": 715.0, + "volume": 1 + }, + { + "ask": 46.89, + "bid": 46.42, + "contract_symbol": "SPY281215P00720000", + "last": 46.7, + "open_interest": 1180, + "right": "put", + "strike": 720.0, + "volume": 4 + }, + { + "ask": 48.24, + "bid": 47.81, + "contract_symbol": "SPY281215P00725000", + "last": 48.0, + "open_interest": 496, + "right": "put", + "strike": 725.0, + "volume": 36 + }, + { + "ask": 49.66, + "bid": 49.16, + "contract_symbol": "SPY281215P00730000", + "last": 52.13, + "open_interest": 400, + "right": "put", + "strike": 730.0, + "volume": 4 + }, + { + "ask": 51.1, + "bid": 50.63, + "contract_symbol": "SPY281215P00735000", + "last": 51.54, + "open_interest": 1552, + "right": "put", + "strike": 735.0, + "volume": 3 + }, + { + "ask": 52.58, + "bid": 52.05, + "contract_symbol": "SPY281215P00740000", + "last": 52.28, + "open_interest": 4305, + "right": "put", + "strike": 740.0, + "volume": 83 + }, + { + "ask": 54.11, + "bid": 53.62, + "contract_symbol": "SPY281215P00745000", + "last": 54.18, + "open_interest": 589, + "right": "put", + "strike": 745.0, + "volume": 1 + }, + { + "ask": 55.68, + "bid": 55.12, + "contract_symbol": "SPY281215P00750000", + "last": 55.3, + "open_interest": 1394, + "right": "put", + "strike": 750.0, + "volume": 68 + }, + { + "ask": 57.3, + "bid": 56.77, + "contract_symbol": "SPY281215P00755000", + "last": 57.15, + "open_interest": 521, + "right": "put", + "strike": 755.0, + "volume": 874 + }, + { + "ask": 58.95, + "bid": 58.37, + "contract_symbol": "SPY281215P00760000", + "last": 58.58, + "open_interest": 712, + "right": "put", + "strike": 760.0, + "volume": 552 + }, + { + "ask": 60.67, + "bid": 60.11, + "contract_symbol": "SPY281215P00765000", + "last": 60.18, + "open_interest": 700, + "right": "put", + "strike": 765.0, + "volume": 12 + }, + { + "ask": 62.44, + "bid": 61.82, + "contract_symbol": "SPY281215P00770000", + "last": 61.93, + "open_interest": 1036, + "right": "put", + "strike": 770.0, + "volume": 6 + }, + { + "ask": 64.25, + "bid": 63.62, + "contract_symbol": "SPY281215P00775000", + "last": 66.02, + "open_interest": 1023, + "right": "put", + "strike": 775.0, + "volume": 1 + }, + { + "ask": 66.13, + "bid": 65.49, + "contract_symbol": "SPY281215P00780000", + "last": 68.88, + "open_interest": 687, + "right": "put", + "strike": 780.0, + "volume": 2 + }, + { + "ask": 68.07, + "bid": 67.41, + "contract_symbol": "SPY281215P00785000", + "last": 71.58, + "open_interest": 238, + "right": "put", + "strike": 785.0, + "volume": 1 + }, + { + "ask": 70.07, + "bid": 69.4, + "contract_symbol": "SPY281215P00790000", + "last": 70.26, + "open_interest": 110, + "right": "put", + "strike": 790.0, + "volume": 3 + }, + { + "ask": 72.14, + "bid": 71.45, + "contract_symbol": "SPY281215P00795000", + "last": 72.25, + "open_interest": 235, + "right": "put", + "strike": 795.0, + "volume": 3 + }, + { + "ask": 74.27, + "bid": 73.57, + "contract_symbol": "SPY281215P00800000", + "last": 73.93, + "open_interest": 300, + "right": "put", + "strike": 800.0, + "volume": 57 + }, + { + "ask": 76.48, + "bid": 75.76, + "contract_symbol": "SPY281215P00805000", + "last": 76.16, + "open_interest": 145, + "right": "put", + "strike": 805.0, + "volume": 88 + }, + { + "ask": 78.76, + "bid": 78.03, + "contract_symbol": "SPY281215P00810000", + "last": 79.04, + "open_interest": 1586, + "right": "put", + "strike": 810.0, + "volume": 15 + }, + { + "ask": 81.11, + "bid": 80.38, + "contract_symbol": "SPY281215P00815000", + "last": 81.79, + "open_interest": 467, + "right": "put", + "strike": 815.0, + "volume": 28 + }, + { + "ask": 87.59, + "bid": 86.76, + "contract_symbol": "SPY281215P00820000", + "last": 84.21, + "open_interest": 304, + "right": "put", + "strike": 820.0, + "volume": 62 + }, + { + "ask": 86.06, + "bid": 85.31, + "contract_symbol": "SPY281215P00825000", + "last": 85.75, + "open_interest": 73, + "right": "put", + "strike": 825.0, + "volume": 8 + }, + { + "ask": 88.67, + "bid": 87.91, + "contract_symbol": "SPY281215P00830000", + "last": 88.49, + "open_interest": 174, + "right": "put", + "strike": 830.0, + "volume": 874 + }, + { + "ask": 91.36, + "bid": 90.6, + "contract_symbol": "SPY281215P00835000", + "last": 90.58, + "open_interest": 427, + "right": "put", + "strike": 835.0, + "volume": 2 + }, + { + "ask": 94.15, + "bid": 93.39, + "contract_symbol": "SPY281215P00840000", + "last": 93.29, + "open_interest": 115, + "right": "put", + "strike": 840.0, + "volume": 552 + }, + { + "ask": 97.03, + "bid": 96.27, + "contract_symbol": "SPY281215P00845000", + "last": 96.15, + "open_interest": 174, + "right": "put", + "strike": 845.0, + "volume": 2 + }, + { + "ask": 100.01, + "bid": 99.26, + "contract_symbol": "SPY281215P00850000", + "last": 98.94, + "open_interest": 1308, + "right": "put", + "strike": 850.0, + "volume": 2 + }, + { + "ask": 103.12, + "bid": 102.37, + "contract_symbol": "SPY281215P00855000", + "last": 106.32, + "open_interest": 30, + "right": "put", + "strike": 855.0, + "volume": 2 + }, + { + "ask": 106.33, + "bid": 105.59, + "contract_symbol": "SPY281215P00860000", + "last": 110.58, + "open_interest": 704, + "right": "put", + "strike": 860.0, + "volume": 1 + }, + { + "ask": 109.67, + "bid": 108.93, + "contract_symbol": "SPY281215P00865000", + "last": 108.58, + "open_interest": 10, + "right": "put", + "strike": 865.0, + "volume": 2 + }, + { + "ask": 113.14, + "bid": 112.44, + "contract_symbol": "SPY281215P00870000", + "last": 111.59, + "open_interest": 50, + "right": "put", + "strike": 870.0, + "volume": 1 + }, + { + "ask": 116.75, + "bid": 116.09, + "contract_symbol": "SPY281215P00875000", + "last": 119.57, + "open_interest": 260, + "right": "put", + "strike": 875.0, + "volume": 2 + }, + { + "ask": 120.53, + "bid": 119.9, + "contract_symbol": "SPY281215P00880000", + "last": 119.8, + "open_interest": 206, + "right": "put", + "strike": 880.0, + "volume": 1 + }, + { + "ask": 124.46, + "bid": 123.86, + "contract_symbol": "SPY281215P00885000", + "last": 119.63, + "open_interest": 35, + "right": "put", + "strike": 885.0, + "volume": 15 + }, + { + "ask": 130.83, + "bid": 126.76, + "contract_symbol": "SPY281215P00890000", + "last": 134.39, + "open_interest": 42, + "right": "put", + "strike": 890.0, + "volume": 3 + }, + { + "ask": 135.0, + "bid": 130.46, + "contract_symbol": "SPY281215P00895000", + "last": 132.5, + "open_interest": 11, + "right": "put", + "strike": 895.0, + "volume": 1 + }, + { + "ask": 139.5, + "bid": 134.75, + "contract_symbol": "SPY281215P00900000", + "last": 138.65, + "open_interest": 79, + "right": "put", + "strike": 900.0, + "volume": 8 + }, + { + "ask": 144.5, + "bid": 139.57, + "contract_symbol": "SPY281215P00905000", + "last": 144.0, + "open_interest": 3, + "right": "put", + "strike": 905.0, + "volume": 1 + }, + { + "ask": 149.0, + "bid": 144.5, + "contract_symbol": "SPY281215P00910000", + "last": 146.0, + "open_interest": 13, + "right": "put", + "strike": 910.0, + "volume": 1 + }, + { + "ask": 154.0, + "bid": 149.51, + "contract_symbol": "SPY281215P00915000", + "last": 152.92, + "open_interest": 35, + "right": "put", + "strike": 915.0, + "volume": 13 + }, + { + "ask": 159.0, + "bid": 154.55, + "contract_symbol": "SPY281215P00920000", + "last": 162.88, + "open_interest": 40, + "right": "put", + "strike": 920.0, + "volume": 1 + }, + { + "ask": 163.95, + "bid": 159.5, + "contract_symbol": "SPY281215P00925000", + "last": 159.24, + "open_interest": 5, + "right": "put", + "strike": 925.0, + "volume": 5 + }, + { + "ask": 168.95, + "bid": 164.5, + "contract_symbol": "SPY281215P00930000", + "last": 164.43, + "open_interest": 15, + "right": "put", + "strike": 930.0, + "volume": 2 + }, + { + "ask": 174.0, + "bid": 169.5, + "contract_symbol": "SPY281215P00935000", + "last": 170.7, + "open_interest": 0, + "right": "put", + "strike": 935.0, + "volume": 10 + }, + { + "ask": 179.0, + "bid": 174.5, + "contract_symbol": "SPY281215P00940000", + "last": 171.0, + "open_interest": 3, + "right": "put", + "strike": 940.0, + "volume": 1 + }, + { + "ask": 193.0, + "bid": 188.04, + "contract_symbol": "SPY281215P00945000", + "last": 208.0, + "open_interest": 0, + "right": "put", + "strike": 945.0, + "volume": 1 + }, + { + "ask": 189.0, + "bid": 184.5, + "contract_symbol": "SPY281215P00950000", + "last": 179.11, + "open_interest": 3, + "right": "put", + "strike": 950.0, + "volume": 2 + }, + { + "ask": 203.0, + "bid": 198.04, + "contract_symbol": "SPY281215P00955000", + "last": 229.35, + "open_interest": 0, + "right": "put", + "strike": 955.0, + "volume": null + }, + { + "ask": 208.0, + "bid": 203.04, + "contract_symbol": "SPY281215P00960000", + "last": 232.96, + "open_interest": 0, + "right": "put", + "strike": 960.0, + "volume": null + }, + { + "ask": 213.0, + "bid": 208.04, + "contract_symbol": "SPY281215P00965000", + "last": 238.22, + "open_interest": 0, + "right": "put", + "strike": 965.0, + "volume": null + }, + { + "ask": 209.0, + "bid": 204.5, + "contract_symbol": "SPY281215P00970000", + "last": 193.6, + "open_interest": 0, + "right": "put", + "strike": 970.0, + "volume": 1 + }, + { + "ask": 214.0, + "bid": 209.5, + "contract_symbol": "SPY281215P00975000", + "last": 199.0, + "open_interest": 0, + "right": "put", + "strike": 975.0, + "volume": 1 + }, + { + "ask": 219.0, + "bid": 214.5, + "contract_symbol": "SPY281215P00980000", + "last": 209.54, + "open_interest": 1, + "right": "put", + "strike": 980.0, + "volume": 2 + }, + { + "ask": 233.0, + "bid": 228.04, + "contract_symbol": "SPY281215P00985000", + "last": 254.37, + "open_interest": 0, + "right": "put", + "strike": 985.0, + "volume": null + }, + { + "ask": 238.0, + "bid": 233.04, + "contract_symbol": "SPY281215P00990000", + "last": 262.73, + "open_interest": 0, + "right": "put", + "strike": 990.0, + "volume": null + }, + { + "ask": 239.0, + "bid": 234.5, + "contract_symbol": "SPY281215P01000000", + "last": 239.23, + "open_interest": 0, + "right": "put", + "strike": 1000.0, + "volume": 4 + }, + { + "ask": 315.0, + "bid": 310.18, + "contract_symbol": "SPY281215P01005000", + "last": 311.54, + "open_interest": 0, + "right": "put", + "strike": 1005.0, + "volume": null + }, + { + "ask": 249.0, + "bid": 244.5, + "contract_symbol": "SPY281215P01010000", + "last": 232.87, + "open_interest": 0, + "right": "put", + "strike": 1010.0, + "volume": 2 + }, + { + "ask": 283.77, + "bid": 279.0, + "contract_symbol": "SPY281215P01045000", + "last": 305.5, + "open_interest": 0, + "right": "put", + "strike": 1045.0, + "volume": 1 + }, + { + "ask": 333.0, + "bid": 328.24, + "contract_symbol": "SPY281215P01100000", + "last": 343.28, + "open_interest": 0, + "right": "put", + "strike": 1100.0, + "volume": 1 + }, + { + "ask": 430.0, + "bid": 425.2, + "contract_symbol": "SPY281215P01180000", + "last": 440.71, + "open_interest": 0, + "right": "put", + "strike": 1180.0, + "volume": null + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215P01200000", + "last": 516.56, + "open_interest": 0, + "right": "put", + "strike": 1200.0, + "volume": null + }, + { + "ask": 508.77, + "bid": 504.0, + "contract_symbol": "SPY281215P01270000", + "last": 521.55, + "open_interest": 0, + "right": "put", + "strike": 1270.0, + "volume": null + }, + { + "ask": 518.5, + "bid": 514.04, + "contract_symbol": "SPY281215P01280000", + "last": 512.1, + "open_interest": 0, + "right": "put", + "strike": 1280.0, + "volume": 1 + }, + { + "ask": 528.5, + "bid": 524.03, + "contract_symbol": "SPY281215P01290000", + "last": 522.12, + "open_interest": 0, + "right": "put", + "strike": 1290.0, + "volume": null + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215P01300000", + "last": 559.26, + "open_interest": 0, + "right": "put", + "strike": 1300.0, + "volume": 1 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215P01330000", + "last": 674.64, + "open_interest": 0, + "right": "put", + "strike": 1330.0, + "volume": 25 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215P01340000", + "last": 705.45, + "open_interest": 0, + "right": "put", + "strike": 1340.0, + "volume": 2 + }, + { + "ask": 0.0, + "bid": 0.0, + "contract_symbol": "SPY281215P01350000", + "last": 609.24, + "open_interest": 0, + "right": "put", + "strike": 1350.0, + "volume": 1 + }, + { + "ask": 718.5, + "bid": 714.0, + "contract_symbol": "SPY281215P01480000", + "last": 709.06, + "open_interest": 0, + "right": "put", + "strike": 1480.0, + "volume": 4 + } + ] + }, + "currency": "USD", + "previous_close": 757.5286, + "spot": 764.2899780273438 + } + } +} From 10eb50e6ff8d0e12370a40e05ad3b4ae24457be0 Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:38:06 -0400 Subject: [PATCH 02/11] M2: persistence, idempotent daily snapshots, and IV rank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IV-history problem is the reason this project is interesting: free providers don't sell historical implied volatility, so the tool accumulates its own from day one forward. - models: tickers / snapshots / contracts. Idempotency is a database guarantee — unique constraints on (ticker_id, snapshot_date) and (snapshot_id, expiry, strike, right) together make (ticker, date, expiry, strike, right) unique by construction, so a buggy writer still cannot duplicate a day. Each snapshot stores the risk-free rate and dividend yield in force at capture, without which a stored IV can't be interpreted later. - atm_iv: reduces a day's chains to one number — 30-day constant-maturity ATM IV, interpolated to the money within each expiry and then across expiries in total variance, since variance is what's additive in time. Returns None when 30 days can't be bracketed rather than extrapolating; an invented early value would become the window's minimum for a year. - snapshot: ON CONFLICT DO UPDATE upserts, chunked under SQLite's 999-parameter cap. One failing expiry or ticker doesn't cost the rest of the run. - iv_rank: the formula is trivial; refusing to compute it is the point. Below min_history_days the result carries rank=None plus "insufficient history (3/20 days)". A flat window returns undefined rather than a conventional 50. Percentile is reported alongside rank because one outlier sets the range rank is measured against but moves percentile by a single observation. - session: SQLite needs PRAGMA foreign_keys=ON for the schema's CASCADE to do anything, and WAL so the snapshot job doesn't block API reads. - alembic with render_as_batch=True, since SQLite can't ALTER a column in place. Initial migration applies and reverses cleanly. Verified end to end on the fixture: two runs the same day held stored contract rows at 1122 -> 1122, reported as "refreshed" not "captured", with zero duplicate (ticker, date, expiry, strike, right) groups in SQL. ivrank on a one-day database prints "insufficient history (1/20 days)". Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 78 ++++- api/alembic.ini | 151 +++++++++ api/alembic/env.py | 60 ++++ api/alembic/script.py.mako | 28 ++ ...6a74a_initial_schema_tickers_snapshots_.py | 106 +++++++ api/options_tool/analytics/atm_iv.py | 124 ++++++++ api/options_tool/analytics/iv_rank.py | 154 +++++++++ api/options_tool/cli.py | 147 +++++++++ api/options_tool/db/models.py | 176 ++++++++++ api/options_tool/db/queries.py | 135 ++++++++ api/options_tool/db/session.py | 87 +++++ api/options_tool/db/snapshot.py | 300 ++++++++++++++++++ 12 files changed, 1544 insertions(+), 2 deletions(-) create mode 100644 api/alembic.ini create mode 100644 api/alembic/env.py create mode 100644 api/alembic/script.py.mako create mode 100644 api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py create mode 100644 api/options_tool/analytics/atm_iv.py create mode 100644 api/options_tool/analytics/iv_rank.py create mode 100644 api/options_tool/db/models.py create mode 100644 api/options_tool/db/queries.py create mode 100644 api/options_tool/db/session.py create mode 100644 api/options_tool/db/snapshot.py diff --git a/README.md b/README.md index 3ec3857..1b1440b 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ rather than rendering a misleading zero. | Milestone | State | |---|---| | M1 — core analytics (pricing, greeks, IV solver, chain normalisation, CLI) | done | -| M2 — persistence + daily snapshots + IV rank | in progress | -| M3 — FastAPI | planned | +| M2 — persistence + daily snapshots + IV rank | done | +| M3 — FastAPI | in progress | | M4 — tests + CI | planned | | M5 — Next.js front end | planned | | M6 — Docker + Linux deploy | planned | @@ -78,6 +78,37 @@ cp ../.env.example ../.env # optional; every setting has a working default .venv/bin/python -m options_tool quote SPY ``` +### Build the IV history + +```bash +.venv/bin/python -m options_tool watchlist --add SPY AAPL +.venv/bin/python -m options_tool snapshot # run this daily +.venv/bin/python -m options_tool ivrank +``` + +`snapshot` is idempotent — running it twice in one day refreshes that day's rows +rather than appending a second copy: + +``` +$ options-tool snapshot +AAPL 2026-09-13: captured 368 contracts across 3 expiries (94% solved), 30d ATM IV 26.7% +SPY 2026-09-13: captured 754 contracts across 3 expiries (85% solved), 30d ATM IV 14.6% +stored contract rows: 0 -> 1122 + +$ options-tool snapshot +AAPL 2026-09-13: refreshed 368 contracts across 3 expiries (94% solved), 30d ATM IV 26.7% +SPY 2026-09-13: refreshed 754 contracts across 3 expiries (85% solved), 30d ATM IV 14.6% +stored contract rows: 1122 -> 1122 +``` + +On a fresh database IV rank says so rather than inventing a number: + +``` +$ options-tool ivrank +AAPL IV rank unavailable: insufficient history (1/20 days) (ATM IV 26.7%) +SPY IV rank unavailable: insufficient history (1/20 days) (ATM IV 14.6%) +``` + ``` SPY 2026-09-14 spot 764.29 T 0.0050y r 4.00% ┏━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓ @@ -93,6 +124,49 @@ cp ../.env.example ../.env # optional; every setting has a working default --- +## How the IV history works + +This is the part of the project I find most interesting, because the data simply +does not exist to be downloaded. + +IV rank needs a history of implied volatility. Free providers give you today's +chain and nothing else, and the historical-IV products that do exist are paid. +The two dishonest ways out are to substitute realised volatility (a different +quantity that answers a different question) or to render a rank from three days +of data and hope nobody asks. This tool does neither: it runs a daily job that +captures the full chain, computes every contract's IV, and writes the lot to +SQLite. The history is one the tool builds for itself, starting empty. + +**Reducing a day to one number.** IV rank needs a single scalar per day, and +"the IV of some strike" will not do — as spot drifts, a fixed strike slides along +the skew, and as days pass the front expiry rolls, so the series would move for +reasons unrelated to volatility. Instead each day is reduced to a **30-day +constant-maturity at-the-money IV**: interpolate to the at-the-money strike +within each expiry, then interpolate across expiries *in total variance* to a +30-day horizon. Variance rather than volatility because variance is what is +additive in time. If 30 days cannot be bracketed by the listed expiries, the day +stores `null` and is excluded — extrapolating would invent a number, and on an +early day an invented number becomes the historical minimum for a year. + +**Idempotency is a database guarantee, not a convention.** Unique constraints on +`(ticker_id, snapshot_date)` and `(snapshot_id, expiry, strike, right)` make the +tuple `(ticker, date, expiry, strike, right)` unique by construction, and the +writer uses `INSERT ... ON CONFLICT DO UPDATE`. A cron timer that fires twice, a +retried half-failure, or a manual re-run all refresh the day instead of +double-weighting it in the rank window. + +**Storing the assumptions with the data.** Each snapshot records the risk-free +rate and dividend yield in force when it was captured. Without them a stored IV +is uninterpretable later — you could not distinguish a real volatility move from +a change to the tool's own configuration. + +**Rank and percentile, not just rank.** IV rank places today between the window's +extremes; IV percentile reports the share of days below today. Percentile is the +more robust of the two — a single outlier day sets the range that rank is +measured against, but moves percentile by one observation. Both are reported. + +--- + ## How the analytics work **Pricing** is Black-Scholes-Merton with a continuous dividend yield. Delta, diff --git a/api/alembic.ini b/api/alembic.ini new file mode 100644 index 0000000..5aa3d1a --- /dev/null +++ b/api/alembic.ini @@ -0,0 +1,151 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# The URL is set in alembic/env.py from options_tool.config, so that +# OPTIONS_DATABASE_URL is the single source of truth. Left blank on purpose. +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/api/alembic/env.py b/api/alembic/env.py new file mode 100644 index 0000000..0693e51 --- /dev/null +++ b/api/alembic/env.py @@ -0,0 +1,60 @@ +"""Alembic environment. + +Two project-specific things happen here: + +- the database URL comes from `options_tool.config`, not from `alembic.ini`, so + there is exactly one place a connection string is configured +- `render_as_batch=True`, because SQLite cannot ALTER a column in place. Without + it, any future migration that alters or drops a column fails on the only + database this project actually uses. +""" + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from options_tool.config import get_settings +from options_tool.db.models import Base + +config = context.config +config.set_main_option("sqlalchemy.url", get_settings().options_database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/api/alembic/script.py.mako b/api/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/api/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py b/api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py new file mode 100644 index 0000000..9c755ac --- /dev/null +++ b/api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py @@ -0,0 +1,106 @@ +"""initial schema: tickers, snapshots, contracts + +Revision ID: da5c89f6a74a +Revises: +Create Date: 2026-09-12 20:37:18.906707 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'da5c89f6a74a' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tickers', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('symbol', sa.String(length=16), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('note', sa.String(length=256), nullable=True), + sa.Column('added_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('tickers', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_tickers_symbol'), ['symbol'], unique=True) + + op.create_table('snapshots', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('ticker_id', sa.Integer(), nullable=False), + sa.Column('snapshot_date', sa.Date(), nullable=False), + sa.Column('as_of', sa.DateTime(timezone=True), nullable=False), + sa.Column('provider', sa.String(length=32), nullable=False), + sa.Column('spot', sa.Float(), nullable=False), + sa.Column('risk_free_rate', sa.Float(), nullable=False), + sa.Column('dividend_yield', sa.Float(), nullable=False), + sa.Column('contract_count', sa.Integer(), nullable=False), + sa.Column('solved_count', sa.Integer(), nullable=False), + sa.Column('atm_iv_30d', sa.Float(), nullable=True), + sa.Column('atm_iv_front', sa.Float(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['ticker_id'], ['tickers.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('ticker_id', 'snapshot_date', name='uq_snapshot_ticker_date') + ) + with op.batch_alter_table('snapshots', schema=None) as batch_op: + batch_op.create_index('ix_snapshot_ticker_date', ['ticker_id', 'snapshot_date'], unique=False) + + op.create_table('contracts', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('snapshot_id', sa.Integer(), nullable=False), + sa.Column('expiry', sa.Date(), nullable=False), + sa.Column('strike', sa.Float(), nullable=False), + sa.Column('right', sa.String(length=4), nullable=False), + sa.Column('contract_symbol', sa.String(length=64), nullable=True), + sa.Column('bid', sa.Float(), nullable=True), + sa.Column('ask', sa.Float(), nullable=True), + sa.Column('last', sa.Float(), nullable=True), + sa.Column('mid', sa.Float(), nullable=True), + sa.Column('volume', sa.Integer(), nullable=True), + sa.Column('open_interest', sa.Integer(), nullable=True), + sa.Column('price', sa.Float(), nullable=True), + sa.Column('price_source', sa.String(length=8), nullable=True), + sa.Column('time_to_expiry', sa.Float(), nullable=False), + sa.Column('moneyness', sa.Float(), nullable=False), + sa.Column('iv', sa.Float(), nullable=True), + sa.Column('iv_status', sa.String(length=24), nullable=False), + sa.Column('delta', sa.Float(), nullable=True), + sa.Column('gamma', sa.Float(), nullable=True), + sa.Column('vega', sa.Float(), nullable=True), + sa.Column('theta', sa.Float(), nullable=True), + sa.Column('rho', sa.Float(), nullable=True), + sa.ForeignKeyConstraint(['snapshot_id'], ['snapshots.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('snapshot_id', 'expiry', 'strike', 'right', name='uq_contract_snapshot_key') + ) + with op.batch_alter_table('contracts', schema=None) as batch_op: + batch_op.create_index('ix_contract_expiry', ['snapshot_id', 'expiry'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('contracts', schema=None) as batch_op: + batch_op.drop_index('ix_contract_expiry') + + op.drop_table('contracts') + with op.batch_alter_table('snapshots', schema=None) as batch_op: + batch_op.drop_index('ix_snapshot_ticker_date') + + op.drop_table('snapshots') + with op.batch_alter_table('tickers', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_tickers_symbol')) + + op.drop_table('tickers') + # ### end Alembic commands ### diff --git a/api/options_tool/analytics/atm_iv.py b/api/options_tool/analytics/atm_iv.py new file mode 100644 index 0000000..2587915 --- /dev/null +++ b/api/options_tool/analytics/atm_iv.py @@ -0,0 +1,124 @@ +"""Reduce a day's chains to the one implied-volatility number IV rank needs. + +IV rank compares today's implied volatility to its own history, which requires a +single scalar per day. Picking "the IV of some strike" is not good enough: as +spot drifts, a fixed strike slides along the skew, and as days pass the front +expiry rolls. Both make the resulting series move for reasons that have nothing +to do with volatility. + +So the daily number is a **30-day constant-maturity at-the-money implied +volatility**: + +1. within each expiry, interpolate IV to the exactly-at-the-money strike +2. across expiries, interpolate in *total variance* to a 30-day horizon + +Variance, not volatility, because variance is what is additive in time -- +interpolating volatility linearly across maturities systematically misprices the +middle. If 30 days cannot be bracketed by the listed expiries, this returns +``None`` and the day is excluded from IV rank rather than extrapolated. +""" + +from __future__ import annotations + +import pandas as pd + +__all__ = ["CONSTANT_MATURITY_DAYS", "atm_iv_for_expiry", "constant_maturity_atm_iv"] + +CONSTANT_MATURITY_DAYS = 30.0 +"""Horizon for the daily series. 30 days is the market convention (it is what +VIX-style measures target) and is long enough to be liquid, short enough to move.""" + +# Ignore strikes further than this from spot when interpolating to the money. +# Beyond it the skew is steep enough that interpolation is guesswork. +_MAX_MONEYNESS_DISTANCE = 0.15 + + +def atm_iv_for_expiry(frame: pd.DataFrame) -> float | None: + """Implied vol at spot for one expiry, or None if it cannot be established. + + Calls and puts are averaged where both sides interpolate. Put-call parity + means they should agree; averaging damps the disagreement caused by one side + having a wider or staler market. + """ + usable = frame[frame["iv"].notna()] + if usable.empty: + return None + usable = usable[(usable["moneyness"] - 1.0).abs() <= _MAX_MONEYNESS_DISTANCE] + if usable.empty: + return None + + per_side = [ + value + for _, side in usable.groupby("right", observed=True) + if (value := _interpolate_to_spot(side)) is not None + ] + if not per_side: + return None + return sum(per_side) / len(per_side) + + +def _interpolate_to_spot(side: pd.DataFrame) -> float | None: + """Linearly interpolate this side's IV between the strikes bracketing spot.""" + spot = float(side["spot"].iloc[0]) + below = side[side["strike"] <= spot].nlargest(1, "strike") + above = side[side["strike"] >= spot].nsmallest(1, "strike") + + if below.empty and above.empty: + return None + # Spot sits outside the listed strikes on this side: the nearest strike is + # the best available answer, and it is a real quote rather than an + # extrapolation off the end of the skew. + if below.empty: + return float(above["iv"].iloc[0]) + if above.empty: + return float(below["iv"].iloc[0]) + + k_lo, iv_lo = float(below["strike"].iloc[0]), float(below["iv"].iloc[0]) + k_hi, iv_hi = float(above["strike"].iloc[0]), float(above["iv"].iloc[0]) + if k_hi == k_lo: + return iv_lo + + weight = (spot - k_lo) / (k_hi - k_lo) + return iv_lo + weight * (iv_hi - iv_lo) + + +def constant_maturity_atm_iv( + by_maturity: dict[float, float], target_days: float = CONSTANT_MATURITY_DAYS +) -> float | None: + """Interpolate ATM vols to a fixed horizon, in total variance. + + Parameters + ---------- + by_maturity : {years to expiry: ATM implied vol} + target_days : the horizon to interpolate to, in calendar days + + Returns None when the horizon cannot be bracketed. Extrapolating the term + structure past the listed expiries would invent a number, and on day one of + the IV history that invented number becomes the historical minimum forever. + """ + points = sorted((T, iv) for T, iv in by_maturity.items() if T > 0 and iv is not None and iv > 0) + if not points: + return None + + target = target_days / 365.0 + + # An exact or near-exact match needs no interpolation at all. + for T, iv in points: + if abs(T - target) < 1e-9: + return iv + + below = [p for p in points if p[0] < target] + above = [p for p in points if p[0] > target] + if not below or not above: + return None + + t_lo, iv_lo = below[-1] + t_hi, iv_hi = above[0] + + # Total variance is additive in time; volatility is not. + var_lo, var_hi = iv_lo * iv_lo * t_lo, iv_hi * iv_hi * t_hi + weight = (target - t_lo) / (t_hi - t_lo) + variance = var_lo + weight * (var_hi - var_lo) + if variance <= 0: + return None + return float((variance / target) ** 0.5) diff --git a/api/options_tool/analytics/iv_rank.py b/api/options_tool/analytics/iv_rank.py new file mode 100644 index 0000000..8dac753 --- /dev/null +++ b/api/options_tool/analytics/iv_rank.py @@ -0,0 +1,154 @@ +"""IV rank and IV percentile over the locally-accumulated history. + + iv_rank = (iv_today - iv_min) / (iv_max - iv_min) * 100 + +The formula is trivial. The part that matters is **refusing to compute it when +there isn't enough history.** + +This tool starts with an empty database and accumulates one observation a day. +On day 3, `iv_min` and `iv_max` are drawn from three points, so today is +guaranteed to be at 0, 50 or 100 -- a number that looks like a signal and is +pure artefact. Worse, a spurious early minimum stays in the trailing window for +a year and depresses every rank computed against it. + +So below a configured minimum (20 days by default) this returns a result whose +`rank` is `None`, carrying a reason and a day count the UI renders as +"insufficient history (3/20 days)". That degraded state is a feature of the +design, not a gap in it (SPEC.md section 3). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from enum import StrEnum + +__all__ = ["IVRankStatus", "IVRankResult", "IVObservation", "compute_iv_rank"] + + +class IVRankStatus(StrEnum): + OK = "ok" + INSUFFICIENT_HISTORY = "insufficient_history" + NO_CURRENT_IV = "no_current_iv" + DEGENERATE_RANGE = "degenerate_range" + + +_EXPLANATIONS: dict[IVRankStatus, str] = { + IVRankStatus.OK: "ranked against stored history", + IVRankStatus.INSUFFICIENT_HISTORY: "not enough stored history yet", + IVRankStatus.NO_CURRENT_IV: "no at-the-money implied volatility for the latest day", + IVRankStatus.DEGENERATE_RANGE: "implied volatility has not moved across the window", +} + + +@dataclass(frozen=True, slots=True) +class IVObservation: + """One stored day of at-the-money implied volatility.""" + + observed_on: date + iv: float + + +@dataclass(frozen=True, slots=True) +class IVRankResult: + """`rank` and `percentile` are None unless `status is IVRankStatus.OK`.""" + + rank: float | None + percentile: float | None + current_iv: float | None + iv_min: float | None + iv_max: float | None + iv_mean: float | None + days_available: int + days_required: int + window_days: int + status: IVRankStatus + first_observed: date | None = None + last_observed: date | None = None + + @property + def ok(self) -> bool: + return self.status is IVRankStatus.OK + + @property + def reason(self) -> str: + """Plain-language explanation, safe to render verbatim in a UI.""" + if self.status is IVRankStatus.INSUFFICIENT_HISTORY: + return f"insufficient history ({self.days_available}/{self.days_required} days)" + return _EXPLANATIONS[self.status] + + +def compute_iv_rank( + observations: list[IVObservation], + window_days: int = 252, + min_history_days: int = 20, +) -> IVRankResult: + """Rank the most recent observation against the trailing window. + + Parameters + ---------- + observations : stored daily ATM implied vols, any order. Days where no ATM + vol could be established are expected to be absent, not present as zero. + window_days : how many recent observations to rank against + min_history_days : below this many observations, refuse to produce a rank + """ + usable = sorted( + (o for o in observations if o.iv is not None and o.iv > 0), + key=lambda o: o.observed_on, + ) + window = usable[-window_days:] + days_available = len(window) + + def failed(status: IVRankStatus, current: float | None = None) -> IVRankResult: + return IVRankResult( + rank=None, + percentile=None, + current_iv=current, + iv_min=None, + iv_max=None, + iv_mean=None, + days_available=days_available, + days_required=min_history_days, + window_days=window_days, + status=status, + first_observed=window[0].observed_on if window else None, + last_observed=window[-1].observed_on if window else None, + ) + + if not window: + return failed(IVRankStatus.NO_CURRENT_IV) + if days_available < min_history_days: + # Deliberately still None even though the arithmetic would succeed. + return failed(IVRankStatus.INSUFFICIENT_HISTORY, window[-1].iv) + + values = [o.iv for o in window] + current = values[-1] + iv_min, iv_max = min(values), max(values) + + if iv_max - iv_min <= 0: + # A flat window makes the rank 0/0. "Undefined" is the honest answer; + # convention would say 50, which invents a midpoint that means nothing. + return failed(IVRankStatus.DEGENERATE_RANGE, current) + + rank = (current - iv_min) / (iv_max - iv_min) * 100.0 + # IV percentile answers a different question from IV rank: what share of days + # were *below* today, rather than where today sits between the extremes. It + # is the more robust of the two, because a single outlier day sets the range + # that rank is measured against but moves percentile by one observation. + at_or_below = sum(1 for value in values if value <= current) + percentile = at_or_below / len(values) * 100.0 + + return IVRankResult( + rank=rank, + percentile=percentile, + current_iv=current, + iv_min=iv_min, + iv_max=iv_max, + iv_mean=sum(values) / len(values), + days_available=days_available, + days_required=min_history_days, + window_days=window_days, + status=IVRankStatus.OK, + first_observed=window[0].observed_on, + last_observed=window[-1].observed_on, + ) diff --git a/api/options_tool/cli.py b/api/options_tool/cli.py index 18a3f81..85894d1 100644 --- a/api/options_tool/cli.py +++ b/api/options_tool/cli.py @@ -66,6 +66,25 @@ def build_parser() -> argparse.ArgumentParser: ) p_chain.add_argument("--csv", type=Path, default=None, help="also write the frame to CSV") + p_watch = sub.add_parser("watchlist", help="show or edit the tracked symbols") + p_watch.add_argument("--add", nargs="+", metavar="TICKER", default=None) + p_watch.add_argument("--remove", nargs="+", metavar="TICKER", default=None) + p_watch.add_argument("--all", action="store_true", help="include removed symbols") + + p_snapshot = sub.add_parser( + "snapshot", + help="capture today's chains into the local history (idempotent, safe to re-run)", + ) + p_snapshot.add_argument( + "tickers", nargs="*", help="symbols to capture (default: the whole watchlist)" + ) + p_snapshot.add_argument( + "--expiries", type=int, default=6, help="expiries to capture per ticker" + ) + + p_ivrank = sub.add_parser("ivrank", help="IV rank from locally stored history") + p_ivrank.add_argument("tickers", nargs="*", help="default: the whole watchlist") + p_capture = sub.add_parser( "capture-fixture", help="refresh the offline test/demo fixture from the live vendor" ) @@ -88,10 +107,15 @@ def main(argv: list[str] | None = None) -> int: try: provider = get_provider(args.provider) + # Every handler takes (args, provider) so the dispatch stays uniform; + # the two that read only local history ignore the provider. handlers = { "quote": _cmd_quote, "expiries": _cmd_expiries, "chain": _cmd_chain, + "watchlist": _cmd_watchlist, + "snapshot": _cmd_snapshot, + "ivrank": _cmd_ivrank, "capture-fixture": _cmd_capture, } return handlers[args.command](args, provider) @@ -231,6 +255,129 @@ def _cmd_chain(args: argparse.Namespace, provider: MarketDataProvider) -> int: return 0 +def _cmd_watchlist(args: argparse.Namespace, provider: MarketDataProvider) -> int: # noqa: ARG001 + from options_tool.db.queries import ( # noqa: PLC0415 + add_to_watchlist, + history_depth, + list_watchlist, + remove_from_watchlist, + ) + from options_tool.db.session import init_db, session_scope # noqa: PLC0415 + + engine = init_db() + with session_scope(engine) as session: + for symbol in args.add or []: + add_to_watchlist(session, symbol) + print(f"added {symbol.upper()}") + for symbol in args.remove or []: + removed = remove_from_watchlist(session, symbol) + print(f"removed {symbol.upper()}" if removed else f"{symbol.upper()} was not tracked") + + tickers = list_watchlist(session, include_inactive=args.all) + if not tickers: + print("watchlist is empty — add one with: options-tool watchlist --add SPY") + return 0 + + print(f"{'symbol':<8} {'days':>5} history") + for ticker in tickers: + days, first, last = history_depth(session, ticker.symbol) + span = f"{first} to {last}" if days else "no snapshots yet" + flag = "" if ticker.active else " (removed)" + print(f"{ticker.symbol:<8} {days:>5} {span}{flag}") + return 0 + + +def _cmd_snapshot(args: argparse.Namespace, provider: MarketDataProvider) -> int: + from options_tool.config import get_settings # noqa: PLC0415 + from options_tool.db.queries import contract_count # noqa: PLC0415 + from options_tool.db.session import init_db, session_scope # noqa: PLC0415 + from options_tool.db.snapshot import snapshot_ticker, snapshot_watchlist # noqa: PLC0415 + from options_tool.providers.rates import resolve_risk_free_rate # noqa: PLC0415 + + settings = get_settings() + rate = resolve_risk_free_rate(settings) + engine = init_db() + + with session_scope(engine) as session: + before = contract_count(session) + if args.tickers: + results = [ + snapshot_ticker( + session, + provider, + ticker, + rate, + settings.options_dividend_yield, + args.expiries, + ) + for ticker in args.tickers + ] + else: + results = snapshot_watchlist( + session, provider, rate, settings.options_dividend_yield, args.expiries + ) + if not results: + print("watchlist is empty — add one with: options-tool watchlist --add SPY") + return 0 + + for result in results: + if result.errors and not result.contracts_written: + print(f"{result.ticker}: FAILED — {result.errors[0]}") + continue + atm = f"{result.atm_iv_30d:.1%}" if result.atm_iv_30d else "n/a" + verb = "captured" if result.created else "refreshed" + print( + f"{result.ticker} {result.snapshot_date}: {verb} " + f"{result.contracts_written} contracts across {len(result.expiries)} expiries " + f"({result.solve_rate:.0%} solved), 30d ATM IV {atm}" + ) + for error in result.errors: + print(f" warning: {error}") + + after = contract_count(session) + print(f"stored contract rows: {before} -> {after}") + return 0 + + +def _cmd_ivrank(args: argparse.Namespace, provider: MarketDataProvider) -> int: # noqa: ARG001 + from options_tool.config import get_settings # noqa: PLC0415 + from options_tool.db.queries import iv_rank_for, list_watchlist # noqa: PLC0415 + from options_tool.db.session import init_db, session_scope # noqa: PLC0415 + + settings = get_settings() + engine = init_db() + + with session_scope(engine) as session: + symbols = args.tickers or [t.symbol for t in list_watchlist(session)] + if not symbols: + print("watchlist is empty — add one with: options-tool watchlist --add SPY") + return 0 + + for symbol in symbols: + result = iv_rank_for( + session, + symbol, + settings.options_iv_window_days, + settings.options_min_history_days, + ) + if not result.ok: + # The degraded state is printed as prominently as a real answer. + # A blank or a zero here would read as "IV rank is low". + current = f" (ATM IV {result.current_iv:.1%})" if result.current_iv else "" + print(f"{symbol.upper():<8} IV rank unavailable: {result.reason}{current}") + continue + print( + f"{symbol.upper():<8} IV rank {result.rank:5.1f} " + f"percentile {result.percentile:5.1f} " + f"ATM IV {result.current_iv:.1%} " + f"range {result.iv_min:.1%}-{result.iv_max:.1%} " + f"({result.days_available} days, {result.first_observed} to " + f"{result.last_observed})" + ) + print(f"\n{DISCLAIMER}") + return 0 + + def _cmd_capture(args: argparse.Namespace, provider: MarketDataProvider) -> int: from options_tool.providers.capture import capture_fixture # noqa: PLC0415 from options_tool.providers.fixture_provider import DEFAULT_FIXTURE_PATH # noqa: PLC0415 diff --git a/api/options_tool/db/models.py b/api/options_tool/db/models.py new file mode 100644 index 0000000..a7f0702 --- /dev/null +++ b/api/options_tool/db/models.py @@ -0,0 +1,176 @@ +"""SQLAlchemy schema for the locally-accumulated options history. + +Three tables: + +- ``tickers`` -- the watchlist +- ``snapshots`` -- one row per (ticker, day): the chain-level facts, including + the ATM implied volatility that IV rank is computed from +- ``contracts`` -- one row per listed contract in that snapshot + +Idempotency is a **database guarantee, not application politeness.** The unique +constraint on ``(ticker_id, snapshot_date)`` combined with the one on +``(snapshot_id, expiry, strike, right)`` makes the pair +(ticker, date, expiry, strike, right) unique by construction, so re-running the +snapshot job cannot duplicate rows even if the writer has a bug (SPEC.md +section 3). + +``snapshots.atm_iv_30d`` is deliberately computed at write time rather than +derived on read. IV rank is a query over a daily series; storing the series +means that query is an index scan over a few hundred rows rather than a +re-solve of every contract ever captured. +""" + +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, + Date, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + +__all__ = ["Base", "Ticker", "Snapshot", "Contract"] + + +class Base(DeclarativeBase): + pass + + +class Ticker(Base): + """A symbol being tracked. `active` is what makes this the watchlist.""" + + __tablename__ = "tickers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + symbol: Mapped[str] = mapped_column(String(16), unique=True, nullable=False, index=True) + active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + note: Mapped[str | None] = mapped_column(String(256), nullable=True) + added_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + snapshots: Mapped[list[Snapshot]] = relationship( + back_populates="ticker", cascade="all, delete-orphan", passive_deletes=True + ) + + def __repr__(self) -> str: + return f"" + + +class Snapshot(Base): + """One capture of one ticker's chains on one day. + + The pricing assumptions in force at capture time (`risk_free_rate`, + `dividend_yield`) are stored alongside the results. Without them a stored IV + is uninterpretable later -- you could not tell a genuine volatility move from + a change to the rate the tool happened to be configured with. + """ + + __tablename__ = "snapshots" + __table_args__ = ( + UniqueConstraint("ticker_id", "snapshot_date", name="uq_snapshot_ticker_date"), + Index("ix_snapshot_ticker_date", "ticker_id", "snapshot_date"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + ticker_id: Mapped[int] = mapped_column( + ForeignKey("tickers.id", ondelete="CASCADE"), nullable=False + ) + snapshot_date: Mapped[date] = mapped_column(Date, nullable=False) + as_of: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + provider: Mapped[str] = mapped_column(String(32), nullable=False) + + spot: Mapped[float] = mapped_column(Float, nullable=False) + risk_free_rate: Mapped[float] = mapped_column(Float, nullable=False) + dividend_yield: Mapped[float] = mapped_column(Float, nullable=False) + + contract_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + solved_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # Nullable on purpose: a day where no ATM vol could be established stores + # null and is excluded from IV rank, rather than contributing a zero that + # would drag the historical minimum down forever. + atm_iv_30d: Mapped[float | None] = mapped_column(Float, nullable=True) + atm_iv_front: Mapped[float | None] = mapped_column(Float, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + ticker: Mapped[Ticker] = relationship(back_populates="snapshots") + contracts: Mapped[list[Contract]] = relationship( + back_populates="snapshot", cascade="all, delete-orphan", passive_deletes=True + ) + + @property + def solve_rate(self) -> float: + """Share of contracts whose IV solved. A low rate means distrust this day.""" + return self.solved_count / self.contract_count if self.contract_count else 0.0 + + def __repr__(self) -> str: + return f"" + + +class Contract(Base): + """One listed option contract as captured, with our own computed analytics. + + `iv` and the greeks are ours. There is no column for a vendor-supplied greek + and none should be added -- see SPEC.md section 4. + """ + + __tablename__ = "contracts" + __table_args__ = ( + UniqueConstraint( + "snapshot_id", "expiry", "strike", "right", name="uq_contract_snapshot_key" + ), + Index("ix_contract_expiry", "snapshot_id", "expiry"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + snapshot_id: Mapped[int] = mapped_column( + ForeignKey("snapshots.id", ondelete="CASCADE"), nullable=False + ) + + expiry: Mapped[date] = mapped_column(Date, nullable=False) + strike: Mapped[float] = mapped_column(Float, nullable=False) + right: Mapped[str] = mapped_column(String(4), nullable=False) + contract_symbol: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # --- as quoted by the vendor --- + bid: Mapped[float | None] = mapped_column(Float, nullable=True) + ask: Mapped[float | None] = mapped_column(Float, nullable=True) + last: Mapped[float | None] = mapped_column(Float, nullable=True) + mid: Mapped[float | None] = mapped_column(Float, nullable=True) + volume: Mapped[int | None] = mapped_column(Integer, nullable=True) + open_interest: Mapped[int | None] = mapped_column(Integer, nullable=True) + + # --- what we priced off, and what we derived --- + price: Mapped[float | None] = mapped_column(Float, nullable=True) + price_source: Mapped[str | None] = mapped_column(String(8), nullable=True) + time_to_expiry: Mapped[float] = mapped_column(Float, nullable=False) + moneyness: Mapped[float] = mapped_column(Float, nullable=False) + + iv: Mapped[float | None] = mapped_column(Float, nullable=True) + iv_status: Mapped[str] = mapped_column(String(24), nullable=False) + delta: Mapped[float | None] = mapped_column(Float, nullable=True) + gamma: Mapped[float | None] = mapped_column(Float, nullable=True) + vega: Mapped[float | None] = mapped_column(Float, nullable=True) + theta: Mapped[float | None] = mapped_column(Float, nullable=True) + rho: Mapped[float | None] = mapped_column(Float, nullable=True) + + snapshot: Mapped[Snapshot] = relationship(back_populates="contracts") + + def __repr__(self) -> str: + return f"" diff --git a/api/options_tool/db/queries.py b/api/options_tool/db/queries.py new file mode 100644 index 0000000..c974139 --- /dev/null +++ b/api/options_tool/db/queries.py @@ -0,0 +1,135 @@ +"""Read helpers over the stored history. + +Kept separate from the writer so the analytics stay ignorant of SQLAlchemy: this +module turns rows into the plain dataclasses `analytics/` already understands. +""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from options_tool.analytics.iv_rank import IVObservation, IVRankResult, compute_iv_rank +from options_tool.db.models import Contract, Snapshot, Ticker + +__all__ = [ + "list_watchlist", + "iv_observations", + "iv_rank_for", + "latest_snapshot", + "history_depth", + "add_to_watchlist", + "remove_from_watchlist", + "contract_count", +] + + +def list_watchlist(session: Session, include_inactive: bool = False) -> list[Ticker]: + statement = select(Ticker).order_by(Ticker.symbol) + if not include_inactive: + statement = statement.where(Ticker.active.is_(True)) + return list(session.scalars(statement)) + + +def add_to_watchlist(session: Session, symbol: str, note: str | None = None) -> Ticker: + """Add a symbol, or reactivate it if it was previously removed. + + Reactivating rather than re-inserting keeps the ticker's accumulated + snapshot history attached -- removing a symbol from the watchlist should not + throw away months of IV observations you cannot get back. + """ + normalised = symbol.strip().upper() + ticker = session.scalar(select(Ticker).where(Ticker.symbol == normalised)) + if ticker is None: + ticker = Ticker(symbol=normalised, active=True, note=note) + session.add(ticker) + session.flush() + return ticker + + ticker.active = True + if note is not None: + ticker.note = note + session.flush() + return ticker + + +def remove_from_watchlist(session: Session, symbol: str) -> bool: + """Deactivate a symbol. Returns False if it was not on the list. + + A soft delete: the history stays, the daily job stops fetching it. + """ + ticker = session.scalar(select(Ticker).where(Ticker.symbol == symbol.strip().upper())) + if ticker is None or not ticker.active: + return False + ticker.active = False + session.flush() + return True + + +def iv_observations(session: Session, symbol: str, limit: int | None = None) -> list[IVObservation]: + """Stored daily ATM implied vols for a symbol, oldest first. + + Days whose `atm_iv_30d` is null are excluded rather than zero-filled -- a day + where no ATM vol could be established is a missing observation, and zero + would become a permanent artificial minimum in the IV-rank window. + """ + statement = ( + select(Snapshot.snapshot_date, Snapshot.atm_iv_30d) + .join(Ticker, Snapshot.ticker_id == Ticker.id) + .where(Ticker.symbol == symbol.strip().upper(), Snapshot.atm_iv_30d.is_not(None)) + .order_by(Snapshot.snapshot_date.desc()) + ) + if limit is not None: + statement = statement.limit(limit) + + rows = list(session.execute(statement)) + return [ + IVObservation(observed_on=row.snapshot_date, iv=float(row.atm_iv_30d)) + for row in reversed(rows) + ] + + +def iv_rank_for( + session: Session, symbol: str, window_days: int = 252, min_history_days: int = 20 +) -> IVRankResult: + """IV rank for a symbol from stored history, honest about insufficiency.""" + observations = iv_observations(session, symbol, limit=window_days) + return compute_iv_rank(observations, window_days, min_history_days) + + +def latest_snapshot(session: Session, symbol: str) -> Snapshot | None: + return session.scalar( + select(Snapshot) + .join(Ticker, Snapshot.ticker_id == Ticker.id) + .where(Ticker.symbol == symbol.strip().upper()) + .order_by(Snapshot.snapshot_date.desc()) + .limit(1) + ) + + +def history_depth(session: Session, symbol: str) -> tuple[int, date | None, date | None]: + """(stored days, first date, last date) for a symbol. Cheap enough to call often.""" + row = session.execute( + select( + func.count(Snapshot.id), + func.min(Snapshot.snapshot_date), + func.max(Snapshot.snapshot_date), + ) + .join(Ticker, Snapshot.ticker_id == Ticker.id) + .where(Ticker.symbol == symbol.strip().upper()) + ).one() + return int(row[0]), row[1], row[2] + + +def contract_count(session: Session, symbol: str | None = None) -> int: + """Total stored contract rows, optionally for one symbol. Used to prove idempotency.""" + statement = select(func.count(Contract.id)) + if symbol is not None: + statement = ( + statement.join(Snapshot, Contract.snapshot_id == Snapshot.id) + .join(Ticker, Snapshot.ticker_id == Ticker.id) + .where(Ticker.symbol == symbol.strip().upper()) + ) + return int(session.scalar(statement) or 0) diff --git a/api/options_tool/db/session.py b/api/options_tool/db/session.py new file mode 100644 index 0000000..77b85ec --- /dev/null +++ b/api/options_tool/db/session.py @@ -0,0 +1,87 @@ +"""Engine and session construction. + +SQLite needs two things switched on explicitly that most people assume are +already true: + +- ``PRAGMA foreign_keys=ON`` -- off by default, so ``ON DELETE CASCADE`` in the + schema is silently ignored without it +- WAL journalling -- so the daily snapshot job writing does not block the API + reading, which is the exact concurrency this project has +""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +from sqlalchemy import Engine, create_engine, event +from sqlalchemy.orm import Session, sessionmaker + +from options_tool.config import get_settings +from options_tool.db.models import Base + +__all__ = ["create_db_engine", "get_sessionmaker", "session_scope", "init_db"] + + +def _ensure_sqlite_directory(url: str) -> None: + """Create the parent directory of a SQLite file so a clean clone just works.""" + prefix = "sqlite:///" + if not url.startswith(prefix) or url == f"{prefix}:memory:": + return + path = Path(url[len(prefix) :]) + if path.parent and str(path.parent) not in ("", "."): + path.parent.mkdir(parents=True, exist_ok=True) + + +def create_db_engine(url: str | None = None, echo: bool = False) -> Engine: + """Build an engine with the SQLite pragmas this project depends on.""" + url = url or get_settings().options_database_url + _ensure_sqlite_directory(url) + engine = create_engine(url, echo=echo, future=True) + + if engine.dialect.name == "sqlite": + + @event.listens_for(engine, "connect") + def _sqlite_pragmas(connection, _record): # type: ignore[no-untyped-def] + cursor = connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + # In-memory databases have no journal to write ahead of. + if not url.endswith(":memory:"): + cursor.execute("PRAGMA journal_mode=WAL") + cursor.close() + + return engine + + +def get_sessionmaker(engine: Engine | None = None) -> sessionmaker[Session]: + return sessionmaker(bind=engine or create_db_engine(), expire_on_commit=False) + + +@contextmanager +def session_scope(engine: Engine | None = None) -> Iterator[Session]: + """Transactional scope: commit on success, roll back on any exception. + + A half-written snapshot is worse than no snapshot -- it would look like a + complete day to IV rank while missing most of its chain. + """ + session = get_sessionmaker(engine)() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +def init_db(engine: Engine | None = None) -> Engine: + """Create any missing tables. Safe to call repeatedly. + + Alembic owns schema *changes*; this is the first-run convenience so a clean + clone can snapshot immediately without a migrate step. + """ + engine = engine or create_db_engine() + Base.metadata.create_all(engine) + return engine diff --git a/api/options_tool/db/snapshot.py b/api/options_tool/db/snapshot.py new file mode 100644 index 0000000..dd20cd8 --- /dev/null +++ b/api/options_tool/db/snapshot.py @@ -0,0 +1,300 @@ +"""The daily snapshot job -- the piece that makes IV rank possible at all. + +Free market-data sources do not sell historical implied volatility. Rather than +substitute realised volatility and call it the same thing, this job captures the +full chain once a day and writes it, with our own computed IV, to SQLite. +History accumulates from day one forward (SPEC.md section 3). + +**Idempotency is the requirement.** The job may be re-run: a cron timer fires +twice, a run half-fails and gets retried, someone runs it by hand. Running it +again on the same day must update that day's rows, never append a second copy -- +a duplicated day would silently double-weight itself in the IV-rank window. + +That is enforced in two places. The unique constraints in `models.py` make +duplicates impossible at the database level, and the `ON CONFLICT DO UPDATE` +upserts here make a re-run a correct refresh rather than an error. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import UTC, date, datetime +from typing import Any + +import pandas as pd +from sqlalchemy import select +from sqlalchemy.dialects.sqlite import insert as sqlite_insert +from sqlalchemy.orm import Session + +from options_tool.analytics.atm_iv import atm_iv_for_expiry, constant_maturity_atm_iv +from options_tool.analytics.chain import build_chain_frame +from options_tool.db.models import Contract, Snapshot, Ticker +from options_tool.providers.base import MarketDataProvider, ProviderError + +__all__ = ["SnapshotResult", "snapshot_ticker", "snapshot_watchlist", "get_or_create_ticker"] + +logger = logging.getLogger(__name__) + +# How many expiries to capture per ticker per day. Enough to bracket the 30-day +# constant-maturity point and give the term structure some shape, without +# pulling every LEAPS strike daily for data nobody queries. +DEFAULT_MAX_EXPIRIES = 6 + + +@dataclass +class SnapshotResult: + """What one ticker-day of snapshotting actually did.""" + + ticker: str + snapshot_date: date + contracts_written: int = 0 + contracts_solved: int = 0 + expiries: list[date] = field(default_factory=list) + atm_iv_30d: float | None = None + atm_iv_front: float | None = None + created: bool = False + errors: list[str] = field(default_factory=list) + + @property + def solve_rate(self) -> float: + return self.contracts_solved / self.contracts_written if self.contracts_written else 0.0 + + +def get_or_create_ticker(session: Session, symbol: str, active: bool = True) -> Ticker: + """Fetch the ticker row, inserting it if this is the first time we've seen it.""" + normalised = symbol.strip().upper() + ticker = session.scalar(select(Ticker).where(Ticker.symbol == normalised)) + if ticker is None: + ticker = Ticker(symbol=normalised, active=active) + session.add(ticker) + session.flush() + return ticker + + +def snapshot_ticker( + session: Session, + provider: MarketDataProvider, + symbol: str, + risk_free_rate: float, + dividend_yield: float = 0.0, + max_expiries: int = DEFAULT_MAX_EXPIRIES, + as_of: datetime | None = None, +) -> SnapshotResult: + """Capture one ticker's chains for one day. Safe to run more than once a day.""" + as_of = as_of or datetime.now(UTC) + snapshot_date = as_of.date() + result = SnapshotResult(ticker=symbol.upper(), snapshot_date=snapshot_date) + + ticker = get_or_create_ticker(session, symbol) + expiries = provider.get_expiries(symbol)[:max_expiries] + if not expiries: + raise ProviderError(f"{symbol}: no listed expiries to snapshot") + + frames: list[pd.DataFrame] = [] + atm_by_maturity: dict[float, float] = {} + spot: float | None = None + + for expiry in expiries: + try: + chain = provider.get_chain(symbol, expiry) + except ProviderError as exc: + # One bad expiry must not lose the whole day. Record it, keep going, + # and let the stored solve rate show the day was partial. + logger.warning("%s %s: skipping expiry (%s)", symbol, expiry, exc) + result.errors.append(f"{expiry}: {exc}") + continue + + frame = build_chain_frame(chain, risk_free_rate, dividend_yield, as_of) + if frame.empty: + continue + + frames.append(frame) + result.expiries.append(expiry) + spot = chain.spot + + atm = atm_iv_for_expiry(frame) + if atm is not None: + atm_by_maturity[float(frame.attrs["time_to_expiry"])] = atm + + if not frames or spot is None: + raise ProviderError(f"{symbol}: every expiry failed; nothing to snapshot") + + combined = pd.concat(frames, ignore_index=True) + result.contracts_written = len(combined) + result.contracts_solved = int(combined["iv"].notna().sum()) + result.atm_iv_30d = constant_maturity_atm_iv(atm_by_maturity) + result.atm_iv_front = atm_by_maturity[min(atm_by_maturity)] if atm_by_maturity else None + + snapshot_id, result.created = _upsert_snapshot( + session, + ticker_id=ticker.id, + snapshot_date=snapshot_date, + as_of=as_of, + provider_name=provider.name, + spot=spot, + risk_free_rate=risk_free_rate, + dividend_yield=dividend_yield, + result=result, + ) + _upsert_contracts(session, snapshot_id, combined) + + logger.info( + "%s %s: %d contracts (%.0f%% solved), 30d ATM IV %s", + result.ticker, + snapshot_date, + result.contracts_written, + result.solve_rate * 100, + f"{result.atm_iv_30d:.4f}" if result.atm_iv_30d else "unavailable", + ) + return result + + +def _upsert_snapshot( + session: Session, + ticker_id: int, + snapshot_date: date, + as_of: datetime, + provider_name: str, + spot: float, + risk_free_rate: float, + dividend_yield: float, + result: SnapshotResult, +) -> tuple[int, bool]: + """Insert the snapshot row, or refresh today's if it already exists.""" + existing = session.scalar( + select(Snapshot.id).where( + Snapshot.ticker_id == ticker_id, Snapshot.snapshot_date == snapshot_date + ) + ) + + values = { + "ticker_id": ticker_id, + "snapshot_date": snapshot_date, + "as_of": as_of, + "provider": provider_name, + "spot": spot, + "risk_free_rate": risk_free_rate, + "dividend_yield": dividend_yield, + "contract_count": result.contracts_written, + "solved_count": result.contracts_solved, + "atm_iv_30d": result.atm_iv_30d, + "atm_iv_front": result.atm_iv_front, + } + + statement = sqlite_insert(Snapshot).values(**values) + statement = statement.on_conflict_do_update( + index_elements=["ticker_id", "snapshot_date"], + set_={k: v for k, v in values.items() if k not in ("ticker_id", "snapshot_date")}, + ) + session.execute(statement) + session.flush() + + snapshot_id = session.scalar( + select(Snapshot.id).where( + Snapshot.ticker_id == ticker_id, Snapshot.snapshot_date == snapshot_date + ) + ) + assert snapshot_id is not None + return snapshot_id, existing is None + + +def _upsert_contracts(session: Session, snapshot_id: int, frame: pd.DataFrame) -> None: + """Write every contract row, replacing any already stored for this snapshot. + + Chunked because SQLite caps a statement at 999 bound parameters by default + and a full multi-expiry chain is thousands of rows across ~20 columns. + """ + rows = [_contract_values(snapshot_id, record) for record in frame.to_dict("records")] + if not rows: + return + + updatable = [c for c in rows[0] if c not in ("snapshot_id", "expiry", "strike", "right")] + chunk_size = max(1, 900 // len(rows[0])) + + for start in range(0, len(rows), chunk_size): + chunk = rows[start : start + chunk_size] + statement = sqlite_insert(Contract).values(chunk) + statement = statement.on_conflict_do_update( + index_elements=["snapshot_id", "expiry", "strike", "right"], + set_={column: statement.excluded[column] for column in updatable}, + ) + session.execute(statement) + session.flush() + + +def _contract_values(snapshot_id: int, record: Mapping[Any, Any]) -> dict[str, Any]: + """One frame row as column values, with pandas NA normalised to None. + + `pd.NA` and `NaN` are not values SQLite understands; letting them through + stores the string 'nan' or raises, depending on the column. + """ + + def clean(key: str) -> object: + value = record.get(key) + return None if value is None or pd.isna(value) else value + + expiry = record["expiry"] + return { + "snapshot_id": snapshot_id, + "expiry": expiry.date() if isinstance(expiry, pd.Timestamp) else expiry, + "strike": float(record["strike"]), + "right": str(record["right"]), + "contract_symbol": None, + "bid": clean("bid"), + "ask": clean("ask"), + "last": clean("last"), + "mid": clean("mid"), + "volume": clean("volume"), + "open_interest": clean("open_interest"), + "price": clean("price"), + "price_source": clean("price_source"), + "time_to_expiry": float(record["time_to_expiry"]), + "moneyness": float(record["moneyness"]), + "iv": clean("iv"), + "iv_status": str(record["iv_status"]), + "delta": clean("delta"), + "gamma": clean("gamma"), + "vega": clean("vega"), + "theta": clean("theta"), + "rho": clean("rho"), + } + + +def snapshot_watchlist( + session: Session, + provider: MarketDataProvider, + risk_free_rate: float, + dividend_yield: float = 0.0, + max_expiries: int = DEFAULT_MAX_EXPIRIES, + as_of: datetime | None = None, +) -> list[SnapshotResult]: + """Snapshot every active watchlist ticker. + + One ticker failing does not abort the rest -- a rate limit on the third + symbol should not cost you the history of the other five. + """ + symbols = list(session.scalars(select(Ticker.symbol).where(Ticker.active.is_(True)))) + results = [] + for symbol in symbols: + try: + results.append( + snapshot_ticker( + session, + provider, + symbol, + risk_free_rate, + dividend_yield, + max_expiries, + as_of, + ) + ) + except ProviderError as exc: + logger.error("%s: snapshot failed (%s)", symbol, exc) + failed = SnapshotResult( + ticker=symbol, snapshot_date=(as_of or datetime.now(UTC)).date() + ) + failed.errors.append(str(exc)) + results.append(failed) + return results From d85b502939043f0b23977e4408937643f1825a8b Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:44:54 -0400 Subject: [PATCH 03/11] M3: FastAPI with typed errors, and the multi-leg payoff engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nine endpoints, every response a Pydantic model, /docs browsable. - payoff: both curves — intrinsic at expiry, and every leg repriced through black_scholes.price at the remaining time for today. Reusing the M1 pricer is why a spread's greeks and its P/L curve cannot disagree. The extremes are derived analytically from the payoff's terminal slopes and its kink points, not read off the plotting grid. A first pass got this wrong in both directions: it called a long put's profit unlimited and a covered call's loss unlimited. Only the upside can be unbounded — the underlying can rise without limit but cannot fall below zero — so the downside extreme is exactly the value at S=0. Verified against hand-computed figures for long/ short calls and puts, vertical spreads, iron condors, collars and covered calls. Breakevens are solved segment-by-segment on the piecewise-linear payoff, so one beyond the outermost strike is still found. A leg with no implied volatility yields no today-curve and a reason, rather than a curve silently drawn at an assumed vol — on a chart there is nothing to distinguish the two. - errors: every failure is a structured body with a machine-readable code. The distinction that matters is 503 (Yahoo throttling, retryable) vs 404 (no such symbol). ExpiryNotFoundError was split out after an unlisted expiry came back as 502 implying the vendor was down; it is now a 404 carrying the available expiries so a client can correct itself in one round trip. The catch-all logs the traceback and returns internal_error — no bare 500 with a stack trace. - summarise_chain now returns a ChainStats dataclass rather than dict[str, object], which removed seven type: ignore casts at the route layer. - routes use Annotated dependencies, the current FastAPI idiom, so the dependency call is not evaluated in an argument default. Idempotency holds across interfaces: a snapshot taken by the CLI is recognised and refreshed by POST /snapshot rather than duplicated. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 40 +- api/options_tool/analytics/chain.py | 59 ++- api/options_tool/analytics/payoff.py | 309 ++++++++++++++ api/options_tool/api/deps.py | 44 ++ api/options_tool/api/errors.py | 112 +++++ api/options_tool/api/main.py | 66 +++ api/options_tool/api/routes.py | 391 ++++++++++++++++++ api/options_tool/api/schemas.py | 238 +++++++++++ api/options_tool/cli.py | 12 +- api/options_tool/providers/__init__.py | 2 + api/options_tool/providers/base.py | 23 +- .../providers/fixture_provider.py | 9 +- 12 files changed, 1273 insertions(+), 32 deletions(-) create mode 100644 api/options_tool/analytics/payoff.py create mode 100644 api/options_tool/api/deps.py create mode 100644 api/options_tool/api/errors.py create mode 100644 api/options_tool/api/main.py create mode 100644 api/options_tool/api/routes.py create mode 100644 api/options_tool/api/schemas.py diff --git a/README.md b/README.md index 1b1440b..56d3a09 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ rather than rendering a misleading zero. |---|---| | M1 — core analytics (pricing, greeks, IV solver, chain normalisation, CLI) | done | | M2 — persistence + daily snapshots + IV rank | done | -| M3 — FastAPI | in progress | -| M4 — tests + CI | planned | +| M3 — FastAPI | done | +| M4 — tests + CI | in progress | | M5 — Next.js front end | planned | | M6 — Docker + Linux deploy | planned | | M7 — polish (earnings flag, screener, CSV export) | planned | @@ -122,6 +122,42 @@ SPY IV rank unavailable: insufficient history (1/20 days) (ATM IV 14.6%) 6/6 strikes solved (100%) · greeks computed locally, not vendor-supplied ``` +### Run the API + +```bash +cd api +.venv/bin/uvicorn options_tool.api.main:app --reload +``` + +Interactive docs at . + +| Endpoint | Purpose | +|---|---| +| `GET /health` | liveness, plus whether the DB and provider are reachable | +| `GET /watchlist`, `POST /watchlist`, `DELETE /watchlist/{ticker}` | tracked symbols | +| `GET /quote/{ticker}` | spot | +| `GET /chain/{ticker}` | full chain with self-computed greeks | +| `GET /ivrank/{ticker}` | IV rank, or `null` plus a reason | +| `POST /snapshot/{ticker}` | capture today's chains (idempotent) | +| `POST /payoff` | multi-leg P/L at expiry **and** today | + +Errors are typed, never a bare 500. An unlisted expiry returns the ones that do +exist so the client can correct itself in one round trip: + +```json +{ + "code": "expiry_not_found", + "message": "AAPL: no listed chain for expiry 1999-01-01. Available: 2026-09-14, 2027-01-15, 2028-12-15", + "detail": { "available_expiries": ["2026-09-14", "2027-01-15", "2028-12-15"] } +} +``` + +`POST /payoff` solves each leg's implied volatility from the live chain when you +do not supply one, and returns both curves. Extremes are computed analytically +rather than read off the plotted grid, so `"max_loss": null` means genuinely +unlimited — and a long put's profit is correctly reported as large but finite, +because the underlying cannot fall below zero. + --- ## How the IV history works diff --git a/api/options_tool/analytics/chain.py b/api/options_tool/analytics/chain.py index b809288..412b31f 100644 --- a/api/options_tool/analytics/chain.py +++ b/api/options_tool/analytics/chain.py @@ -8,6 +8,7 @@ from __future__ import annotations +from dataclasses import dataclass, field from datetime import UTC, date, datetime from zoneinfo import ZoneInfo @@ -17,7 +18,13 @@ from options_tool.analytics.implied_vol import IVStatus, solve_implied_vol from options_tool.providers.base import OptionChain, OptionQuote -__all__ = ["CHAIN_COLUMNS", "build_chain_frame", "time_to_expiry", "summarise_chain"] +__all__ = [ + "CHAIN_COLUMNS", + "ChainStats", + "build_chain_frame", + "time_to_expiry", + "summarise_chain", +] # US equity options stop trading at 16:00 New York time on the expiry date. _MARKET_CLOSE = (16, 0) @@ -184,8 +191,25 @@ def build_chain_frame( return frame -def summarise_chain(frame: pd.DataFrame) -> dict[str, object]: - """Headline numbers for a chain, including how much of it failed to solve. +@dataclass(frozen=True, slots=True) +class ChainStats: + """Headline numbers for a chain, including how much of it failed to solve.""" + + ticker: str + expiry: date | None + spot: float + time_to_expiry: float + contracts: int + solved: int + solve_rate: float + atm_iv: float | None + total_volume: int + total_open_interest: int + unsolved_reasons: dict[str, int] = field(default_factory=dict) + + +def summarise_chain(frame: pd.DataFrame) -> ChainStats: + """Reduce a chain frame to its headline numbers. The solve rate is reported on purpose. A chain where a third of the strikes have no implied vol is a chain you should not draw conclusions from, and @@ -196,22 +220,23 @@ def summarise_chain(frame: pd.DataFrame) -> dict[str, object]: atm_iv = None if not atm.empty: # Nearest-to-the-money solved strike on each side, averaged: a cheap, - # transparent ATM vol. Not an interpolated surface -- and not claimed to be. + # transparent ATM vol. Not an interpolated surface -- and not claimed to + # be. `analytics.atm_iv` does the careful version for IV rank. atm["distance"] = (atm["moneyness"] - 1.0).abs() nearest = atm.sort_values("distance").groupby("right", observed=True).head(1) atm_iv = float(nearest["iv"].mean()) failures = frame.loc[~solved, "iv_status"].value_counts().to_dict() - return { - "ticker": frame.attrs.get("ticker"), - "expiry": frame.attrs.get("expiry"), - "spot": frame.attrs.get("spot"), - "time_to_expiry": frame.attrs.get("time_to_expiry"), - "contracts": len(frame), - "solved": int(solved.sum()), - "solve_rate": float(solved.mean()) if len(frame) else 0.0, - "atm_iv": atm_iv, - "total_volume": int(frame["volume"].sum(skipna=True) or 0), - "total_open_interest": int(frame["open_interest"].sum(skipna=True) or 0), - "unsolved_reasons": {str(k): int(v) for k, v in failures.items()}, - } + return ChainStats( + ticker=str(frame.attrs.get("ticker", "")), + expiry=frame.attrs.get("expiry"), + spot=float(frame.attrs.get("spot", 0.0)), + time_to_expiry=float(frame.attrs.get("time_to_expiry", 0.0)), + contracts=len(frame), + solved=int(solved.sum()), + solve_rate=float(solved.mean()) if len(frame) else 0.0, + atm_iv=atm_iv, + total_volume=int(frame["volume"].sum(skipna=True) or 0), + total_open_interest=int(frame["open_interest"].sum(skipna=True) or 0), + unsolved_reasons={str(k): int(v) for k, v in failures.items()}, + ) diff --git a/api/options_tool/analytics/payoff.py b/api/options_tool/analytics/payoff.py new file mode 100644 index 0000000..b51d583 --- /dev/null +++ b/api/options_tool/analytics/payoff.py @@ -0,0 +1,309 @@ +"""Profit and loss for a multi-leg options position. + +Two curves, because they answer different questions: + +- **at expiry** -- intrinsic value only, the classic hockey-stick diagram +- **today** -- every leg repriced through Black-Scholes at the time remaining, + which is what the position is actually worth if you close it now + +The "today" curve reuses `black_scholes.price` rather than reimplementing +anything. That reuse is the reason a spread's greeks and its P/L curve can never +disagree with each other. + +Behaviour beyond the plotted range is derived **analytically, not read off the +grid.** Past the outermost strike an option payoff is linear, so the slope there +is exact; a short call's unlimited loss is reported as unlimited rather than as +whatever number the last grid point happened to land on. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from options_tool.analytics.black_scholes import Right, price + +__all__ = [ + "LegKind", + "Leg", + "PayoffCurve", + "OPTION_MULTIPLIER", + "payoff_at_expiry", + "value_today", + "build_payoff_curve", +] + +OPTION_MULTIPLIER = 100 +"""Shares per US equity option contract. Getting this wrong is a 100x error in +every dollar figure the tool reports, so it is named rather than inlined.""" + + +class LegKind(StrEnum): + CALL = "call" + PUT = "put" + STOCK = "stock" + + +@dataclass(frozen=True, slots=True) +class Leg: + """One leg of a position. + + `quantity` is signed: positive is long, negative is short. `premium` is what + was paid (or received) per share, always quoted positive -- the sign of the + cash flow comes from `quantity`, so a short leg's credit does not need the + caller to also negate the premium. + """ + + kind: LegKind + quantity: int + premium: float + strike: float | None = None + volatility: float | None = None + + def __post_init__(self) -> None: + if self.quantity == 0: + raise ValueError("leg quantity must be non-zero") + if self.premium < 0: + raise ValueError( + f"premium is quoted positive ({self.premium} given); " + "use a negative quantity for a short leg" + ) + if self.kind is LegKind.STOCK: + if self.strike is not None: + raise ValueError("a stock leg has no strike") + elif self.strike is None or self.strike <= 0: + raise ValueError(f"{self.kind} leg needs a positive strike") + + @property + def multiplier(self) -> int: + return 1 if self.kind is LegKind.STOCK else OPTION_MULTIPLIER + + @property + def cost(self) -> float: + """Signed cash flow to open. Positive is a debit paid, negative a credit.""" + return self.quantity * self.premium * self.multiplier + + def intrinsic(self, underlying: float) -> float: + """Value of one share/contract-share of this leg at expiry.""" + if self.kind is LegKind.STOCK: + return underlying + assert self.strike is not None + if self.kind is LegKind.CALL: + return max(underlying - self.strike, 0.0) + return max(self.strike - underlying, 0.0) + + +def payoff_at_expiry(legs: list[Leg], underlying: float) -> float: + """Position P/L at expiry for a given settlement price.""" + value = sum(leg.quantity * leg.intrinsic(underlying) * leg.multiplier for leg in legs) + return value - sum(leg.cost for leg in legs) + + +def value_today( + legs: list[Leg], + underlying: float, + time_to_expiry: float, + risk_free_rate: float, + dividend_yield: float = 0.0, +) -> float | None: + """Position P/L if closed now, or None if a leg has no volatility to price with. + + Returning None rather than defaulting the missing volatility is deliberate: + a P/L curve drawn at an assumed 30% vol looks exactly as authoritative as one + drawn at the market's real vol, and there is nothing on the chart to tell + you which you are looking at. + """ + total = 0.0 + for leg in legs: + if leg.kind is LegKind.STOCK: + total += leg.quantity * underlying * leg.multiplier + continue + if leg.volatility is None or leg.volatility <= 0: + return None + assert leg.strike is not None + right = Right.CALL if leg.kind is LegKind.CALL else Right.PUT + leg_price = price( + underlying, + leg.strike, + time_to_expiry, + risk_free_rate, + leg.volatility, + dividend_yield, + right, + ) + total += leg.quantity * leg_price * leg.multiplier + + return total - sum(leg.cost for leg in legs) + + +@dataclass(frozen=True, slots=True) +class PayoffCurve: + """P/L across a range of underlying prices, plus the position's shape.""" + + underlying_prices: list[float] + pnl_at_expiry: list[float] + pnl_today: list[float] | None + net_cost: float + breakevens: list[float] + max_profit: float | None + max_loss: float | None + upside_slope: float + downside_slope: float + time_to_expiry: float + today_unavailable_reason: str | None = None + + @property + def unlimited_profit(self) -> bool: + return self.max_profit is None + + @property + def unlimited_loss(self) -> bool: + return self.max_loss is None + + +def build_payoff_curve( + legs: list[Leg], + spot: float, + time_to_expiry: float, + risk_free_rate: float, + dividend_yield: float = 0.0, + price_range: tuple[float, float] | None = None, + points: int = 201, +) -> PayoffCurve: + """Both P/L curves plus breakevens and true (not grid-bounded) extremes.""" + if not legs: + raise ValueError("a position needs at least one leg") + if spot <= 0: + raise ValueError(f"spot must be positive, got {spot}") + if points < 3: + raise ValueError("need at least 3 points to draw a curve") + + low, high = price_range or _default_range(legs, spot) + step = (high - low) / (points - 1) + prices = [low + i * step for i in range(points)] + + pnl_expiry = [payoff_at_expiry(legs, p) for p in prices] + + missing_vol = [ + leg for leg in legs if leg.kind is not LegKind.STOCK and not (leg.volatility or 0) > 0 + ] + if missing_vol: + pnl_today = None + reason = ( + f"{len(missing_vol)} leg(s) have no implied volatility, so today's value " + "cannot be priced. Supply a volatility per leg or let the API solve it " + "from the live chain." + ) + else: + pnl_today = [ + value_today(legs, p, time_to_expiry, risk_free_rate, dividend_yield) or 0.0 + for p in prices + ] + reason = None + + upside_slope, downside_slope = _terminal_slopes(legs) + max_profit, max_loss = _extremes(legs, upside_slope) + + return PayoffCurve( + underlying_prices=prices, + pnl_at_expiry=pnl_expiry, + pnl_today=pnl_today, + net_cost=sum(leg.cost for leg in legs), + breakevens=_breakevens(legs, upside_slope), + max_profit=max_profit, + max_loss=max_loss, + upside_slope=upside_slope, + downside_slope=downside_slope, + time_to_expiry=time_to_expiry, + today_unavailable_reason=reason, + ) + + +def _default_range(legs: list[Leg], spot: float) -> tuple[float, float]: + """A range wide enough to show every strike and the shape around them.""" + strikes = [leg.strike for leg in legs if leg.strike is not None] + low = min([spot, *strikes]) * 0.7 + high = max([spot, *strikes]) * 1.3 + return max(low, 0.01), high + + +def _terminal_slopes(legs: list[Leg]) -> tuple[float, float]: + """dP/dS above every strike, and dP/dS below every strike. + + Above all strikes every call is in the money (slope 1 per share) and every + put is worthless; below all strikes the reverse. Stock is linear throughout. + Both are exact derivatives, so "unlimited" can be reported as unlimited + rather than inferred from where a chart happens to stop. + """ + upside = 0.0 + downside = 0.0 + for leg in legs: + weight = leg.quantity * leg.multiplier + if leg.kind is LegKind.STOCK: + upside += weight + downside += weight + elif leg.kind is LegKind.CALL: + upside += weight + else: + downside -= weight + return upside, downside + + +def _kink_prices(legs: list[Leg]) -> list[float]: + """Underlying prices where the expiry payoff changes slope, plus both ends. + + The expiry payoff is piecewise linear with kinks only at strikes, so these + points plus the terminal slopes describe it exactly on [0, infinity). + Zero is included because the underlying can reach it -- that bound is why a + long put's profit is large but finite. + """ + strikes = sorted({leg.strike for leg in legs if leg.strike is not None}) + beyond = (strikes[-1] * 2.0) if strikes else 1.0 + return [0.0, *strikes, beyond] + + +def _extremes(legs: list[Leg], upside_slope: float) -> tuple[float | None, float | None]: + """Exact (max_profit, max_loss) over all possible settlement prices. + + `None` means genuinely unbounded, which only the upside can be: the + underlying can rise without limit but cannot fall below zero. That asymmetry + is why a naked short call has unlimited loss while a covered call -- whose + P/L also falls as the stock falls -- does not. + """ + values = [payoff_at_expiry(legs, p) for p in _kink_prices(legs)] + max_profit = None if upside_slope > 0 else max(values) + max_loss = None if upside_slope < 0 else min(values) + return max_profit, max_loss + + +def _breakevens(legs: list[Leg], upside_slope: float) -> list[float]: + """Exact underlying prices where expiry P/L crosses zero. + + Solved segment by segment on the piecewise-linear payoff rather than scanned + off the plotting grid, so a breakeven never lands a few cents out because of + where the grid points happened to fall -- and one beyond the last strike is + still found. + """ + kinks = _kink_prices(legs) + values = [payoff_at_expiry(legs, p) for p in kinks] + crossings: list[float] = [] + + for i in range(len(kinks) - 1): + lo, hi = values[i], values[i + 1] + if lo == 0.0: + crossings.append(kinks[i]) + elif lo * hi < 0.0: + weight = abs(lo) / (abs(lo) + abs(hi)) + crossings.append(kinks[i] + weight * (kinks[i + 1] - kinks[i])) + + # Past the outermost strike the payoff is a straight line, so any remaining + # crossing can be solved for directly instead of extending the grid. + if upside_slope != 0.0: + last_kink, last_value = kinks[-1], values[-1] + crossing = last_kink - last_value / upside_slope + if crossing > last_kink: + crossings.append(crossing) + elif values[-1] == 0.0: + crossings.append(kinks[-1]) + + return sorted(set(crossings)) diff --git a/api/options_tool/api/deps.py b/api/options_tool/api/deps.py new file mode 100644 index 0000000..a40b2a9 --- /dev/null +++ b/api/options_tool/api/deps.py @@ -0,0 +1,44 @@ +"""Shared dependencies for the API layer.""" + +from __future__ import annotations + +from collections.abc import Iterator +from functools import lru_cache + +from sqlalchemy import Engine +from sqlalchemy.orm import Session + +from options_tool.config import Settings, get_settings +from options_tool.db.session import get_sessionmaker, init_db +from options_tool.providers import MarketDataProvider, get_provider + +__all__ = ["get_engine", "get_session", "get_market_provider", "get_api_settings"] + + +@lru_cache(maxsize=1) +def get_engine() -> Engine: + """One engine per process. `init_db` makes a clean clone serve immediately.""" + return init_db() + + +def get_session() -> Iterator[Session]: + """Request-scoped session. Commits on success so POSTs persist.""" + session = get_sessionmaker(get_engine())() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +@lru_cache(maxsize=1) +def get_market_provider() -> MarketDataProvider: + """The configured provider. Cached so the fixture file is read once.""" + return get_provider() + + +def get_api_settings() -> Settings: + return get_settings() diff --git a/api/options_tool/api/errors.py b/api/options_tool/api/errors.py new file mode 100644 index 0000000..8ad8b61 --- /dev/null +++ b/api/options_tool/api/errors.py @@ -0,0 +1,112 @@ +"""Typed API errors. + +Every failure leaves this service as a structured body with a machine-readable +`code`, an explanation, and an HTTP status that says whose problem it is. There +is no path that produces a bare 500 with a stack trace: the catch-all handler +logs the traceback server-side and returns `internal_error` to the client. + +The distinction that matters most here is 503 vs 404. Yahoo throttling us is a +transient upstream failure the caller should retry; a symbol that does not exist +is not. Collapsing both into 500 would make the front end unable to tell the +user anything useful. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from options_tool.providers.base import ( + ExpiryNotFoundError, + ProviderError, + RateLimitedError, + UnknownTickerError, +) + +__all__ = ["ErrorBody", "APIError", "register_exception_handlers"] + +logger = logging.getLogger(__name__) + + +class ErrorBody(BaseModel): + """The shape of every error response this API returns.""" + + code: str = Field(description="Stable machine-readable identifier for the failure.") + message: str = Field(description="Human-readable explanation, safe to show a user.") + detail: dict[str, Any] | None = Field( + default=None, description="Optional structured context, e.g. available expiries." + ) + + +class APIError(Exception): + """Raise inside a route to return a typed error response.""" + + def __init__( + self, + code: str, + message: str, + status_code: int = status.HTTP_400_BAD_REQUEST, + detail: dict[str, Any] | None = None, + ) -> None: + super().__init__(message) + self.code = code + self.message = message + self.status_code = status_code + self.detail = detail + + def response(self) -> JSONResponse: + body = ErrorBody(code=self.code, message=self.message, detail=self.detail) + return JSONResponse(status_code=self.status_code, content=body.model_dump()) + + +def register_exception_handlers(app: FastAPI) -> None: + """Map every exception this service can raise onto a typed response.""" + + @app.exception_handler(APIError) + async def _api_error(_request: Request, exc: APIError) -> JSONResponse: + return exc.response() + + @app.exception_handler(UnknownTickerError) + async def _unknown_ticker(_request: Request, exc: UnknownTickerError) -> JSONResponse: + return APIError("unknown_ticker", str(exc), status.HTTP_404_NOT_FOUND).response() + + @app.exception_handler(ExpiryNotFoundError) + async def _expiry_not_found(_request: Request, exc: ExpiryNotFoundError) -> JSONResponse: + # The caller asked for a date that isn't listed. Hand back the ones that + # are, so the client can correct itself without a second round trip. + return APIError( + "expiry_not_found", + str(exc), + status.HTTP_404_NOT_FOUND, + detail={"available_expiries": [d.isoformat() for d in exc.available]}, + ).response() + + @app.exception_handler(RateLimitedError) + async def _rate_limited(_request: Request, exc: RateLimitedError) -> JSONResponse: + # 503, not 500: the caller did nothing wrong and retrying may well work. + return APIError( + "provider_rate_limited", str(exc), status.HTTP_503_SERVICE_UNAVAILABLE + ).response() + + @app.exception_handler(ProviderError) + async def _provider_error(_request: Request, exc: ProviderError) -> JSONResponse: + return APIError("provider_error", str(exc), status.HTTP_502_BAD_GATEWAY).response() + + @app.exception_handler(ValueError) + async def _value_error(_request: Request, exc: ValueError) -> JSONResponse: + # Analytics raise ValueError for inputs that are wrong rather than merely + # degenerate (a non-positive strike, a zero-quantity leg). That is a 400. + return APIError("invalid_input", str(exc), status.HTTP_400_BAD_REQUEST).response() + + @app.exception_handler(Exception) + async def _unhandled(request: Request, exc: Exception) -> JSONResponse: # noqa: ARG001 + logger.exception("unhandled error on %s %s", request.method, request.url.path) + return APIError( + "internal_error", + "An unexpected error occurred. The details have been logged server-side.", + status.HTTP_500_INTERNAL_SERVER_ERROR, + ).response() diff --git a/api/options_tool/api/main.py b/api/options_tool/api/main.py new file mode 100644 index 0000000..2c9a40e --- /dev/null +++ b/api/options_tool/api/main.py @@ -0,0 +1,66 @@ +"""FastAPI application. + + uvicorn options_tool.api.main:app --reload + +Docs are at `/docs`. Every response is a Pydantic model and every failure is a +typed error body -- see `errors.py`. +""" + +from __future__ import annotations + +import logging + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from options_tool import __version__ +from options_tool.api.errors import register_exception_handlers +from options_tool.api.routes import router + +__all__ = ["app", "create_app"] + +logger = logging.getLogger(__name__) + +DESCRIPTION = """ +Personal options analytics. + +**Greeks and implied volatility on every response are computed by this service**, +not read from the market-data vendor. Black-Scholes-Merton for pricing, Newton- +Raphson with a bisection fallback for the implied-vol solve. + +Where a value cannot be established it is returned as `null` with a status +explaining why — an unsolvable contract carries `iv: null` and an `iv_status`, +and `/ivrank` returns `rank: null` with a human-readable `reason` until enough +history has accumulated. Nothing here substitutes a plausible-looking default. + +_Not investment advice. A personal analytics tool; market data is delayed and +sourced from an unofficial endpoint._ +""" + + +def create_app() -> FastAPI: + app = FastAPI( + title="Options Analytics API", + version=__version__, + description=DESCRIPTION, + docs_url="/docs", + redoc_url="/redoc", + ) + + # The Next.js front end is served from a different origin in development. + # Permissive here because this is a single-user tool with no authentication + # and no secrets to leak; a multi-user deployment must narrow it. + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_credentials=False, + allow_methods=["GET", "POST", "DELETE"], + allow_headers=["*"], + ) + + register_exception_handlers(app) + app.include_router(router) + return app + + +app = create_app() diff --git a/api/options_tool/api/routes.py b/api/options_tool/api/routes.py new file mode 100644 index 0000000..1392383 --- /dev/null +++ b/api/options_tool/api/routes.py @@ -0,0 +1,391 @@ +"""HTTP routes. + +Thin by design: each route resolves inputs, calls the same analytics the CLI +calls, and maps the result onto a Pydantic model. Nothing is computed here that +is not also available offline, which is what keeps the API and the CLI from +drifting apart. +""" + +from __future__ import annotations + +from datetime import date +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from options_tool import __version__ +from options_tool.analytics.chain import build_chain_frame, summarise_chain +from options_tool.analytics.payoff import Leg, LegKind, build_payoff_curve +from options_tool.api.deps import get_api_settings, get_market_provider, get_session +from options_tool.api.errors import APIError +from options_tool.api.schemas import ( + ChainResponse, + ChainSummary, + ContractOut, + HealthResponse, + IVRankResponse, + LegIn, + PayoffRequest, + PayoffResponse, + QuoteResponse, + SnapshotResponse, + WatchlistAddRequest, + WatchlistItem, + WatchlistResponse, +) +from options_tool.config import Settings +from options_tool.db.queries import ( + add_to_watchlist, + history_depth, + iv_rank_for, + list_watchlist, + remove_from_watchlist, +) +from options_tool.db.snapshot import snapshot_ticker +from options_tool.providers.base import MarketDataProvider +from options_tool.providers.rates import resolve_risk_free_rate + +router = APIRouter() + +# Annotated dependencies rather than `= Depends(...)` defaults: the call then +# happens inside the annotation rather than at import time, which is both the +# current FastAPI idiom and what keeps these signatures honest to a type checker. +SessionDep = Annotated[Session, Depends(get_session)] +ProviderDep = Annotated[MarketDataProvider, Depends(get_market_provider)] +SettingsDep = Annotated[Settings, Depends(get_api_settings)] + + +# -------------------------------------------------------------------------- +# health +# -------------------------------------------------------------------------- + + +@router.get("/health", response_model=HealthResponse, tags=["meta"]) +def health(session: SessionDep, provider: ProviderDep) -> HealthResponse: + """Liveness plus the two things that actually break: the DB and the provider.""" + from sqlalchemy import text # noqa: PLC0415 + + try: + session.execute(text("SELECT 1")) + database_reachable = True + except Exception: # noqa: BLE001 -- health must report failure, not raise it + database_reachable = False + + return HealthResponse( + status="ok", + version=__version__, + provider=provider.name, + database_reachable=database_reachable, + ) + + +# -------------------------------------------------------------------------- +# watchlist +# -------------------------------------------------------------------------- + + +@router.get("/watchlist", response_model=WatchlistResponse, tags=["watchlist"]) +def get_watchlist( + session: SessionDep, + include_inactive: Annotated[bool, Query(description="Include removed symbols.")] = False, +) -> WatchlistResponse: + items = [] + for ticker in list_watchlist(session, include_inactive=include_inactive): + days, first, last = history_depth(session, ticker.symbol) + items.append( + WatchlistItem( + symbol=ticker.symbol, + active=ticker.active, + note=ticker.note, + stored_days=days, + first_snapshot=first, + last_snapshot=last, + ) + ) + return WatchlistResponse(items=items) + + +@router.post( + "/watchlist", + response_model=WatchlistItem, + status_code=status.HTTP_201_CREATED, + tags=["watchlist"], +) +def post_watchlist(payload: WatchlistAddRequest, session: SessionDep) -> WatchlistItem: + ticker = add_to_watchlist(session, payload.symbol, payload.note) + days, first, last = history_depth(session, ticker.symbol) + return WatchlistItem( + symbol=ticker.symbol, + active=ticker.active, + note=ticker.note, + stored_days=days, + first_snapshot=first, + last_snapshot=last, + ) + + +@router.delete("/watchlist/{ticker}", status_code=status.HTTP_204_NO_CONTENT, tags=["watchlist"]) +def delete_watchlist(ticker: str, session: SessionDep) -> None: + if not remove_from_watchlist(session, ticker): + raise APIError( + "not_on_watchlist", + f"{ticker.upper()} is not on the watchlist.", + status.HTTP_404_NOT_FOUND, + ) + + +# -------------------------------------------------------------------------- +# market data +# -------------------------------------------------------------------------- + + +@router.get("/quote/{ticker}", response_model=QuoteResponse, tags=["market"]) +def get_quote(ticker: str, provider: ProviderDep) -> QuoteResponse: + quote = provider.get_quote(ticker) + return QuoteResponse( + ticker=quote.ticker, + price=quote.price, + currency=quote.currency, + as_of=quote.as_of, + previous_close=quote.previous_close, + ) + + +@router.get("/chain/{ticker}", response_model=ChainResponse, tags=["market"]) +def get_chain( + ticker: str, + provider: ProviderDep, + settings: SettingsDep, + expiry: Annotated[ + date | None, Query(description="Defaults to the nearest listed expiry.") + ] = None, + right: Annotated[str | None, Query(pattern="^(call|put)$")] = None, + near_the_money: Annotated[ + float | None, + Query(gt=0, le=100, description="Keep strikes within this percent of spot."), + ] = None, +) -> ChainResponse: + """Full chain with locally computed greeks and implied volatility.""" + rate = resolve_risk_free_rate(settings) + expiries = provider.get_expiries(ticker) + resolved = provider.resolve_expiry(ticker, expiry) + + chain = provider.get_chain(ticker, resolved) + frame = build_chain_frame(chain, rate, settings.options_dividend_yield, chain.as_of) + + if right: + frame = frame[frame["right"] == right] + if near_the_money is not None: + frame = frame[(frame["moneyness"] - 1.0).abs() <= near_the_money / 100.0] + + summary = summarise_chain(frame) + contracts = [ + ContractOut.model_validate( + { + str(key): _none_if_na(value) + for key, value in record.items() + if key in _CONTRACT_FIELDS + } + ) + for record in frame.to_dict("records") + ] + + return ChainResponse( + ticker=chain.ticker, + expiry=resolved, + spot=chain.spot, + as_of=chain.as_of, + provider=provider.name, + risk_free_rate=rate, + dividend_yield=settings.options_dividend_yield, + time_to_expiry=float(frame.attrs["time_to_expiry"]), + available_expiries=list(expiries), + summary=ChainSummary( + contracts=summary.contracts, + solved=summary.solved, + solve_rate=summary.solve_rate, + atm_iv=summary.atm_iv, + total_volume=summary.total_volume, + total_open_interest=summary.total_open_interest, + unsolved_reasons=summary.unsolved_reasons, + ), + contracts=contracts, + ) + + +_CONTRACT_FIELDS = set(ContractOut.model_fields) + + +def _none_if_na(value: Any) -> Any: + """pandas NA/NaN are not JSON; Pydantic must see a real None.""" + import pandas as pd # noqa: PLC0415 + + if value is None: + return None + return None if not isinstance(value, list | dict) and bool(pd.isna(value)) else value + + +# -------------------------------------------------------------------------- +# analytics over stored history +# -------------------------------------------------------------------------- + + +@router.get("/ivrank/{ticker}", response_model=IVRankResponse, tags=["analytics"]) +def get_iv_rank(ticker: str, session: SessionDep, settings: SettingsDep) -> IVRankResponse: + """IV rank from locally accumulated history. + + Returns 200 with `rank: null` and a reason when history is insufficient. This + is not an error -- it is the correct answer on day three, and a client that + got a 404 here would have nothing useful to render. + """ + result = iv_rank_for( + session, ticker, settings.options_iv_window_days, settings.options_min_history_days + ) + return IVRankResponse( + ticker=ticker.upper(), + rank=result.rank, + percentile=result.percentile, + current_iv=result.current_iv, + iv_min=result.iv_min, + iv_max=result.iv_max, + iv_mean=result.iv_mean, + days_available=result.days_available, + days_required=result.days_required, + window_days=result.window_days, + status=result.status.value, + reason=result.reason, + first_observed=result.first_observed, + last_observed=result.last_observed, + ) + + +@router.post( + "/snapshot/{ticker}", + response_model=SnapshotResponse, + tags=["analytics"], + summary="Capture today's chains (idempotent)", +) +def post_snapshot( + ticker: str, + session: SessionDep, + provider: ProviderDep, + settings: SettingsDep, + expiries: Annotated[int, Query(ge=1, le=20)] = 6, +) -> SnapshotResponse: + """Run the snapshot for one ticker. Re-running the same day refreshes it.""" + rate = resolve_risk_free_rate(settings) + result = snapshot_ticker( + session, provider, ticker, rate, settings.options_dividend_yield, expiries + ) + return SnapshotResponse( + ticker=result.ticker, + snapshot_date=result.snapshot_date, + contracts_written=result.contracts_written, + contracts_solved=result.contracts_solved, + solve_rate=result.solve_rate, + expiries=result.expiries, + atm_iv_30d=result.atm_iv_30d, + created=result.created, + errors=result.errors, + ) + + +# -------------------------------------------------------------------------- +# payoff +# -------------------------------------------------------------------------- + + +@router.post("/payoff", response_model=PayoffResponse, tags=["analytics"]) +def post_payoff( + request: PayoffRequest, provider: ProviderDep, settings: SettingsDep +) -> PayoffResponse: + """P/L for a multi-leg position, at expiry and as of today.""" + rate = resolve_risk_free_rate(settings) + spot = request.spot + chain_frame = None + + # Only touch the vendor if we actually need something from it. + needs_lookup = spot is None or any( + leg.volatility is None and leg.kind != "stock" for leg in request.legs + ) + if needs_lookup and request.ticker: + expiry = provider.resolve_expiry(request.ticker, request.expiry) + chain = provider.get_chain(request.ticker, expiry) + spot = spot or chain.spot + chain_frame = build_chain_frame(chain, rate, settings.options_dividend_yield, chain.as_of) + + if spot is None: + raise APIError( + "spot_required", + "Provide `spot`, or a `ticker` the service can look one up for.", + status.HTTP_400_BAD_REQUEST, + ) + + time_to_expiry = _resolve_time_to_expiry(request, chain_frame) + legs = [_to_leg(leg, chain_frame) for leg in request.legs] + + curve = build_payoff_curve( + legs, + spot, + time_to_expiry, + rate, + settings.options_dividend_yield, + request.price_range, + request.points, + ) + + return PayoffResponse( + underlying_prices=curve.underlying_prices, + pnl_at_expiry=curve.pnl_at_expiry, + pnl_today=curve.pnl_today, + today_unavailable_reason=curve.today_unavailable_reason, + net_cost=curve.net_cost, + breakevens=curve.breakevens, + max_profit=curve.max_profit, + max_loss=curve.max_loss, + unlimited_profit=curve.unlimited_profit, + unlimited_loss=curve.unlimited_loss, + spot=spot, + time_to_expiry=curve.time_to_expiry, + ) + + +def _resolve_time_to_expiry(request: PayoffRequest, chain_frame: object) -> float: + if request.time_to_expiry is not None: + return request.time_to_expiry + if request.expiry is not None: + from options_tool.analytics.chain import time_to_expiry # noqa: PLC0415 + + return time_to_expiry(request.expiry) + if chain_frame is not None: + return float(chain_frame.attrs["time_to_expiry"]) # type: ignore[attr-defined] + # No horizon given and none discoverable: the expiry curve is still exact, + # and the today curve degrades to it rather than being fabricated. + return 0.0 + + +def _to_leg(leg: LegIn, chain_frame: object) -> Leg: + """Convert a request leg, solving its volatility from the chain if omitted.""" + kind = LegKind(leg.kind) + volatility = leg.volatility + if volatility is None and kind is not LegKind.STOCK and chain_frame is not None: + volatility = _lookup_iv(chain_frame, kind, leg.strike) + return Leg( + kind=kind, + quantity=leg.quantity, + premium=leg.premium, + strike=leg.strike, + volatility=volatility, + ) + + +def _lookup_iv(frame: object, kind: LegKind, strike: float | None) -> float | None: + """Implied vol of the nearest solved strike on the right side of the chain.""" + if strike is None: + return None + subset = frame[(frame["right"] == kind.value) & frame["iv"].notna()] # type: ignore[index] + if subset.empty: + return None + nearest = subset.iloc[(subset["strike"] - strike).abs().argmin()] + return float(nearest["iv"]) diff --git a/api/options_tool/api/schemas.py b/api/options_tool/api/schemas.py new file mode 100644 index 0000000..fcbec9e --- /dev/null +++ b/api/options_tool/api/schemas.py @@ -0,0 +1,238 @@ +"""Pydantic models for every request and response. + +Two conventions run through all of these: + +- **Nullable analytics are `float | None`, never defaulted.** A contract whose + implied volatility did not solve carries `iv: null` and an `iv_status` saying + why. Clients must handle the null; there is no zero to mistake for a real + reading. +- **Degraded states are modelled, not signalled by omission.** `IVRankResponse` + always returns, carrying `rank: null` plus a `reason` the UI renders verbatim. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +__all__ = [ + "HealthResponse", + "QuoteResponse", + "ContractOut", + "ChainSummary", + "ChainResponse", + "IVRankResponse", + "WatchlistItem", + "WatchlistResponse", + "WatchlistAddRequest", + "LegIn", + "PayoffRequest", + "PayoffResponse", + "SnapshotResponse", +] + +DISCLAIMER = "Not investment advice. Personal analytics only." + + +class HealthResponse(BaseModel): + status: Literal["ok"] + version: str + provider: str + database_reachable: bool + + +class QuoteResponse(BaseModel): + ticker: str + price: float + currency: str + as_of: datetime + previous_close: float | None = None + + +class ContractOut(BaseModel): + """One contract. Every greek here was computed by this service.""" + + model_config = ConfigDict(from_attributes=True) + + expiry: date + strike: float + right: Literal["call", "put"] + + bid: float | None + ask: float | None + last: float | None + mid: float | None + volume: int | None + open_interest: int | None + + price: float | None = Field(None, description="The price implied vol was solved from.") + price_source: Literal["mid", "last"] | None = Field( + None, description="Whether `price` came from a two-sided book or a stale print." + ) + time_to_expiry: float + moneyness: float + in_the_money: bool + + iv: float | None = Field(None, description="Null when the solve failed; see iv_status.") + iv_status: str = Field(description="'ok', or the named reason no IV was produced.") + delta: float | None + gamma: float | None + vega: float | None + theta: float | None + rho: float | None + + +class ChainSummary(BaseModel): + contracts: int + solved: int + solve_rate: float = Field(description="Share of strikes whose IV solved. Low means distrust.") + atm_iv: float | None + total_volume: int + total_open_interest: int + unsolved_reasons: dict[str, int] + + +class ChainResponse(BaseModel): + ticker: str + expiry: date + spot: float + as_of: datetime + provider: str + risk_free_rate: float + dividend_yield: float + time_to_expiry: float + available_expiries: list[date] + summary: ChainSummary + contracts: list[ContractOut] + disclaimer: str = DISCLAIMER + + +class IVRankResponse(BaseModel): + """Always returned, even when no rank can be produced. + + `rank` is null until enough history exists. `reason` is written to be shown + to a user as-is: "insufficient history (3/20 days)". + """ + + ticker: str + rank: float | None + percentile: float | None + current_iv: float | None + iv_min: float | None + iv_max: float | None + iv_mean: float | None + days_available: int + days_required: int + window_days: int + status: str + reason: str + first_observed: date | None + last_observed: date | None + disclaimer: str = DISCLAIMER + + +class WatchlistItem(BaseModel): + symbol: str + active: bool + note: str | None = None + stored_days: int = Field(description="Snapshot days accumulated for this symbol.") + first_snapshot: date | None + last_snapshot: date | None + + +class WatchlistResponse(BaseModel): + items: list[WatchlistItem] + + +class WatchlistAddRequest(BaseModel): + symbol: str = Field(min_length=1, max_length=16) + note: str | None = Field(default=None, max_length=256) + + @field_validator("symbol") + @classmethod + def _normalise(cls, value: str) -> str: + return value.strip().upper() + + +class LegIn(BaseModel): + """One leg of a position. + + `quantity` is signed -- negative is short. `premium` is always positive; the + direction of the cash flow comes from the quantity, so a short leg's credit + does not need the caller to negate anything. + """ + + kind: Literal["call", "put", "stock"] + quantity: int = Field(description="Signed: positive long, negative short.") + premium: float = Field(ge=0, description="Paid or received per share, always positive.") + strike: float | None = Field(default=None, gt=0) + volatility: float | None = Field( + default=None, + gt=0, + description=( + "Implied vol for the today-curve. Omit and the service solves it from " + "the live chain; if it cannot, the today curve is omitted with a reason." + ), + ) + + @field_validator("quantity") + @classmethod + def _non_zero(cls, value: int) -> int: + if value == 0: + raise ValueError("leg quantity must be non-zero") + return value + + +class PayoffRequest(BaseModel): + legs: list[LegIn] = Field(min_length=1, max_length=8) + ticker: str | None = Field( + default=None, description="Used to look up spot and solve missing leg volatilities." + ) + spot: float | None = Field(default=None, gt=0, description="Overrides the looked-up spot.") + expiry: date | None = Field( + default=None, description="Sets time to expiry for the today curve." + ) + time_to_expiry: float | None = Field(default=None, ge=0, description="Years; overrides expiry.") + points: int = Field(default=201, ge=3, le=1001) + price_range: tuple[float, float] | None = None + + @field_validator("ticker") + @classmethod + def _normalise(cls, value: str | None) -> str | None: + return value.strip().upper() if value else None + + +class PayoffResponse(BaseModel): + underlying_prices: list[float] + pnl_at_expiry: list[float] + pnl_today: list[float] | None = Field( + None, description="Null when a leg's volatility is unknown; see today_unavailable_reason." + ) + today_unavailable_reason: str | None = None + net_cost: float = Field(description="Positive is a net debit paid, negative a net credit.") + breakevens: list[float] + max_profit: float | None = Field( + None, description="Null means genuinely unlimited (the underlying can rise without bound)." + ) + max_loss: float | None = Field(None, description="Null means genuinely unlimited.") + unlimited_profit: bool + unlimited_loss: bool + spot: float | None + time_to_expiry: float + disclaimer: str = DISCLAIMER + + +class SnapshotResponse(BaseModel): + ticker: str + snapshot_date: date + contracts_written: int + contracts_solved: int + solve_rate: float + expiries: list[date] + atm_iv_30d: float | None + created: bool = Field( + description="False means an existing day was refreshed in place, not duplicated." + ) + errors: list[str] diff --git a/api/options_tool/cli.py b/api/options_tool/cli.py index 85894d1..f60a6a5 100644 --- a/api/options_tool/cli.py +++ b/api/options_tool/cli.py @@ -195,12 +195,12 @@ def _cmd_chain(args: argparse.Namespace, provider: MarketDataProvider) -> int: table = Table( title=( f"{chain.ticker} {expiry} spot {chain.spot:,.2f} " - f"T {summary['time_to_expiry']:.4f}y r {rate:.2%}" + f"T {summary.time_to_expiry:.4f}y r {rate:.2%}" ), caption=( - f"{summary['solved']}/{summary['contracts']} strikes solved " - f"({summary['solve_rate']:.0%})" - + (f" · ATM IV {summary['atm_iv']:.1%}" if summary["atm_iv"] else "") + f"{summary.solved}/{summary.contracts} strikes solved " + f"({summary.solve_rate:.0%})" + + (f" · ATM IV {summary.atm_iv:.1%}" if summary.atm_iv else "") + f" · greeks computed locally, not vendor-supplied · {DISCLAIMER}" ), ) @@ -248,8 +248,8 @@ def _cmd_chain(args: argparse.Namespace, provider: MarketDataProvider) -> int: ) console.print(table) - if summary["unsolved_reasons"]: - console.print(f"[dim]unsolved: {summary['unsolved_reasons']}[/dim]") + if summary.unsolved_reasons: + console.print(f"[dim]unsolved: {summary.unsolved_reasons}[/dim]") if args.csv: console.print(f"[dim]wrote {args.csv}[/dim]") return 0 diff --git a/api/options_tool/providers/__init__.py b/api/options_tool/providers/__init__.py index d782c8c..06f55a3 100644 --- a/api/options_tool/providers/__init__.py +++ b/api/options_tool/providers/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations from options_tool.providers.base import ( + ExpiryNotFoundError, MarketDataProvider, OptionChain, OptionQuote, @@ -17,6 +18,7 @@ ) __all__ = [ + "ExpiryNotFoundError", "MarketDataProvider", "OptionChain", "OptionQuote", diff --git a/api/options_tool/providers/base.py b/api/options_tool/providers/base.py index 79b0505..48da45d 100644 --- a/api/options_tool/providers/base.py +++ b/api/options_tool/providers/base.py @@ -21,6 +21,7 @@ "ProviderError", "RateLimitedError", "UnknownTickerError", + "ExpiryNotFoundError", "Quote", "OptionQuote", "OptionChain", @@ -40,6 +41,19 @@ class UnknownTickerError(ProviderError): """The vendor has no such symbol. A 404, not a 503.""" +class ExpiryNotFoundError(ProviderError): + """The symbol exists but is not listed for that expiry. + + Separate from `ProviderError` because it is the *caller's* mistake, not an + upstream failure -- it deserves a 404 carrying the available expiries, not a + 502 that implies the vendor is down. + """ + + def __init__(self, message: str, available: tuple[date, ...] = ()) -> None: + super().__init__(message) + self.available = available + + @dataclass(frozen=True, slots=True) class Quote: """A spot quote for an underlying.""" @@ -127,9 +141,10 @@ def resolve_expiry(self, ticker: str, expiry: date | None) -> date: if expiry is None: return expiries[0] if expiry not in expiries: - available = ", ".join(str(e) for e in expiries[:8]) - raise ProviderError( - f"{ticker}: no chain for expiry {expiry}. Available: {available}" - + (" ..." if len(expiries) > 8 else "") + shown = ", ".join(str(e) for e in expiries[:8]) + raise ExpiryNotFoundError( + f"{ticker.upper()}: no listed chain for expiry {expiry}. " + f"Available: {shown}" + (" ..." if len(expiries) > 8 else ""), + available=expiries, ) return expiry diff --git a/api/options_tool/providers/fixture_provider.py b/api/options_tool/providers/fixture_provider.py index d8181d6..1c4fd36 100644 --- a/api/options_tool/providers/fixture_provider.py +++ b/api/options_tool/providers/fixture_provider.py @@ -23,6 +23,7 @@ from typing import Any from options_tool.providers.base import ( + ExpiryNotFoundError, MarketDataProvider, OptionChain, OptionQuote, @@ -86,9 +87,11 @@ def get_chain(self, ticker: str, expiry: date) -> OptionChain: underlying = self._underlying(symbol) rows = underlying["chains"].get(expiry.isoformat()) if rows is None: - available = ", ".join(sorted(underlying["chains"])) - raise UnknownTickerError( - f"{symbol}: fixture has no chain for {expiry}. Captured expiries: {available}" + captured = self.get_expiries(symbol) + raise ExpiryNotFoundError( + f"{symbol}: fixture has no chain for {expiry}. Captured expiries: " + + ", ".join(str(e) for e in captured), + available=captured, ) quotes = tuple( From 7d697e4b3b0a43f72b04f60b8fc31b1d739be32f Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:04:47 -0400 Subject: [PATCH 04/11] M4: 459 tests, enforced-offline suite, and GitHub Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite does not merely avoid the network, it is prevented from reaching it: pytest-socket runs with --disable-socket, and tests/test_offline.py asserts the block is really in place so the guarantee cannot rot when someone edits config. AF_UNIX stays permitted because asyncio builds its event-loop self-pipe from socketpair(); outbound AF_INET, which is the part that matters, is blocked. Coverage: 96% on analytics/, 94% overall. CI gates at 90% and 85% rather than quoting a number in the README. Writing the tests found three real defects: - **Snapshot date was the UTC date.** A daily job run after 20:00 Eastern filed under the following day, so a 15:00 run and a 20:30 retry on the same trading afternoon produced two rows — defeating idempotency in exactly the scenario a cron timer creates. Snapshots are now keyed on the US market date. - **IV solver tolerance scaled to the wrong quantity.** It scaled to the total premium, but only the extrinsic part carries information about volatility. A deep ITM put worth $59.36 with four hundredths of a millionth in time value satisfied the price tolerance while sigma was wrong in the fourth decimal. Scaling to extrinsic value, plus a Newton termination on step size rather than residual alone, took that case from 4.2e-4 error to 2.8e-9. - **Payoff extremes** (fixed in M3, now regression-covered by golden data). Three test assumptions were wrong and got corrected rather than the code: gamma checked by a second difference of price is dominated by float64 cancellation at h=1e-5, so it is now checked as the first derivative of delta; delta is monotone in strike only at constant volatility, so strictness is asserted on synthetic constant-vol data and only trend on a real smile; and an ATM option's time value decays as sqrt(T), so near-expiry convergence is asserted against that analytic bound instead of a guessed constant. tests/fixtures/payoff_golden.json is read by both this suite and (from M5) the vitest suite, so the Python engine and the TypeScript preview cannot diverge without a test going red. CI: ruff, ruff format, mypy, pytest on 3.11 and 3.12, a migration up/down/up check, a CLI smoke test that greps for "refreshed" and "insufficient history", and a job that fails if a .env is ever tracked. The web job gates on web/package.json existing so it switches itself on when M5 lands. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 160 ++ README.md | 58 +- api/alembic/env.py | 2 +- ...6a74a_initial_schema_tickers_snapshots_.py | 175 ++- api/options_tool/analytics/chain.py | 22 +- api/options_tool/analytics/implied_vol.py | 24 +- api/options_tool/db/snapshot.py | 8 +- api/pyproject.toml | 19 +- api/tests/conftest.py | 135 ++ api/tests/fixtures/payoff_golden.json | 1296 +++++++++++++++++ api/tests/generate_payoff_golden.py | 147 ++ api/tests/test_api.py | 332 +++++ api/tests/test_atm_iv.py | 173 +++ api/tests/test_black_scholes.py | 255 ++++ api/tests/test_capture.py | 98 ++ api/tests/test_chain.py | 268 ++++ api/tests/test_cli.py | 198 +++ api/tests/test_config.py | 76 + api/tests/test_implied_vol.py | 247 ++++ api/tests/test_iv_rank.py | 178 +++ api/tests/test_offline.py | 69 + api/tests/test_payoff.py | 315 ++++ api/tests/test_providers.py | 413 ++++++ api/tests/test_snapshot.py | 365 +++++ 24 files changed, 4941 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 api/tests/conftest.py create mode 100644 api/tests/fixtures/payoff_golden.json create mode 100644 api/tests/generate_payoff_golden.py create mode 100644 api/tests/test_api.py create mode 100644 api/tests/test_atm_iv.py create mode 100644 api/tests/test_black_scholes.py create mode 100644 api/tests/test_capture.py create mode 100644 api/tests/test_chain.py create mode 100644 api/tests/test_cli.py create mode 100644 api/tests/test_config.py create mode 100644 api/tests/test_implied_vol.py create mode 100644 api/tests/test_iv_rank.py create mode 100644 api/tests/test_offline.py create mode 100644 api/tests/test_payoff.py create mode 100644 api/tests/test_providers.py create mode 100644 api/tests/test_snapshot.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c61a279 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,160 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # The front end lands in M5. Until then there is no web/package.json to + # install, so the web job gates on its existence rather than failing the run + # for a milestone that has not started. It switches itself on automatically. + detect: + name: detect workspaces + runs-on: ubuntu-latest + outputs: + web: ${{ steps.check.outputs.web }} + steps: + - uses: actions/checkout@v4 + - id: check + run: | + if [ -f web/package.json ]; then + echo "web=true" >> "$GITHUB_OUTPUT" + else + echo "web=false" >> "$GITHUB_OUTPUT" + echo "::notice::web/package.json not present yet; skipping the web job" + fi + + api: + name: api (python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + defaults: + run: + working-directory: api + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: api/pyproject.toml + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Lint + run: ruff check . + + - name: Format check + run: ruff format --check . + + - name: Type check + run: mypy options_tool + + # The suite runs with --disable-socket, so this is already offline. The + # coverage gate is scoped to the analytics package: that is where a + # regression would silently produce wrong numbers rather than an error. + - name: Test + run: >- + pytest + --cov=options_tool + --cov-report=term-missing + --cov-report=xml + --cov-fail-under=85 + + - name: Analytics coverage gate + run: >- + pytest + --cov=options_tool/analytics + --cov-report=term + --cov-fail-under=90 + -q + + - name: Verify the database migration applies and reverses + env: + OPTIONS_DATABASE_URL: sqlite:///./ci-migration-check.db + run: | + alembic upgrade head + alembic downgrade base + alembic upgrade head + + - name: Verify the CLI runs offline against the checked-in fixture + env: + OPTIONS_PROVIDER: fixture + OPTIONS_DATABASE_URL: sqlite:///./ci-smoke.db + run: | + python -m options_tool --provider fixture chain AAPL --expiry 2027-01-15 --near-the-money 3 + python -m options_tool --provider fixture watchlist --add AAPL + python -m options_tool --provider fixture snapshot AAPL + python -m options_tool --provider fixture snapshot AAPL | tee second-run.txt + grep -q "refreshed" second-run.txt + python -m options_tool --provider fixture ivrank AAPL | tee rank.txt + grep -q "insufficient history" rank.txt + + - name: Upload coverage + if: matrix.python-version == '3.11' + uses: actions/upload-artifact@v4 + with: + name: coverage + path: api/coverage.xml + if-no-files-found: error + + web: + name: web + needs: detect + if: needs.detect.outputs.web == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install + run: npm ci + + - name: Lint + run: npm run lint + + - name: Type check + run: npm run typecheck + + - name: Test + run: npm run test:run + + - name: Build + run: npm run build + env: + NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 + + secrets: + name: no committed secrets + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Fail if a .env file was committed + run: | + if git ls-files --error-unmatch .env 2>/dev/null; then + echo "::error::.env is tracked by git and must not be" + exit 1 + fi + echo "no tracked .env" diff --git a/README.md b/README.md index 56d3a09..bafc3a4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Options & Markets Analytics +[![CI](https://github.com/Tejas12972/stockwatchlist/actions/workflows/ci.yml/badge.svg)](https://github.com/Tejas12972/stockwatchlist/actions/workflows/ci.yml) +[![coverage](https://img.shields.io/badge/analytics%20coverage-96%25-brightgreen)](#tests) +[![python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue)](https://www.python.org/) +[![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) + A personal options-analytics tool. Option chains with **Black-Scholes greeks and implied volatility computed locally**, and an IV-rank history the tool builds itself by snapshotting chains daily. @@ -41,8 +46,8 @@ rather than rendering a misleading zero. | M1 — core analytics (pricing, greeks, IV solver, chain normalisation, CLI) | done | | M2 — persistence + daily snapshots + IV rank | done | | M3 — FastAPI | done | -| M4 — tests + CI | in progress | -| M5 — Next.js front end | planned | +| M4 — tests + CI | done | +| M5 — Next.js front end | in progress | | M6 — Docker + Linux deploy | planned | | M7 — polish (earnings flag, screener, CSV export) | planned | @@ -234,6 +239,55 @@ cannot be priced is information, so it is shown rather than dropped. --- +## Tests + +459 tests. **The suite runs with no network at all**, which is enforced rather +than intended: `pytest-socket` is configured with `--disable-socket`, so any +accidental outbound call fails the test that made it. `tests/test_offline.py` +asserts the block is actually in place, so the guarantee cannot rot quietly if +someone edits the config. + +```bash +cd api +.venv/bin/pytest # offline; sockets are blocked +.venv/bin/pytest --cov=options_tool # with coverage +.venv/bin/pytest -m network # opt in to live-vendor tests +.venv/bin/ruff check . && .venv/bin/mypy options_tool +``` + +| Module | Coverage | +|---|---| +| `analytics/` (pricing, IV, IV rank, payoff) | **96%** | +| whole package | 94% | + +CI enforces ≥85% overall and ≥90% on `analytics/` — a gate, not a number typed +into a README. + +What the suite actually checks, beyond line coverage: + +- **Greeks two independent ways** — against published reference values, *and* + against finite differences of this project's own pricer. A formula can match a + textbook while being wired to the wrong function; a greek can match finite + differences while being the wrong formula. Both together leave little room. +- **Put-call parity** across spots, maturities and dividend yields. +- **IV round-trips** over the whole surface: σ from 1e-4 to 5, T from 1e-4 to 2 + years, both rights, including the wings where Newton hands off to bisection. +- **Every solver edge case returns `None`** with a named reason — expired, zero + bid, below intrinsic, above the no-arbitrage maximum, non-finite input. +- **Snapshot idempotency**, checked from both ends: that re-running holds the row + count, and that the database rejects a duplicate even with the upsert bypassed. +- **The market date, not the UTC date** — a job run at 20:30 Eastern must file + under the same trading day as one at 15:00. +- **Payoff against hand-computed values** for eleven structures, plus a golden + file shared with the TypeScript suite so the two implementations cannot drift. +- **No vendor greek can cross the provider boundary** — asserted structurally by + checking `OptionQuote` has no field one could be stored in. +- **The vendor's failure modes**, against a fake `yfinance`: throttling is + retried and reported as retryable, a schema change fails fast, NaN becomes + `None`, and a strike-less row is dropped. + +--- + ## Limitations These are real and I would rather state them than hide them. diff --git a/api/alembic/env.py b/api/alembic/env.py index 0693e51..fde964a 100644 --- a/api/alembic/env.py +++ b/api/alembic/env.py @@ -11,9 +11,9 @@ from logging.config import fileConfig -from alembic import context from sqlalchemy import engine_from_config, pool +from alembic import context from options_tool.config import get_settings from options_tool.db.models import Base diff --git a/api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py b/api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py index 9c755ac..5919a9d 100644 --- a/api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py +++ b/api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py @@ -1,89 +1,112 @@ """initial schema: tickers, snapshots, contracts Revision ID: da5c89f6a74a -Revises: +Revises: Create Date: 2026-09-12 20:37:18.906707 """ -from typing import Sequence, Union -from alembic import op +from collections.abc import Sequence + import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'da5c89f6a74a' -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +revision: str = "da5c89f6a74a" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Upgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - op.create_table('tickers', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('symbol', sa.String(length=16), nullable=False), - sa.Column('active', sa.Boolean(), nullable=False), - sa.Column('note', sa.String(length=256), nullable=True), - sa.Column('added_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.PrimaryKeyConstraint('id') + op.create_table( + "tickers", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("symbol", sa.String(length=16), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("note", sa.String(length=256), nullable=True), + sa.Column( + "added_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), ) - with op.batch_alter_table('tickers', schema=None) as batch_op: - batch_op.create_index(batch_op.f('ix_tickers_symbol'), ['symbol'], unique=True) - - op.create_table('snapshots', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('ticker_id', sa.Integer(), nullable=False), - sa.Column('snapshot_date', sa.Date(), nullable=False), - sa.Column('as_of', sa.DateTime(timezone=True), nullable=False), - sa.Column('provider', sa.String(length=32), nullable=False), - sa.Column('spot', sa.Float(), nullable=False), - sa.Column('risk_free_rate', sa.Float(), nullable=False), - sa.Column('dividend_yield', sa.Float(), nullable=False), - sa.Column('contract_count', sa.Integer(), nullable=False), - sa.Column('solved_count', sa.Integer(), nullable=False), - sa.Column('atm_iv_30d', sa.Float(), nullable=True), - sa.Column('atm_iv_front', sa.Float(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['ticker_id'], ['tickers.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('ticker_id', 'snapshot_date', name='uq_snapshot_ticker_date') + with op.batch_alter_table("tickers", schema=None) as batch_op: + batch_op.create_index(batch_op.f("ix_tickers_symbol"), ["symbol"], unique=True) + + op.create_table( + "snapshots", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("ticker_id", sa.Integer(), nullable=False), + sa.Column("snapshot_date", sa.Date(), nullable=False), + sa.Column("as_of", sa.DateTime(timezone=True), nullable=False), + sa.Column("provider", sa.String(length=32), nullable=False), + sa.Column("spot", sa.Float(), nullable=False), + sa.Column("risk_free_rate", sa.Float(), nullable=False), + sa.Column("dividend_yield", sa.Float(), nullable=False), + sa.Column("contract_count", sa.Integer(), nullable=False), + sa.Column("solved_count", sa.Integer(), nullable=False), + sa.Column("atm_iv_30d", sa.Float(), nullable=True), + sa.Column("atm_iv_front", sa.Float(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.ForeignKeyConstraint(["ticker_id"], ["tickers.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("ticker_id", "snapshot_date", name="uq_snapshot_ticker_date"), ) - with op.batch_alter_table('snapshots', schema=None) as batch_op: - batch_op.create_index('ix_snapshot_ticker_date', ['ticker_id', 'snapshot_date'], unique=False) - - op.create_table('contracts', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('snapshot_id', sa.Integer(), nullable=False), - sa.Column('expiry', sa.Date(), nullable=False), - sa.Column('strike', sa.Float(), nullable=False), - sa.Column('right', sa.String(length=4), nullable=False), - sa.Column('contract_symbol', sa.String(length=64), nullable=True), - sa.Column('bid', sa.Float(), nullable=True), - sa.Column('ask', sa.Float(), nullable=True), - sa.Column('last', sa.Float(), nullable=True), - sa.Column('mid', sa.Float(), nullable=True), - sa.Column('volume', sa.Integer(), nullable=True), - sa.Column('open_interest', sa.Integer(), nullable=True), - sa.Column('price', sa.Float(), nullable=True), - sa.Column('price_source', sa.String(length=8), nullable=True), - sa.Column('time_to_expiry', sa.Float(), nullable=False), - sa.Column('moneyness', sa.Float(), nullable=False), - sa.Column('iv', sa.Float(), nullable=True), - sa.Column('iv_status', sa.String(length=24), nullable=False), - sa.Column('delta', sa.Float(), nullable=True), - sa.Column('gamma', sa.Float(), nullable=True), - sa.Column('vega', sa.Float(), nullable=True), - sa.Column('theta', sa.Float(), nullable=True), - sa.Column('rho', sa.Float(), nullable=True), - sa.ForeignKeyConstraint(['snapshot_id'], ['snapshots.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('snapshot_id', 'expiry', 'strike', 'right', name='uq_contract_snapshot_key') + with op.batch_alter_table("snapshots", schema=None) as batch_op: + batch_op.create_index( + "ix_snapshot_ticker_date", ["ticker_id", "snapshot_date"], unique=False + ) + + op.create_table( + "contracts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("snapshot_id", sa.Integer(), nullable=False), + sa.Column("expiry", sa.Date(), nullable=False), + sa.Column("strike", sa.Float(), nullable=False), + sa.Column("right", sa.String(length=4), nullable=False), + sa.Column("contract_symbol", sa.String(length=64), nullable=True), + sa.Column("bid", sa.Float(), nullable=True), + sa.Column("ask", sa.Float(), nullable=True), + sa.Column("last", sa.Float(), nullable=True), + sa.Column("mid", sa.Float(), nullable=True), + sa.Column("volume", sa.Integer(), nullable=True), + sa.Column("open_interest", sa.Integer(), nullable=True), + sa.Column("price", sa.Float(), nullable=True), + sa.Column("price_source", sa.String(length=8), nullable=True), + sa.Column("time_to_expiry", sa.Float(), nullable=False), + sa.Column("moneyness", sa.Float(), nullable=False), + sa.Column("iv", sa.Float(), nullable=True), + sa.Column("iv_status", sa.String(length=24), nullable=False), + sa.Column("delta", sa.Float(), nullable=True), + sa.Column("gamma", sa.Float(), nullable=True), + sa.Column("vega", sa.Float(), nullable=True), + sa.Column("theta", sa.Float(), nullable=True), + sa.Column("rho", sa.Float(), nullable=True), + sa.ForeignKeyConstraint(["snapshot_id"], ["snapshots.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "snapshot_id", "expiry", "strike", "right", name="uq_contract_snapshot_key" + ), ) - with op.batch_alter_table('contracts', schema=None) as batch_op: - batch_op.create_index('ix_contract_expiry', ['snapshot_id', 'expiry'], unique=False) + with op.batch_alter_table("contracts", schema=None) as batch_op: + batch_op.create_index("ix_contract_expiry", ["snapshot_id", "expiry"], unique=False) # ### end Alembic commands ### @@ -91,16 +114,16 @@ def upgrade() -> None: def downgrade() -> None: """Downgrade schema.""" # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('contracts', schema=None) as batch_op: - batch_op.drop_index('ix_contract_expiry') + with op.batch_alter_table("contracts", schema=None) as batch_op: + batch_op.drop_index("ix_contract_expiry") - op.drop_table('contracts') - with op.batch_alter_table('snapshots', schema=None) as batch_op: - batch_op.drop_index('ix_snapshot_ticker_date') + op.drop_table("contracts") + with op.batch_alter_table("snapshots", schema=None) as batch_op: + batch_op.drop_index("ix_snapshot_ticker_date") - op.drop_table('snapshots') - with op.batch_alter_table('tickers', schema=None) as batch_op: - batch_op.drop_index(batch_op.f('ix_tickers_symbol')) + op.drop_table("snapshots") + with op.batch_alter_table("tickers", schema=None) as batch_op: + batch_op.drop_index(batch_op.f("ix_tickers_symbol")) - op.drop_table('tickers') + op.drop_table("tickers") # ### end Alembic commands ### diff --git a/api/options_tool/analytics/chain.py b/api/options_tool/analytics/chain.py index 412b31f..0ddeca7 100644 --- a/api/options_tool/analytics/chain.py +++ b/api/options_tool/analytics/chain.py @@ -20,6 +20,8 @@ __all__ = [ "CHAIN_COLUMNS", + "MARKET_TZ", + "market_date", "ChainStats", "build_chain_frame", "time_to_expiry", @@ -28,9 +30,25 @@ # US equity options stop trading at 16:00 New York time on the expiry date. _MARKET_CLOSE = (16, 0) -_MARKET_TZ = ZoneInfo("America/New_York") +MARKET_TZ = ZoneInfo("America/New_York") _SECONDS_PER_YEAR = 365.0 * 24 * 60 * 60 + +def market_date(moment: datetime | None = None) -> date: + """The US trading date a moment belongs to. + + Deliberately *not* the UTC date. Snapshots are keyed by day, and after 20:00 + Eastern the UTC date has already rolled over -- so a job run at 15:00 and a + retry at 20:30 on the same trading afternoon would be filed under two + different dates and stored as two rows, defeating the idempotency guarantee + in precisely the situation a cron timer creates. + """ + moment = moment or datetime.now(UTC) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=UTC) + return moment.astimezone(MARKET_TZ).date() + + CHAIN_COLUMNS: dict[str, str] = { "ticker": "string", "expiry": "datetime64[ns]", @@ -76,7 +94,7 @@ def time_to_expiry(expiry: date, as_of: datetime | None = None) -> float: as_of = as_of or datetime.now(UTC) if as_of.tzinfo is None: as_of = as_of.replace(tzinfo=UTC) - close = datetime(expiry.year, expiry.month, expiry.day, *_MARKET_CLOSE, tzinfo=_MARKET_TZ) + close = datetime(expiry.year, expiry.month, expiry.day, *_MARKET_CLOSE, tzinfo=MARKET_TZ) return max((close - as_of).total_seconds() / _SECONDS_PER_YEAR, 0.0) diff --git a/api/options_tool/analytics/implied_vol.py b/api/options_tool/analytics/implied_vol.py index 694720e..961f5a3 100644 --- a/api/options_tool/analytics/implied_vol.py +++ b/api/options_tool/analytics/implied_vol.py @@ -41,9 +41,17 @@ SIGMA_TOLERANCE = 1e-9 -def _price_tolerance(target_price: float) -> float: - """Scale the price tolerance to the quote, with a floor at float64 noise.""" - return max(abs(target_price) * 1e-10, 1e-13) +def _price_tolerance(target_price: float, intrinsic: float) -> float: + """Scale the price tolerance to the option's *time value*, not its price. + + Only the extrinsic part of a premium carries information about volatility. + A deep in-the-money put worth $59.36 can have four cents of a millionth in + time value; a tolerance scaled to $59.36 is then far looser than the entire + informative signal, and the solver stops with sigma wrong in the fourth + decimal while reporting a price match. Scaling to the extrinsic value fixes + that; the floor keeps the target above float64 noise. + """ + return max((target_price - intrinsic) * 1e-8, 1e-14) class IVStatus(StrEnum): @@ -154,7 +162,7 @@ def solve_implied_vol( if target_price >= upper - PRICE_TOLERANCE: return IVResult(None, IVStatus.ABOVE_MAX) - tolerance = _price_tolerance(target_price) + tolerance = _price_tolerance(target_price, lower) def error_at(sigma: float) -> float: return price(S, K, T, r, sigma, q, right) - target_price @@ -192,9 +200,15 @@ def _newton( if vega < MIN_VEGA: return None - sigma -= err / vega + step = err / vega + sigma -= step if not math.isfinite(sigma) or sigma <= MIN_SIGMA or sigma >= MAX_SIGMA: return None + # Converged in the variable rather than the residual. On a contract whose + # price is nearly flat in sigma, the residual target can be below float64 + # resolution and unreachable, but the iteration has still stopped moving. + if abs(step) < SIGMA_TOLERANCE: + return sigma, iteration return None diff --git a/api/options_tool/db/snapshot.py b/api/options_tool/db/snapshot.py index dd20cd8..f36f582 100644 --- a/api/options_tool/db/snapshot.py +++ b/api/options_tool/db/snapshot.py @@ -29,7 +29,7 @@ from sqlalchemy.orm import Session from options_tool.analytics.atm_iv import atm_iv_for_expiry, constant_maturity_atm_iv -from options_tool.analytics.chain import build_chain_frame +from options_tool.analytics.chain import build_chain_frame, market_date from options_tool.db.models import Contract, Snapshot, Ticker from options_tool.providers.base import MarketDataProvider, ProviderError @@ -84,7 +84,7 @@ def snapshot_ticker( ) -> SnapshotResult: """Capture one ticker's chains for one day. Safe to run more than once a day.""" as_of = as_of or datetime.now(UTC) - snapshot_date = as_of.date() + snapshot_date = market_date(as_of) result = SnapshotResult(ticker=symbol.upper(), snapshot_date=snapshot_date) ticker = get_or_create_ticker(session, symbol) @@ -292,9 +292,7 @@ def snapshot_watchlist( ) except ProviderError as exc: logger.error("%s: snapshot failed (%s)", symbol, exc) - failed = SnapshotResult( - ticker=symbol, snapshot_date=(as_of or datetime.now(UTC)).date() - ) + failed = SnapshotResult(ticker=symbol, snapshot_date=market_date(as_of)) failed.errors.append(str(exc)) results.append(failed) return results diff --git a/api/pyproject.toml b/api/pyproject.toml index 06be299..6442ad6 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -51,6 +51,12 @@ packages = ["options_tool"] testpaths = ["tests"] addopts = [ "--disable-socket", + # AF_UNIX is local IPC, not network access. asyncio builds its event-loop + # self-pipe from socket.socketpair(), so without this every test that + # touches the FastAPI TestClient dies before it starts. Outbound AF_INET + # remains blocked, which is the guarantee that actually matters -- and + # tests/test_offline.py asserts it rather than trusting this comment. + "--allow-unix-socket", "-m", "not network", "--strict-markers", ] @@ -86,7 +92,18 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["ARG001"] +# Test idioms, not smells: +# ARG001/ARG002 -- a pytest fixture is often requested for its side effect +# (the `settings` fixture sets environment variables), so the parameter is +# genuinely unused by name. +# PLC0415 -- importing inside a test keeps the import under the assertion that +# depends on it, and lets a test prove something about import behaviour. +# RUF012 -- parametrisation tables held as plain class attributes read far +# better than ClassVar-annotated ones. +"tests/*" = ["ARG001", "ARG002", "PLC0415", "RUF012"] +# Alembic writes these; hand-wrapping them only loses the next autogenerate. +"alembic/versions/*" = ["E501"] +"alembic/env.py" = ["ERA001"] # Each early return in the solver is one documented, separately-named failure # mode. Collapsing them into fewer exits would hide exactly what section 4 of # SPEC.md asks to be made explicit. diff --git a/api/tests/conftest.py b/api/tests/conftest.py new file mode 100644 index 0000000..c1981e0 --- /dev/null +++ b/api/tests/conftest.py @@ -0,0 +1,135 @@ +"""Shared test fixtures. + +Every test in this suite runs with sockets disabled (`--disable-socket` in +`pyproject.toml`). Nothing here may reach the network: market data comes from +`FixtureProvider` replaying a real capture, and the database is SQLite on disk in +a temporary directory. + +That is not just hygiene. A suite that quietly depends on Yahoo being up is a +suite that fails in CI for reasons unrelated to the code, and one that passes +locally while the analytics are broken. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from datetime import UTC, date, datetime, timedelta +from pathlib import Path + +import pytest +from sqlalchemy import Engine +from sqlalchemy.orm import Session + +from options_tool.analytics.iv_rank import IVObservation +from options_tool.config import Settings, get_settings +from options_tool.db.models import Base +from options_tool.db.session import create_db_engine, get_sessionmaker +from options_tool.providers.fixture_provider import DEFAULT_FIXTURE_PATH, FixtureProvider + +# Reference market state used across the analytics tests. Round numbers on +# purpose -- when an assertion fails you can check it against a textbook. +REF_SPOT = 100.0 +REF_STRIKE = 100.0 +REF_YEARS = 1.0 +REF_RATE = 0.05 +REF_SIGMA = 0.20 + + +@pytest.fixture(scope="session") +def fixture_path() -> Path: + if not DEFAULT_FIXTURE_PATH.exists(): + pytest.fail( + f"missing {DEFAULT_FIXTURE_PATH}. It is checked in deliberately; " + "regenerate with `python -m options_tool capture-fixture AAPL SPY`." + ) + return DEFAULT_FIXTURE_PATH + + +@pytest.fixture +def provider(fixture_path: Path) -> FixtureProvider: + """Offline market data replayed from a real vendor capture.""" + return FixtureProvider(fixture_path) + + +@pytest.fixture +def settings(tmp_path: Path) -> Iterator[Settings]: + """Settings pointed at a throwaway database, with the cache cleared. + + `get_settings` is lru_cached for the process, so without clearing it one + test's configuration leaks into every later test. + """ + previous = dict(os.environ) + os.environ.update( + { + "OPTIONS_PROVIDER": "fixture", + "OPTIONS_DATABASE_URL": f"sqlite:///{tmp_path / 'test.db'}", + "OPTIONS_RISK_FREE_RATE": "0.04", + "OPTIONS_DIVIDEND_YIELD": "0.0", + "OPTIONS_MIN_HISTORY_DAYS": "20", + "OPTIONS_IV_WINDOW_DAYS": "252", + "OPTIONS_FETCH_RISK_FREE_RATE": "false", + } + ) + get_settings.cache_clear() + yield get_settings() + os.environ.clear() + os.environ.update(previous) + get_settings.cache_clear() + + +@pytest.fixture +def engine(settings: Settings) -> Iterator[Engine]: + engine = create_db_engine(settings.options_database_url) + Base.metadata.create_all(engine) + yield engine + engine.dispose() + + +@pytest.fixture +def session(engine: Engine) -> Iterator[Session]: + session = get_sessionmaker(engine)() + try: + yield session + session.commit() + finally: + session.close() + + +@pytest.fixture +def api_client(engine: Engine, provider: FixtureProvider, settings: Settings): # type: ignore[no-untyped-def] + """TestClient with the app's engine and provider pointed at test doubles.""" + from fastapi.testclient import TestClient + + from options_tool.api import deps + from options_tool.api.main import create_app + + app = create_app() + app.dependency_overrides[deps.get_engine] = lambda: engine + app.dependency_overrides[deps.get_market_provider] = lambda: provider + app.dependency_overrides[deps.get_api_settings] = lambda: settings + + def _session() -> Iterator[Session]: + db = get_sessionmaker(engine)() + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + app.dependency_overrides[deps.get_session] = _session + with TestClient(app) as client: + yield client + + +def make_observations(values: list[float], start: date | None = None) -> list[IVObservation]: + """Consecutive daily IV observations, oldest first.""" + start = start or date(2026, 1, 1) + return [IVObservation(start + timedelta(days=i), v) for i, v in enumerate(values)] + + +def utc(year: int, month: int, day: int, hour: int = 15) -> datetime: + return datetime(year, month, day, hour, tzinfo=UTC) diff --git a/api/tests/fixtures/payoff_golden.json b/api/tests/fixtures/payoff_golden.json new file mode 100644 index 0000000..9b7d887 --- /dev/null +++ b/api/tests/fixtures/payoff_golden.json @@ -0,0 +1,1296 @@ +{ + "note": "Golden payoff values. Read by BOTH the Python suite (tests/test_payoff.py) and the TypeScript suite (web/lib/__tests__/payoff.test.ts) so the two implementations cannot drift apart unnoticed. Regenerate with tests/generate_payoff_golden.py.", + "parameters": { + "spot": 100.0, + "risk_free_rate": 0.05, + "time_to_expiry": 0.25, + "dividend_yield": 0.0, + "price_range": [ + 60.0, + 140.0 + ], + "points": 41, + "option_multiplier": 100 + }, + "cases": [ + { + "name": "long_call", + "legs": [ + { + "kind": "call", + "quantity": 1, + "premium": 5.0, + "strike": 100, + "volatility": 0.25 + } + ], + "expected": { + "net_cost": 500.0, + "breakevens": [ + 105.0 + ], + "max_profit": null, + "max_loss": -500.0, + "unlimited_profit": true, + "unlimited_loss": false, + "upside_slope": 100.0, + "downside_slope": 0.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -500.0, + -300.0, + -100.0, + 100.0, + 300.0, + 500.0, + 700.0, + 900.0, + 1100.0, + 1300.0, + 1500.0, + 1700.0, + 1900.0, + 2100.0, + 2300.0, + 2500.0, + 2700.0, + 2900.0, + 3100.0, + 3300.0, + 3500.0 + ] + } + }, + { + "name": "short_call_naked", + "legs": [ + { + "kind": "call", + "quantity": -1, + "premium": 5.0, + "strike": 100, + "volatility": 0.25 + } + ], + "expected": { + "net_cost": -500.0, + "breakevens": [ + 105.0 + ], + "max_profit": 500.0, + "max_loss": null, + "unlimited_profit": false, + "unlimited_loss": true, + "upside_slope": -100.0, + "downside_slope": 0.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 500.0, + 300.0, + 100.0, + -100.0, + -300.0, + -500.0, + -700.0, + -900.0, + -1100.0, + -1300.0, + -1500.0, + -1700.0, + -1900.0, + -2100.0, + -2300.0, + -2500.0, + -2700.0, + -2900.0, + -3100.0, + -3300.0, + -3500.0 + ] + } + }, + { + "name": "long_put", + "legs": [ + { + "kind": "put", + "quantity": 1, + "premium": 4.0, + "strike": 100, + "volatility": 0.25 + } + ], + "expected": { + "net_cost": 400.0, + "breakevens": [ + 96.0 + ], + "max_profit": 9600.0, + "max_loss": -400.0, + "unlimited_profit": false, + "unlimited_loss": false, + "upside_slope": 0.0, + "downside_slope": -100.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + 3600.0, + 3400.0, + 3200.0, + 3000.0, + 2800.0, + 2600.0, + 2400.0, + 2200.0, + 2000.0, + 1800.0, + 1600.0, + 1400.0, + 1200.0, + 1000.0, + 800.0, + 600.0, + 400.0, + 200.0, + 0.0, + -200.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0, + -400.0 + ] + } + }, + { + "name": "short_put", + "legs": [ + { + "kind": "put", + "quantity": -1, + "premium": 4.0, + "strike": 100, + "volatility": 0.25 + } + ], + "expected": { + "net_cost": -400.0, + "breakevens": [ + 96.0 + ], + "max_profit": 400.0, + "max_loss": -9600.0, + "unlimited_profit": false, + "unlimited_loss": false, + "upside_slope": 0.0, + "downside_slope": 100.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + -3600.0, + -3400.0, + -3200.0, + -3000.0, + -2800.0, + -2600.0, + -2400.0, + -2200.0, + -2000.0, + -1800.0, + -1600.0, + -1400.0, + -1200.0, + -1000.0, + -800.0, + -600.0, + -400.0, + -200.0, + 0.0, + 200.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0, + 400.0 + ] + } + }, + { + "name": "bull_call_spread", + "legs": [ + { + "kind": "call", + "quantity": 1, + "premium": 5.0, + "strike": 100, + "volatility": 0.25 + }, + { + "kind": "call", + "quantity": -1, + "premium": 1.5, + "strike": 110, + "volatility": 0.23 + } + ], + "expected": { + "net_cost": 350.0, + "breakevens": [ + 103.5 + ], + "max_profit": 650.0, + "max_loss": -350.0, + "unlimited_profit": false, + "unlimited_loss": false, + "upside_slope": 0.0, + "downside_slope": 0.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -350.0, + -150.0, + 50.0, + 250.0, + 450.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0, + 650.0 + ] + } + }, + { + "name": "bear_put_spread", + "legs": [ + { + "kind": "put", + "quantity": 1, + "premium": 4.0, + "strike": 100, + "volatility": 0.25 + }, + { + "kind": "put", + "quantity": -1, + "premium": 1.2, + "strike": 90, + "volatility": 0.28 + } + ], + "expected": { + "net_cost": 280.0, + "breakevens": [ + 97.2 + ], + "max_profit": 720.0, + "max_loss": -280.0, + "unlimited_profit": false, + "unlimited_loss": false, + "upside_slope": 0.0, + "downside_slope": 0.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 720.0, + 520.0, + 320.0, + 120.0, + -80.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0, + -280.0 + ] + } + }, + { + "name": "iron_condor", + "legs": [ + { + "kind": "put", + "quantity": 1, + "premium": 0.5, + "strike": 90, + "volatility": 0.3 + }, + { + "kind": "put", + "quantity": -1, + "premium": 1.5, + "strike": 95, + "volatility": 0.27 + }, + { + "kind": "call", + "quantity": -1, + "premium": 1.6, + "strike": 105, + "volatility": 0.24 + }, + { + "kind": "call", + "quantity": 1, + "premium": 0.6, + "strike": 110, + "volatility": 0.23 + } + ], + "expected": { + "net_cost": -200.0, + "breakevens": [ + 93.0, + 107.0 + ], + "max_profit": 200.0, + "max_loss": -300.0, + "unlimited_profit": false, + "unlimited_loss": false, + "upside_slope": 0.0, + "downside_slope": 0.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -100.0, + 100.0, + 200.0, + 200.0, + 200.0, + 200.0, + 200.0, + 100.0, + -100.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0, + -300.0 + ] + } + }, + { + "name": "long_straddle", + "legs": [ + { + "kind": "call", + "quantity": 1, + "premium": 5.0, + "strike": 100, + "volatility": 0.25 + }, + { + "kind": "put", + "quantity": 1, + "premium": 4.5, + "strike": 100, + "volatility": 0.25 + } + ], + "expected": { + "net_cost": 950.0, + "breakevens": [ + 90.5, + 109.5 + ], + "max_profit": null, + "max_loss": -950.0, + "unlimited_profit": true, + "unlimited_loss": false, + "upside_slope": 100.0, + "downside_slope": -100.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + 3050.0, + 2850.0, + 2650.0, + 2450.0, + 2250.0, + 2050.0, + 1850.0, + 1650.0, + 1450.0, + 1250.0, + 1050.0, + 850.0, + 650.0, + 450.0, + 250.0, + 50.0, + -150.0, + -350.0, + -550.0, + -750.0, + -950.0, + -750.0, + -550.0, + -350.0, + -150.0, + 50.0, + 250.0, + 450.0, + 650.0, + 850.0, + 1050.0, + 1250.0, + 1450.0, + 1650.0, + 1850.0, + 2050.0, + 2250.0, + 2450.0, + 2650.0, + 2850.0, + 3050.0 + ] + } + }, + { + "name": "covered_call", + "legs": [ + { + "kind": "stock", + "quantity": 100, + "premium": 100.0 + }, + { + "kind": "call", + "quantity": -1, + "premium": 3.0, + "strike": 105, + "volatility": 0.24 + } + ], + "expected": { + "net_cost": 9700.0, + "breakevens": [ + 97.0 + ], + "max_profit": 800.0, + "max_loss": -9700.0, + "unlimited_profit": false, + "unlimited_loss": false, + "upside_slope": 0.0, + "downside_slope": 100.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + -3700.0, + -3500.0, + -3300.0, + -3100.0, + -2900.0, + -2700.0, + -2500.0, + -2300.0, + -2100.0, + -1900.0, + -1700.0, + -1500.0, + -1300.0, + -1100.0, + -900.0, + -700.0, + -500.0, + -300.0, + -100.0, + 100.0, + 300.0, + 500.0, + 700.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0, + 800.0 + ] + } + }, + { + "name": "collar", + "legs": [ + { + "kind": "stock", + "quantity": 100, + "premium": 100.0 + }, + { + "kind": "put", + "quantity": 1, + "premium": 3.0, + "strike": 95, + "volatility": 0.28 + }, + { + "kind": "call", + "quantity": -1, + "premium": 2.5, + "strike": 110, + "volatility": 0.23 + } + ], + "expected": { + "net_cost": 10050.0, + "breakevens": [ + 100.5 + ], + "max_profit": 950.0, + "max_loss": -550.0, + "unlimited_profit": false, + "unlimited_loss": false, + "upside_slope": 0.0, + "downside_slope": 0.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -550.0, + -450.0, + -250.0, + -50.0, + 150.0, + 350.0, + 550.0, + 750.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0, + 950.0 + ] + } + }, + { + "name": "call_ratio_spread", + "legs": [ + { + "kind": "call", + "quantity": 1, + "premium": 5.0, + "strike": 100, + "volatility": 0.25 + }, + { + "kind": "call", + "quantity": -2, + "premium": 1.5, + "strike": 110, + "volatility": 0.23 + } + ], + "expected": { + "net_cost": 200.0, + "breakevens": [ + 102.0, + 118.0 + ], + "max_profit": 800.0, + "max_loss": null, + "unlimited_profit": false, + "unlimited_loss": true, + "upside_slope": -100.0, + "downside_slope": 0.0, + "underlying_prices": [ + 60.0, + 62.0, + 64.0, + 66.0, + 68.0, + 70.0, + 72.0, + 74.0, + 76.0, + 78.0, + 80.0, + 82.0, + 84.0, + 86.0, + 88.0, + 90.0, + 92.0, + 94.0, + 96.0, + 98.0, + 100.0, + 102.0, + 104.0, + 106.0, + 108.0, + 110.0, + 112.0, + 114.0, + 116.0, + 118.0, + 120.0, + 122.0, + 124.0, + 126.0, + 128.0, + 130.0, + 132.0, + 134.0, + 136.0, + 138.0, + 140.0 + ], + "pnl_at_expiry": [ + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + -200.0, + 0.0, + 200.0, + 400.0, + 600.0, + 800.0, + 600.0, + 400.0, + 200.0, + 0.0, + -200.0, + -400.0, + -600.0, + -800.0, + -1000.0, + -1200.0, + -1400.0, + -1600.0, + -1800.0, + -2000.0, + -2200.0 + ] + } + } + ] +} diff --git a/api/tests/generate_payoff_golden.py b/api/tests/generate_payoff_golden.py new file mode 100644 index 0000000..bb4f7bc --- /dev/null +++ b/api/tests/generate_payoff_golden.py @@ -0,0 +1,147 @@ +"""Regenerate `tests/fixtures/payoff_golden.json`. + + python tests/generate_payoff_golden.py + +The golden file is read by **both** test suites -- pytest via +`tests/test_payoff.py` and vitest via `web/lib/__tests__/payoff.test.ts`. That is +the point of it: the TypeScript client-side preview and the Python engine are +two implementations of the same maths, and a shared golden file means they +cannot drift apart without a test going red. + +Regenerating is a deliberate act with a visible diff. If a change here alters +existing numbers, that is a change in behaviour and the diff should be read +rather than committed on autopilot. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from options_tool.analytics.payoff import Leg, LegKind, build_payoff_curve + +SPOT, RATE, TIME_TO_EXPIRY, DIVIDEND_YIELD = 100.0, 0.05, 0.25, 0.0 +PRICE_RANGE = (60.0, 140.0) +POINTS = 41 + +OUTPUT = Path(__file__).parent / "fixtures" / "payoff_golden.json" + +# Every structure a user of this tool is likely to build, including the two that +# a naive implementation gets wrong: a long put (large but finite profit, because +# the underlying cannot go below zero) and a ratio spread (two breakevens, and +# unlimited loss despite being opened for a debit). +STRUCTURES: dict[str, list[dict[str, object]]] = { + "long_call": [ + {"kind": "call", "quantity": 1, "premium": 5.00, "strike": 100, "volatility": 0.25} + ], + "short_call_naked": [ + {"kind": "call", "quantity": -1, "premium": 5.00, "strike": 100, "volatility": 0.25} + ], + "long_put": [ + {"kind": "put", "quantity": 1, "premium": 4.00, "strike": 100, "volatility": 0.25} + ], + "short_put": [ + {"kind": "put", "quantity": -1, "premium": 4.00, "strike": 100, "volatility": 0.25} + ], + "bull_call_spread": [ + {"kind": "call", "quantity": 1, "premium": 5.00, "strike": 100, "volatility": 0.25}, + {"kind": "call", "quantity": -1, "premium": 1.50, "strike": 110, "volatility": 0.23}, + ], + "bear_put_spread": [ + {"kind": "put", "quantity": 1, "premium": 4.00, "strike": 100, "volatility": 0.25}, + {"kind": "put", "quantity": -1, "premium": 1.20, "strike": 90, "volatility": 0.28}, + ], + "iron_condor": [ + {"kind": "put", "quantity": 1, "premium": 0.50, "strike": 90, "volatility": 0.30}, + {"kind": "put", "quantity": -1, "premium": 1.50, "strike": 95, "volatility": 0.27}, + {"kind": "call", "quantity": -1, "premium": 1.60, "strike": 105, "volatility": 0.24}, + {"kind": "call", "quantity": 1, "premium": 0.60, "strike": 110, "volatility": 0.23}, + ], + "long_straddle": [ + {"kind": "call", "quantity": 1, "premium": 5.00, "strike": 100, "volatility": 0.25}, + {"kind": "put", "quantity": 1, "premium": 4.50, "strike": 100, "volatility": 0.25}, + ], + "covered_call": [ + {"kind": "stock", "quantity": 100, "premium": 100.0}, + {"kind": "call", "quantity": -1, "premium": 3.00, "strike": 105, "volatility": 0.24}, + ], + "collar": [ + {"kind": "stock", "quantity": 100, "premium": 100.0}, + {"kind": "put", "quantity": 1, "premium": 3.00, "strike": 95, "volatility": 0.28}, + {"kind": "call", "quantity": -1, "premium": 2.50, "strike": 110, "volatility": 0.23}, + ], + "call_ratio_spread": [ + {"kind": "call", "quantity": 1, "premium": 5.00, "strike": 100, "volatility": 0.25}, + {"kind": "call", "quantity": -2, "premium": 1.50, "strike": 110, "volatility": 0.23}, + ], +} + + +def to_legs(raw: list[dict[str, object]]) -> list[Leg]: + return [ + Leg( + kind=LegKind(str(item["kind"])), + quantity=int(item["quantity"]), # type: ignore[arg-type] + premium=float(item["premium"]), # type: ignore[arg-type] + strike=float(item["strike"]) if item.get("strike") is not None else None, # type: ignore[arg-type] + volatility=float(item["volatility"]) if item.get("volatility") is not None else None, # type: ignore[arg-type] + ) + for item in raw + ] + + +def main() -> None: + cases = [] + for name, raw_legs in STRUCTURES.items(): + curve = build_payoff_curve( + to_legs(raw_legs), SPOT, TIME_TO_EXPIRY, RATE, DIVIDEND_YIELD, PRICE_RANGE, POINTS + ) + cases.append( + { + "name": name, + "legs": raw_legs, + "expected": { + "net_cost": curve.net_cost, + "breakevens": curve.breakevens, + "max_profit": curve.max_profit, + "max_loss": curve.max_loss, + "unlimited_profit": curve.unlimited_profit, + "unlimited_loss": curve.unlimited_loss, + "upside_slope": curve.upside_slope, + "downside_slope": curve.downside_slope, + "underlying_prices": curve.underlying_prices, + "pnl_at_expiry": curve.pnl_at_expiry, + }, + } + ) + + OUTPUT.write_text( + json.dumps( + { + "note": ( + "Golden payoff values. Read by BOTH the Python suite " + "(tests/test_payoff.py) and the TypeScript suite " + "(web/lib/__tests__/payoff.test.ts) so the two implementations " + "cannot drift apart unnoticed. Regenerate with " + "tests/generate_payoff_golden.py." + ), + "parameters": { + "spot": SPOT, + "risk_free_rate": RATE, + "time_to_expiry": TIME_TO_EXPIRY, + "dividend_yield": DIVIDEND_YIELD, + "price_range": list(PRICE_RANGE), + "points": POINTS, + "option_multiplier": 100, + }, + "cases": cases, + }, + indent=1, + ) + + "\n" + ) + print(f"wrote {OUTPUT} with {len(cases)} structures") + + +if __name__ == "__main__": + main() diff --git a/api/tests/test_api.py b/api/tests/test_api.py new file mode 100644 index 0000000..0391cc1 --- /dev/null +++ b/api/tests/test_api.py @@ -0,0 +1,332 @@ +"""API contract tests. + +Run against the real app with the provider and database swapped for test +doubles. Half of these assert the error contract: SPEC.md M3 requires that +errors are typed and useful, never a bare 500. +""" + +from __future__ import annotations + +from datetime import date + +import pytest +from fastapi.testclient import TestClient + + +class TestHealth: + def test_reports_the_provider_and_database(self, api_client: TestClient) -> None: + body = api_client.get("/health").json() + assert body["status"] == "ok" + assert body["provider"] == "fixture" + assert body["database_reachable"] is True + + +class TestWatchlist: + def test_starts_empty(self, api_client: TestClient) -> None: + assert api_client.get("/watchlist").json()["items"] == [] + + def test_add_then_list(self, api_client: TestClient) -> None: + created = api_client.post("/watchlist", json={"symbol": "aapl", "note": "core"}) + assert created.status_code == 201 + assert created.json()["symbol"] == "AAPL" + + items = api_client.get("/watchlist").json()["items"] + assert [i["symbol"] for i in items] == ["AAPL"] + assert items[0]["stored_days"] == 0 + assert items[0]["note"] == "core" + + def test_delete_is_a_soft_delete(self, api_client: TestClient) -> None: + api_client.post("/watchlist", json={"symbol": "AAPL"}) + assert api_client.delete("/watchlist/AAPL").status_code == 204 + assert api_client.get("/watchlist").json()["items"] == [] + + inactive = api_client.get("/watchlist", params={"include_inactive": True}).json() + assert [i["symbol"] for i in inactive["items"]] == ["AAPL"] + + def test_deleting_an_untracked_symbol_is_a_typed_404(self, api_client: TestClient) -> None: + response = api_client.delete("/watchlist/ZZZZ") + assert response.status_code == 404 + assert response.json()["code"] == "not_on_watchlist" + + def test_an_empty_symbol_is_rejected_by_validation(self, api_client: TestClient) -> None: + assert api_client.post("/watchlist", json={"symbol": ""}).status_code == 422 + + +class TestQuoteAndChain: + def test_quote(self, api_client: TestClient) -> None: + body = api_client.get("/quote/AAPL").json() + assert body["ticker"] == "AAPL" + assert body["price"] > 0 + + def test_chain_defaults_to_the_nearest_expiry(self, api_client: TestClient) -> None: + body = api_client.get("/chain/AAPL").json() + assert body["expiry"] == min(body["available_expiries"]) + + def test_chain_carries_the_pricing_assumptions(self, api_client: TestClient) -> None: + """Without these the returned IV cannot be interpreted.""" + body = api_client.get("/chain/AAPL").json() + assert body["risk_free_rate"] == pytest.approx(0.04) + assert body["dividend_yield"] == pytest.approx(0.0) + assert body["time_to_expiry"] > 0 + + def test_contracts_carry_our_own_greeks(self, api_client: TestClient) -> None: + body = api_client.get("/chain/AAPL", params={"expiry": "2027-01-15"}).json() + solved = [c for c in body["contracts"] if c["iv"] is not None] + assert solved + for contract in solved[:20]: + assert contract["delta"] is not None + assert contract["vega"] is not None + assert contract["iv_status"] == "ok" + + def test_unsolved_contracts_are_null_with_a_reason_not_zero( + self, api_client: TestClient + ) -> None: + """The central honesty guarantee, asserted at the HTTP boundary.""" + body = api_client.get("/chain/AAPL", params={"expiry": "2027-01-15"}).json() + unsolved = [c for c in body["contracts"] if c["iv"] is None] + assert unsolved, "the capture contains stale deep-ITM quotes" + for contract in unsolved: + assert contract["iv"] is None, "must be null, never 0.0" + assert contract["iv_status"] not in ("", "ok") + assert contract["delta"] is None + + def test_right_filter(self, api_client: TestClient) -> None: + body = api_client.get("/chain/AAPL", params={"right": "put"}).json() + assert {c["right"] for c in body["contracts"]} == {"put"} + + def test_near_the_money_filter(self, api_client: TestClient) -> None: + body = api_client.get("/chain/AAPL", params={"near_the_money": 5}).json() + assert body["contracts"] + for contract in body["contracts"]: + assert abs(contract["moneyness"] - 1.0) <= 0.05 + + def test_summary_reports_the_solve_rate(self, api_client: TestClient) -> None: + summary = api_client.get("/chain/AAPL").json()["summary"] + assert summary["contracts"] > 0 + assert 0.0 < summary["solve_rate"] <= 1.0 + assert summary["solved"] + sum(summary["unsolved_reasons"].values()) == summary["contracts"] + + def test_disclaimer_is_on_the_response(self, api_client: TestClient) -> None: + assert "Not investment advice" in api_client.get("/chain/AAPL").json()["disclaimer"] + + +class TestErrorContract: + def test_unknown_ticker_is_404_not_500(self, api_client: TestClient) -> None: + response = api_client.get("/chain/NOSUCHTICKER") + assert response.status_code == 404 + assert response.json()["code"] == "unknown_ticker" + + def test_unlisted_expiry_is_404_with_the_alternatives(self, api_client: TestClient) -> None: + """A client should be able to correct itself in one round trip.""" + response = api_client.get("/chain/AAPL", params={"expiry": "1999-01-01"}) + assert response.status_code == 404 + body = response.json() + assert body["code"] == "expiry_not_found" + assert body["detail"]["available_expiries"] + + def test_every_error_body_has_the_same_shape(self, api_client: TestClient) -> None: + for response in ( + api_client.get("/chain/NOSUCHTICKER"), + api_client.get("/chain/AAPL", params={"expiry": "1999-01-01"}), + api_client.delete("/watchlist/ZZZZ"), + ): + body = response.json() + assert set(body) == {"code", "message", "detail"} + assert body["message"] + + def test_bad_query_parameters_are_422_not_500(self, api_client: TestClient) -> None: + assert api_client.get("/chain/AAPL", params={"right": "sideways"}).status_code == 422 + assert api_client.get("/chain/AAPL", params={"near_the_money": -5}).status_code == 422 + + +class TestIVRank: + def test_returns_200_with_null_rather_than_erroring(self, api_client: TestClient) -> None: + """Insufficient history is the correct answer, not a failure.""" + response = api_client.get("/ivrank/AAPL") + assert response.status_code == 200 + body = response.json() + assert body["rank"] is None + assert body["reason"] + + def test_reason_is_renderable(self, api_client: TestClient) -> None: + api_client.post("/snapshot/AAPL") + body = api_client.get("/ivrank/AAPL").json() + assert body["reason"] == "insufficient history (1/20 days)" + assert body["days_available"] == 1 + assert body["days_required"] == 20 + assert body["current_iv"] is not None, "today's vol is known even if its rank is not" + + +class TestSnapshot: + def test_creates_then_refreshes(self, api_client: TestClient) -> None: + first = api_client.post("/snapshot/AAPL").json() + assert first["created"] is True + assert first["contracts_written"] > 0 + + second = api_client.post("/snapshot/AAPL").json() + assert second["created"] is False + assert second["contracts_written"] == first["contracts_written"] + + def test_records_the_solve_rate_and_atm_vol(self, api_client: TestClient) -> None: + body = api_client.post("/snapshot/AAPL").json() + assert 0 < body["solve_rate"] <= 1 + assert body["atm_iv_30d"] is not None + assert body["expiries"] + + def test_snapshotting_registers_the_ticker(self, api_client: TestClient) -> None: + api_client.post("/snapshot/SPY") + items = api_client.get("/watchlist").json()["items"] + assert [i["symbol"] for i in items] == ["SPY"] + assert items[0]["stored_days"] == 1 + + +class TestPayoff: + BULL_SPREAD = { + "spot": 100.0, + "time_to_expiry": 0.25, + "points": 51, + "legs": [ + {"kind": "call", "quantity": 1, "premium": 5.0, "strike": 100, "volatility": 0.25}, + {"kind": "call", "quantity": -1, "premium": 1.5, "strike": 110, "volatility": 0.23}, + ], + } + + def test_returns_both_curves(self, api_client: TestClient) -> None: + body = api_client.post("/payoff", json=self.BULL_SPREAD).json() + assert len(body["underlying_prices"]) == 51 + assert len(body["pnl_at_expiry"]) == 51 + assert len(body["pnl_today"]) == 51 + assert body["today_unavailable_reason"] is None + + def test_reports_the_structure(self, api_client: TestClient) -> None: + body = api_client.post("/payoff", json=self.BULL_SPREAD).json() + assert body["net_cost"] == pytest.approx(350.0) + assert body["max_profit"] == pytest.approx(650.0) + assert body["max_loss"] == pytest.approx(-350.0) + assert body["breakevens"] == pytest.approx([103.5]) + assert body["unlimited_profit"] is False + + def test_unlimited_loss_is_null_not_a_big_number(self, api_client: TestClient) -> None: + body = api_client.post( + "/payoff", + json={ + "spot": 100.0, + "time_to_expiry": 0.25, + "legs": [ + { + "kind": "call", + "quantity": -1, + "premium": 5.0, + "strike": 100, + "volatility": 0.25, + } + ], + }, + ).json() + assert body["max_loss"] is None + assert body["unlimited_loss"] is True + + def test_volatility_is_solved_from_the_chain_when_omitted(self, api_client: TestClient) -> None: + body = api_client.post( + "/payoff", + json={ + "ticker": "AAPL", + "expiry": "2027-01-15", + "points": 21, + "legs": [{"kind": "call", "quantity": 1, "premium": 19.0, "strike": 340}], + }, + ).json() + assert body["pnl_today"] is not None, "the service should have found a vol" + assert body["spot"] > 0 + + def test_missing_volatility_omits_the_today_curve_with_a_reason( + self, api_client: TestClient + ) -> None: + body = api_client.post( + "/payoff", + json={ + "spot": 100.0, + "time_to_expiry": 0.25, + "legs": [{"kind": "call", "quantity": 1, "premium": 5.0, "strike": 100}], + }, + ).json() + assert body["pnl_today"] is None + assert "implied volatility" in body["today_unavailable_reason"] + assert body["pnl_at_expiry"], "the expiry curve needs no volatility" + + def test_no_spot_and_no_ticker_is_a_typed_400(self, api_client: TestClient) -> None: + response = api_client.post( + "/payoff", + json={"legs": [{"kind": "call", "quantity": 1, "premium": 5.0, "strike": 100}]}, + ) + assert response.status_code == 400 + assert response.json()["code"] == "spot_required" + + def test_zero_quantity_is_422(self, api_client: TestClient) -> None: + response = api_client.post( + "/payoff", + json={ + "spot": 100.0, + "legs": [{"kind": "call", "quantity": 0, "premium": 5.0, "strike": 100}], + }, + ) + assert response.status_code == 422 + + def test_a_stock_leg_with_a_strike_is_a_typed_400(self, api_client: TestClient) -> None: + response = api_client.post( + "/payoff", + json={ + "spot": 100.0, + "legs": [{"kind": "stock", "quantity": 100, "premium": 100.0, "strike": 95}], + }, + ) + assert response.status_code == 400 + assert response.json()["code"] == "invalid_input" + + def test_empty_leg_list_is_422(self, api_client: TestClient) -> None: + assert api_client.post("/payoff", json={"spot": 100.0, "legs": []}).status_code == 422 + + +class TestOpenAPI: + def test_docs_are_browsable(self, api_client: TestClient) -> None: + """SPEC.md M3 done-when: /docs is browsable.""" + assert api_client.get("/docs").status_code == 200 + assert api_client.get("/redoc").status_code == 200 + + def test_every_endpoint_is_documented(self, api_client: TestClient) -> None: + paths = api_client.get("/openapi.json").json()["paths"] + assert { + "/health", + "/watchlist", + "/watchlist/{ticker}", + "/quote/{ticker}", + "/chain/{ticker}", + "/ivrank/{ticker}", + "/snapshot/{ticker}", + "/payoff", + } <= set(paths) + + def test_nullable_analytics_are_declared_nullable(self, api_client: TestClient) -> None: + """Clients must be told that iv and rank can be null.""" + schemas = api_client.get("/openapi.json").json()["components"]["schemas"] + assert "anyOf" in schemas["ContractOut"]["properties"]["iv"] + assert "anyOf" in schemas["IVRankResponse"]["properties"]["rank"] + + +class TestEndToEnd: + def test_the_full_loop(self, api_client: TestClient) -> None: + """Add a symbol, snapshot it, read the chain, ask for a rank honestly.""" + assert api_client.post("/watchlist", json={"symbol": "AAPL"}).status_code == 201 + assert api_client.post("/snapshot/AAPL").json()["contracts_written"] > 0 + + chain = api_client.get("/chain/AAPL").json() + assert chain["summary"]["solved"] > 0 + + rank = api_client.get("/ivrank/AAPL").json() + assert rank["rank"] is None + assert "insufficient history" in rank["reason"] + + item = api_client.get("/watchlist").json()["items"][0] + assert item["stored_days"] == 1 + assert item["first_snapshot"] == item["last_snapshot"] + assert date.fromisoformat(item["last_snapshot"]) diff --git a/api/tests/test_atm_iv.py b/api/tests/test_atm_iv.py new file mode 100644 index 0000000..ed26e65 --- /dev/null +++ b/api/tests/test_atm_iv.py @@ -0,0 +1,173 @@ +"""Reducing a day's chains to the single number IV rank ranks.""" + +from __future__ import annotations + +import pandas as pd +import pytest + +from options_tool.analytics.atm_iv import ( + CONSTANT_MATURITY_DAYS, + atm_iv_for_expiry, + constant_maturity_atm_iv, +) +from options_tool.analytics.chain import build_chain_frame +from options_tool.providers.fixture_provider import FixtureProvider + + +class TestConstantMaturityInterpolation: + def test_exact_maturity_needs_no_interpolation(self) -> None: + assert constant_maturity_atm_iv({30 / 365: 0.25}) == pytest.approx(0.25) + + def test_interpolates_in_variance_not_volatility(self) -> None: + """Total variance is additive in time; volatility is not. + + Interpolating volatility linearly would give 0.25 here. The correct + variance-weighted answer is higher, and the difference is the whole + reason this function exists. + """ + result = constant_maturity_atm_iv({20 / 365: 0.20, 60 / 365: 0.30}) + assert result is not None + + t_lo, t_hi, target = 20 / 365, 60 / 365, 30 / 365 + weight = (target - t_lo) / (t_hi - t_lo) + variance = 0.20**2 * t_lo + weight * (0.30**2 * t_hi - 0.20**2 * t_lo) + assert result == pytest.approx((variance / target) ** 0.5) + + naive_linear = 0.20 + weight * (0.30 - 0.20) + assert result != pytest.approx(naive_linear) + assert result > naive_linear + + def test_flat_term_structure_returns_the_flat_level(self) -> None: + assert constant_maturity_atm_iv({10 / 365: 0.3, 90 / 365: 0.3}) == pytest.approx( + 0.3, rel=1e-9 + ) + + def test_result_lies_between_the_bracketing_vols(self) -> None: + result = constant_maturity_atm_iv({7 / 365: 0.18, 90 / 365: 0.42}) + assert result is not None + assert 0.18 < result < 0.42 + + +class TestRefusalToExtrapolate: + """None, not a guess, whenever the horizon cannot be bracketed.""" + + def test_all_expiries_too_long(self) -> None: + assert constant_maturity_atm_iv({1.0: 0.30, 2.0: 0.32}) is None + + def test_all_expiries_too_short(self) -> None: + assert constant_maturity_atm_iv({0.01: 0.30, 0.02: 0.31}) is None + + def test_no_expiries_at_all(self) -> None: + assert constant_maturity_atm_iv({}) is None + + def test_non_positive_vols_are_discarded(self) -> None: + assert constant_maturity_atm_iv({20 / 365: 0.0, 60 / 365: 0.0}) is None + + def test_a_single_bracketing_side_is_not_enough(self) -> None: + assert constant_maturity_atm_iv({20 / 365: 0.25}) is None + + def test_why_it_matters(self) -> None: + """An invented early value would become the window minimum for a year.""" + assert constant_maturity_atm_iv({0.5: 0.15, 1.0: 0.20}) is None + + +class TestAtmIVForExpiry: + @staticmethod + def synthetic(strikes_and_ivs: dict[float, float], spot: float = 100.0) -> pd.DataFrame: + return pd.DataFrame( + [ + { + "right": "call", + "strike": strike, + "iv": iv, + "spot": spot, + "moneyness": spot / strike, + } + for strike, iv in strikes_and_ivs.items() + ] + ) + + def test_interpolates_between_the_strikes_bracketing_spot(self) -> None: + frame = self.synthetic({95.0: 0.20, 105.0: 0.30}) + # spot 100 sits halfway between 95 and 105 + assert atm_iv_for_expiry(frame) == pytest.approx(0.25) + + def test_exact_strike_at_spot_is_used_directly(self) -> None: + frame = self.synthetic({95.0: 0.20, 100.0: 0.24, 105.0: 0.30}) + assert atm_iv_for_expiry(frame) == pytest.approx(0.24) + + def test_calls_and_puts_are_averaged(self) -> None: + frame = pd.DataFrame( + [ + {"right": "call", "strike": 100.0, "iv": 0.20, "spot": 100.0, "moneyness": 1.0}, + {"right": "put", "strike": 100.0, "iv": 0.30, "spot": 100.0, "moneyness": 1.0}, + ] + ) + assert atm_iv_for_expiry(frame) == pytest.approx(0.25) + + def test_unsolved_strikes_are_ignored(self) -> None: + frame = self.synthetic({95.0: 0.20, 105.0: 0.30}) + frame.loc[len(frame)] = { + "right": "call", + "strike": 100.0, + "iv": None, + "spot": 100.0, + "moneyness": 1.0, + } + assert atm_iv_for_expiry(frame) == pytest.approx(0.25) + + def test_no_solved_strikes_returns_none(self) -> None: + frame = self.synthetic({95.0: 0.20}) + frame["iv"] = None + assert atm_iv_for_expiry(frame) is None + + def test_strikes_far_from_spot_are_excluded(self) -> None: + """Beyond the band the skew is too steep for interpolation to mean much.""" + assert atm_iv_for_expiry(self.synthetic({10.0: 1.5, 500.0: 0.9})) is None + + def test_empty_frame(self) -> None: + frame = self.synthetic({95.0: 0.2}).iloc[0:0] + assert atm_iv_for_expiry(frame) is None + + +class TestAgainstRealCapturedChains: + def test_every_fixture_expiry_yields_a_plausible_atm_vol( + self, provider: FixtureProvider + ) -> None: + for symbol in ("AAPL", "SPY"): + for expiry in provider.get_expiries(symbol): + chain = provider.get_chain(symbol, expiry) + frame = build_chain_frame(chain, 0.04, 0.0, chain.as_of) + atm = atm_iv_for_expiry(frame) + assert atm is not None, f"{symbol} {expiry}" + assert 0.02 < atm < 2.0, f"{symbol} {expiry} -> {atm}" + + def test_term_structure_is_upward_sloping_for_the_captured_data( + self, provider: FixtureProvider + ) -> None: + """A real property of the capture: longer-dated vol was higher.""" + vols = [] + for expiry in provider.get_expiries("SPY"): + chain = provider.get_chain("SPY", expiry) + frame = build_chain_frame(chain, 0.04, 0.0, chain.as_of) + vols.append(atm_iv_for_expiry(frame)) + assert all(v is not None for v in vols) + assert vols == sorted(vols) # type: ignore[type-var] + + def test_thirty_day_point_sits_inside_the_term_structure( + self, provider: FixtureProvider + ) -> None: + by_maturity = {} + for expiry in provider.get_expiries("AAPL"): + chain = provider.get_chain("AAPL", expiry) + frame = build_chain_frame(chain, 0.04, 0.0, chain.as_of) + atm = atm_iv_for_expiry(frame) + if atm is not None: + by_maturity[float(frame.attrs["time_to_expiry"])] = atm + + constant_maturity = constant_maturity_atm_iv(by_maturity) + assert constant_maturity is not None + assert min(by_maturity.values()) <= constant_maturity <= max(by_maturity.values()) + + def test_the_horizon_is_thirty_days(self) -> None: + assert CONSTANT_MATURITY_DAYS == 30.0 diff --git a/api/tests/test_black_scholes.py b/api/tests/test_black_scholes.py new file mode 100644 index 0000000..261d347 --- /dev/null +++ b/api/tests/test_black_scholes.py @@ -0,0 +1,255 @@ +"""Black-Scholes pricing and greeks. + +Two independent kinds of check, because they fail differently: + +- **published reference values**, which catch a formula transcribed wrongly +- **finite differences of our own `price`**, which catch a greek that does not + actually differentiate the pricer it ships with + +A formula can match a textbook and still be wired to the wrong pricer; a greek +can agree with finite differences and still be the wrong formula. Both together +leave very little room. +""" + +from __future__ import annotations + +import math + +import pytest + +from options_tool.analytics.black_scholes import ( + DAYS_PER_YEAR, + Greeks, + Right, + greeks, + norm_cdf, + norm_pdf, + price, +) +from tests.conftest import REF_RATE, REF_SIGMA, REF_SPOT, REF_STRIKE, REF_YEARS + + +class TestNormal: + def test_cdf_is_symmetric_about_zero(self) -> None: + assert norm_cdf(0.0) == pytest.approx(0.5) + for x in (0.25, 1.0, 2.5): + assert norm_cdf(-x) == pytest.approx(1.0 - norm_cdf(x)) + + def test_cdf_matches_known_quantiles(self) -> None: + assert norm_cdf(1.959963985) == pytest.approx(0.975, abs=1e-9) + assert norm_cdf(-1.644853627) == pytest.approx(0.05, abs=1e-9) + + def test_pdf_is_the_derivative_of_the_cdf(self) -> None: + h = 1e-6 + for x in (-1.5, 0.0, 0.75): + numeric = (norm_cdf(x + h) - norm_cdf(x - h)) / (2 * h) + assert numeric == pytest.approx(norm_pdf(x), rel=1e-6) + + +class TestReferenceValues: + """S=100, K=100, T=1, r=5%, sigma=20%, q=0 -- the standard worked example.""" + + def test_call_price(self) -> None: + assert price( + REF_SPOT, REF_STRIKE, REF_YEARS, REF_RATE, REF_SIGMA, right=Right.CALL + ) == pytest.approx(10.450583572185565, abs=1e-10) + + def test_put_price(self) -> None: + assert price( + REF_SPOT, REF_STRIKE, REF_YEARS, REF_RATE, REF_SIGMA, right=Right.PUT + ) == pytest.approx(5.573526022256971, abs=1e-10) + + @pytest.mark.parametrize( + ("field", "expected"), + [ + ("delta", 0.6368306511756191), + ("gamma", 0.018762017345846895), + ("vega", 37.52403469169379), + ("theta", -6.414027546438197), + ("rho", 53.232481545376345), + ], + ) + def test_call_greeks(self, field: str, expected: float) -> None: + computed = getattr(greeks(REF_SPOT, REF_STRIKE, REF_YEARS, REF_RATE, REF_SIGMA), field) + assert computed == pytest.approx(expected, rel=1e-12) + + def test_put_delta(self) -> None: + put = greeks(REF_SPOT, REF_STRIKE, REF_YEARS, REF_RATE, REF_SIGMA, right=Right.PUT) + assert put.delta == pytest.approx(-0.3631693488243809, rel=1e-12) + + +class TestPutCallParity: + """C - P = S*e^-qT - K*e^-rT. Holds for every input, so it catches sign errors.""" + + @pytest.mark.parametrize("S", [50.0, 95.0, 100.0, 140.0]) + @pytest.mark.parametrize("T", [0.02, 0.5, 3.0]) + @pytest.mark.parametrize("q", [0.0, 0.03]) + def test_parity_holds(self, S: float, T: float, q: float) -> None: + call = price(S, 100.0, T, 0.05, 0.3, q, Right.CALL) + put = price(S, 100.0, T, 0.05, 0.3, q, Right.PUT) + expected = S * math.exp(-q * T) - 100.0 * math.exp(-0.05 * T) + assert call - put == pytest.approx(expected, abs=1e-10) + + @pytest.mark.parametrize("q", [0.0, 0.02]) + def test_delta_parity(self, q: float) -> None: + """delta_call - delta_put = e^-qT.""" + call = greeks(100.0, 105.0, 0.75, 0.04, 0.25, q, Right.CALL) + put = greeks(100.0, 105.0, 0.75, 0.04, 0.25, q, Right.PUT) + assert call.delta - put.delta == pytest.approx(math.exp(-q * 0.75), abs=1e-12) + + def test_gamma_and_vega_are_right_agnostic(self) -> None: + call = greeks(107.0, 100.0, 0.4, 0.03, 0.35, 0.01, Right.CALL) + put = greeks(107.0, 100.0, 0.4, 0.03, 0.35, 0.01, Right.PUT) + assert call.gamma == pytest.approx(put.gamma, rel=1e-12) + assert call.vega == pytest.approx(put.vega, rel=1e-12) + + +class TestGreeksAgainstFiniteDifferences: + """Every greek must differentiate the pricer it ships with.""" + + CASES = [ + (100.0, 100.0, 1.0, 0.05, 0.20, 0.0), + (100.0, 120.0, 0.5, 0.03, 0.45, 0.02), + (80.0, 100.0, 2.0, 0.01, 0.15, 0.0), + (150.0, 100.0, 0.25, 0.05, 0.60, 0.03), + ] + + @pytest.mark.parametrize(("S", "K", "T", "r", "sigma", "q"), CASES) + @pytest.mark.parametrize("right", [Right.CALL, Right.PUT]) + def test_all_five( + self, S: float, K: float, T: float, r: float, sigma: float, q: float, right: Right + ) -> None: + analytic = greeks(S, K, T, r, sigma, q, right) + h = 1e-5 + + def p(**overrides: float) -> float: + args = {"S": S, "K": K, "T": T, "r": r, "sigma": sigma, "q": q} + args.update(overrides) + return price(**args, right=right) # type: ignore[arg-type] + + assert analytic.delta == pytest.approx((p(S=S + h) - p(S=S - h)) / (2 * h), rel=1e-5) + assert analytic.vega == pytest.approx( + (p(sigma=sigma + h) - p(sigma=sigma - h)) / (2 * h), rel=1e-5 + ) + # Theta is decay per unit of *elapsed* time, hence the sign flip. + assert analytic.theta == pytest.approx(-(p(T=T + h) - p(T=T - h)) / (2 * h), rel=1e-5) + assert analytic.rho == pytest.approx((p(r=r + h) - p(r=r - h)) / (2 * h), rel=1e-5) + + @pytest.mark.parametrize(("S", "K", "T", "r", "sigma", "q"), CASES) + @pytest.mark.parametrize("right", [Right.CALL, Right.PUT]) + def test_gamma_differentiates_delta( + self, S: float, K: float, T: float, r: float, sigma: float, q: float, right: Right + ) -> None: + """Gamma is checked against a first difference of delta, not a second + difference of price. + + A second difference divides by h^2, so at h=1e-5 the 1e-16 relative noise + in a price of order 10 becomes an absolute error of order 1e-5 -- roughly + the size of gamma itself. Differentiating the analytic delta (already + verified against price above) is well-conditioned and tests the same + relationship. + """ + h = 1e-4 * S + + def delta_at(spot: float) -> float: + return greeks(spot, K, T, r, sigma, q, right).delta + + numeric = (delta_at(S + h) - delta_at(S - h)) / (2 * h) + assert greeks(S, K, T, r, sigma, q, right).gamma == pytest.approx(numeric, rel=1e-6) + + @pytest.mark.parametrize(("S", "K", "T", "r", "sigma", "q"), CASES) + def test_gamma_also_matches_a_well_scaled_second_difference( + self, S: float, K: float, T: float, r: float, sigma: float, q: float + ) -> None: + """Independent confirmation with a step sized for a second derivative.""" + h = 1e-3 * S + second = ( + price(S + h, K, T, r, sigma, q, Right.CALL) + - 2 * price(S, K, T, r, sigma, q, Right.CALL) + + price(S - h, K, T, r, sigma, q, Right.CALL) + ) / (h * h) + assert greeks(S, K, T, r, sigma, q, Right.CALL).gamma == pytest.approx(second, rel=1e-4) + + +class TestDegenerateBranches: + """Expired or zero-vol contracts are legitimate market states, not errors.""" + + @pytest.mark.parametrize( + ("S", "K", "right", "expected"), + [ + (110.0, 100.0, Right.CALL, 10.0), + (90.0, 100.0, Right.CALL, 0.0), + (90.0, 100.0, Right.PUT, 10.0), + (110.0, 100.0, Right.PUT, 0.0), + ], + ) + def test_expired_is_intrinsic(self, S: float, K: float, right: Right, expected: float) -> None: + assert price(S, K, 0.0, 0.05, 0.2, 0.0, right) == pytest.approx(expected) + + def test_expired_greeks_are_a_step_function(self) -> None: + itm = greeks(110.0, 100.0, 0.0, 0.05, 0.2, 0.0, Right.CALL) + assert itm.delta == pytest.approx(1.0) + assert itm.gamma == 0.0 + assert itm.vega == 0.0 + + otm = greeks(90.0, 100.0, 0.0, 0.05, 0.2, 0.0, Right.CALL) + assert otm == Greeks(0.0, 0.0, 0.0, 0.0, 0.0) + + def test_at_the_money_at_expiry_picks_no_side(self) -> None: + """The derivative does not exist at the kink; 0 is the honest answer.""" + assert greeks(100.0, 100.0, 0.0, 0.05, 0.2, 0.0, Right.CALL).delta == 0.0 + + def test_zero_vol_is_discounted_forward_intrinsic(self) -> None: + S, K, T, r = 100.0, 90.0, 1.0, 0.05 + expected = S - K * math.exp(-r * T) + assert price(S, K, T, r, 0.0, 0.0, Right.CALL) == pytest.approx(expected) + + def test_negative_time_is_treated_as_expired(self) -> None: + assert price(110.0, 100.0, -0.5, 0.05, 0.2, 0.0, Right.CALL) == pytest.approx(10.0) + + def test_deep_out_of_the_money_underflows_to_zero_not_negative(self) -> None: + assert price(100.0, 1000.0, 0.01, 0.05, 0.2, 0.0, Right.CALL) >= 0.0 + + +class TestInvalidInput: + """Wrong inputs raise; degenerate ones do not. The distinction is deliberate.""" + + @pytest.mark.parametrize(("S", "K"), [(0.0, 100.0), (-1.0, 100.0), (100.0, 0.0), (100.0, -5.0)]) + def test_non_positive_spot_or_strike_raises(self, S: float, K: float) -> None: + with pytest.raises(ValueError, match="positive"): + price(S, K, 1.0, 0.05, 0.2) + + @pytest.mark.parametrize("bad", [float("nan"), float("inf")]) + def test_non_finite_raises(self, bad: float) -> None: + with pytest.raises(ValueError): + price(100.0, 100.0, bad, 0.05, 0.2) + with pytest.raises(ValueError): + price(100.0, 100.0, 1.0, 0.05, bad) + + +class TestRight: + @pytest.mark.parametrize("value", ["c", "C", "call", "CALL", "Calls", Right.CALL]) + def test_parses_call_spellings(self, value: str | Right) -> None: + assert Right.parse(value) is Right.CALL + + @pytest.mark.parametrize("value", ["p", "PUT", "puts", Right.PUT]) + def test_parses_put_spellings(self, value: str | Right) -> None: + assert Right.parse(value) is Right.PUT + + @pytest.mark.parametrize("value", ["", "banana", "stock", "1"]) + def test_rejects_everything_else(self, value: str) -> None: + with pytest.raises(ValueError, match="not an option right"): + Right.parse(value) + + +class TestGreekScaling: + def test_display_scalings(self) -> None: + g = greeks(REF_SPOT, REF_STRIKE, REF_YEARS, REF_RATE, REF_SIGMA) + assert g.vega_per_point == pytest.approx(g.vega / 100.0) + assert g.rho_per_point == pytest.approx(g.rho / 100.0) + assert g.theta_per_day == pytest.approx(g.theta / DAYS_PER_YEAR) + + def test_theta_per_day_is_a_plausible_magnitude(self) -> None: + """A 1-year ATM option loses cents, not dollars, per day.""" + theta_per_day = greeks(REF_SPOT, REF_STRIKE, REF_YEARS, REF_RATE, REF_SIGMA).theta_per_day + assert -0.05 < theta_per_day < 0 diff --git a/api/tests/test_capture.py b/api/tests/test_capture.py new file mode 100644 index 0000000..a9e771d --- /dev/null +++ b/api/tests/test_capture.py @@ -0,0 +1,98 @@ +"""Fixture capture. + +The fixture is what makes this suite offline and the demo reliable, so the code +that produces it is tested too -- against the existing fixture as its source, so +no network is involved. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from options_tool.providers.capture import _spread_expiries, capture_fixture +from options_tool.providers.fixture_provider import FixtureProvider + + +class TestSpreadExpiries: + def test_picks_across_the_term_structure_not_just_the_front(self) -> None: + """Three near-identical weeklies would not exercise the LEAPS path.""" + expiries = list(range(20)) # type: ignore[arg-type] + chosen = _spread_expiries(tuple(expiries), 3) # type: ignore[arg-type] + assert chosen[0] == 0 + assert chosen[-1] == 19 + assert chosen[1] not in (0, 19) + + def test_returns_everything_when_there_is_little_to_choose_from(self) -> None: + assert _spread_expiries((1, 2), 5) == [1, 2] # type: ignore[arg-type,comparison-overlap] + + def test_single_expiry_request(self) -> None: + assert _spread_expiries((1, 2, 3), 1) == [1] # type: ignore[arg-type,comparison-overlap] + + def test_no_duplicates(self) -> None: + chosen = _spread_expiries(tuple(range(10)), 4) # type: ignore[arg-type] + assert len(set(chosen)) == len(chosen) + + +class TestCapture: + def test_round_trips_through_the_fixture_provider( + self, provider: FixtureProvider, tmp_path: Path + ) -> None: + target = tmp_path / "nested" / "chains.json" + counts = capture_fixture(provider, ["AAPL"], target, expiries_per_ticker=2) + + assert target.exists() + assert counts["AAPL"] > 0 + + replayed = FixtureProvider(target) + assert len(replayed.get_expiries("AAPL")) == 2 + assert replayed.get_quote("AAPL").price == pytest.approx(provider.get_quote("AAPL").price) + + def test_records_its_own_provenance(self, provider: FixtureProvider, tmp_path: Path) -> None: + """A capture that cannot say where or when it came from is not evidence.""" + target = tmp_path / "chains.json" + capture_fixture(provider, ["AAPL"], target, expiries_per_ticker=1) + + payload = json.loads(target.read_text()) + assert payload["source"] == "fixture" + assert payload["captured_at"] + assert "not represented as live market data" in payload["note"] + + def test_no_vendor_greeks_are_written(self, provider: FixtureProvider, tmp_path: Path) -> None: + target = tmp_path / "chains.json" + capture_fixture(provider, ["AAPL"], target, expiries_per_ticker=1) + + payload = json.loads(target.read_text()) + chains = payload["underlyings"]["AAPL"]["chains"] + for rows in chains.values(): + for row in rows: + assert set(row) == { + "strike", + "right", + "bid", + "ask", + "last", + "volume", + "open_interest", + "contract_symbol", + } + + def test_output_is_deterministic(self, provider: FixtureProvider, tmp_path: Path) -> None: + """Keys are sorted so a refresh produces a readable diff, not a reshuffle.""" + first, second = tmp_path / "a.json", tmp_path / "b.json" + capture_fixture(provider, ["AAPL"], first, expiries_per_ticker=1) + capture_fixture(provider, ["AAPL"], second, expiries_per_ticker=1) + + a = json.loads(first.read_text()) + b = json.loads(second.read_text()) + a.pop("captured_at") + b.pop("captured_at") + assert a == b + + def test_multiple_tickers(self, provider: FixtureProvider, tmp_path: Path) -> None: + target = tmp_path / "chains.json" + counts = capture_fixture(provider, ["AAPL", "spy"], target, expiries_per_ticker=1) + assert set(counts) == {"AAPL", "SPY"} + assert set(json.loads(target.read_text())["underlyings"]) == {"AAPL", "SPY"} diff --git a/api/tests/test_chain.py b/api/tests/test_chain.py new file mode 100644 index 0000000..eb9daf2 --- /dev/null +++ b/api/tests/test_chain.py @@ -0,0 +1,268 @@ +"""Chain normalisation: the tidy frame every other layer consumes.""" + +from __future__ import annotations + +from datetime import UTC, date, datetime + +import pandas as pd +import pytest + +from options_tool.analytics.chain import ( + CHAIN_COLUMNS, + build_chain_frame, + market_date, + summarise_chain, + time_to_expiry, +) +from options_tool.providers.base import OptionChain, OptionQuote +from options_tool.providers.fixture_provider import FixtureProvider + +RATE = 0.04 + + +@pytest.fixture +def frame(provider: FixtureProvider) -> pd.DataFrame: + chain = provider.get_chain("AAPL", date(2027, 1, 15)) + return build_chain_frame(chain, RATE, 0.0, chain.as_of) + + +class TestTimeToExpiry: + def test_counts_to_the_afternoon_close_not_midnight(self) -> None: + """Options trade until 16:00 New York, not 00:00 UTC.""" + as_of = datetime(2026, 9, 14, 12, tzinfo=UTC) # 08:00 New York + years = time_to_expiry(date(2026, 9, 14), as_of) + assert years == pytest.approx(8 / 24 / 365, rel=1e-6) + + def test_never_negative(self) -> None: + past = datetime(2027, 1, 1, tzinfo=UTC) + assert time_to_expiry(date(2026, 1, 1), past) == 0.0 + + def test_naive_datetimes_are_treated_as_utc(self) -> None: + aware = datetime(2026, 9, 1, 12, tzinfo=UTC) + naive = datetime(2026, 9, 1, 12) + assert time_to_expiry(date(2026, 12, 18), naive) == pytest.approx( + time_to_expiry(date(2026, 12, 18), aware) + ) + + def test_a_weekend_still_decays(self) -> None: + """Calendar time, not trading time -- stated in the README limitations.""" + friday = datetime(2026, 9, 11, 20, tzinfo=UTC) + monday = datetime(2026, 9, 14, 20, tzinfo=UTC) + assert time_to_expiry(date(2026, 12, 18), friday) > time_to_expiry( + date(2026, 12, 18), monday + ) + + +class TestMarketDate: + @pytest.mark.parametrize( + ("utc_hour", "expected_day"), + [(12, 13), (19, 13), (20, 13), (23, 13), (3, 12)], + ) + def test_uses_new_york_not_utc(self, utc_hour: int, expected_day: int) -> None: + """After 20:00 UTC the UTC date has rolled but the trading day has not.""" + moment = datetime(2026, 9, 13, utc_hour, tzinfo=UTC) + assert market_date(moment) == date(2026, 9, expected_day) + + def test_a_late_afternoon_and_an_evening_run_share_a_date(self) -> None: + """The idempotency case: 15:00 and 20:30 Eastern are one trading day.""" + afternoon = datetime(2026, 9, 13, 19, tzinfo=UTC) # 15:00 ET + evening = datetime(2026, 9, 14, 0, 30, tzinfo=UTC) # 20:30 ET + assert market_date(afternoon) == market_date(evening) + + +class TestFrameShape: + def test_columns_and_order_match_the_contract(self, frame: pd.DataFrame) -> None: + assert list(frame.columns) == list(CHAIN_COLUMNS) + + def test_dtypes_are_enforced(self, frame: pd.DataFrame) -> None: + for column, dtype in CHAIN_COLUMNS.items(): + assert str(frame[column].dtype) == dtype, column + + def test_nullable_integers_keep_missing_volume_missing(self, frame: pd.DataFrame) -> None: + """0 volume and "no reported volume" are different observations.""" + assert str(frame["volume"].dtype) == "Int64" + + def test_one_row_per_contract(self, provider: FixtureProvider) -> None: + chain = provider.get_chain("AAPL", date(2027, 1, 15)) + assert len(build_chain_frame(chain, RATE, 0.0, chain.as_of)) == len(chain.quotes) + + def test_sorted_by_right_then_strike(self, frame: pd.DataFrame) -> None: + for _, side in frame.groupby("right", observed=True): + assert side["strike"].is_monotonic_increasing + + def test_chain_level_facts_live_in_attrs_not_columns(self, frame: pd.DataFrame) -> None: + assert frame.attrs["ticker"] == "AAPL" + assert frame.attrs["expiry"] == date(2027, 1, 15) + assert frame.attrs["risk_free_rate"] == pytest.approx(RATE) + assert frame.attrs["time_to_expiry"] > 0 + + +class TestComputedValues: + def test_greeks_are_present_wherever_iv_solved(self, frame: pd.DataFrame) -> None: + solved = frame[frame["iv"].notna()] + assert not solved.empty + for column in ("delta", "gamma", "vega", "theta", "rho"): + assert solved[column].notna().all(), column + + def test_greeks_are_absent_wherever_iv_did_not(self, frame: pd.DataFrame) -> None: + """No fabricated delta for a contract with no tradeable market.""" + unsolved = frame[frame["iv"].isna()] + if unsolved.empty: + pytest.skip("this expiry solved completely") + for column in ("delta", "gamma", "vega", "theta", "rho"): + assert unsolved[column].isna().all(), column + + def test_unsolved_rows_are_kept_and_labelled(self, frame: pd.DataFrame) -> None: + unsolved = frame[frame["iv"].isna()] + assert (unsolved["iv_status"] != "ok").all() + assert (unsolved["iv_status"] != "").all() + + def test_call_deltas_are_positive_and_put_deltas_negative(self, frame: pd.DataFrame) -> None: + solved = frame[frame["iv"].notna()] + assert (solved.loc[solved["right"] == "call", "delta"] > 0).all() + assert (solved.loc[solved["right"] == "put", "delta"] < 0).all() + + def test_gamma_and_vega_are_never_negative(self, frame: pd.DataFrame) -> None: + solved = frame[frame["iv"].notna()] + assert (solved["gamma"] >= 0).all() + assert (solved["vega"] >= 0).all() + + def test_moneyness_and_in_the_money_agree(self, frame: pd.DataFrame) -> None: + calls = frame[frame["right"] == "call"] + assert (calls["in_the_money"] == (calls["moneyness"] > 1.0)).all() + + def test_call_delta_is_strictly_decreasing_at_constant_volatility(self) -> None: + """The pure maths, isolated from the market. + + Delta is monotone in strike only when every strike shares a volatility. + Priced off one sigma, the ordering must be exact -- any inversion here is + a bug rather than microstructure. + """ + from options_tool.analytics.black_scholes import Right, price + + quotes = tuple( + OptionQuote( + ticker="TEST", + expiry=date(2027, 1, 15), + strike=float(strike), + right="call", + bid=(p := price(100.0, float(strike), 0.34, RATE, 0.25, 0.0, Right.CALL)) * 0.99, + ask=p * 1.01, + ) + for strike in range(75, 140, 5) + ) + chain = OptionChain( + ticker="TEST", + expiry=date(2027, 1, 15), + spot=100.0, + as_of=datetime(2026, 9, 13, 15, tzinfo=UTC), + quotes=quotes, + ) + frame = build_chain_frame(chain, RATE, 0.0, chain.as_of).sort_values("strike") + solved = frame[frame["delta"].notna()] + assert len(solved) == len(frame), "every synthetic strike should solve" + assert solved["delta"].is_monotonic_decreasing + + def test_real_chain_deltas_decrease_across_the_liquid_strikes( + self, frame: pd.DataFrame + ) -> None: + """Strict monotonicity only holds where the market is real. + + Every strike on a live chain carries its own smile volatility, so delta + is not strictly ordered end to end. The inversions are confined to the + extremes -- deep in-the-money strikes quoted three dollars wide, and far + out-of-the-money wings with a zero bid and an implied vol that is mostly + an artefact of the ask. Within the liquid band the ordering is exact. + """ + calls = frame[(frame["right"] == "call") & frame["delta"].notna()].sort_values("strike") + assert calls["delta"].iloc[0] > 0.9, "deepest ITM call should be near delta 1" + assert calls["delta"].iloc[-1] < 0.1, "furthest OTM call should be near delta 0" + + liquid = calls[((calls["moneyness"] - 1.0).abs() <= 0.2) & (calls["price_source"] == "mid")] + assert len(liquid) > 10, "the fixture should contain a liquid band" + assert liquid["delta"].is_monotonic_decreasing + + def test_real_chain_shows_a_volatility_smile(self, frame: pd.DataFrame) -> None: + """Sanity check against real captured data: equity skew slopes down.""" + calls = frame[(frame["right"] == "call") & frame["iv"].notna()] + spot = float(frame.attrs["spot"]) + near = calls[(calls["moneyness"] - 1.0).abs() < 0.1].sort_values("strike") + assert len(near) > 4 + assert near["iv"].iloc[0] > near["iv"].iloc[-1], "expected downward equity skew" + assert spot > 0 + + +class TestPriceSelection: + @staticmethod + def one_quote(**overrides: object) -> pd.DataFrame: + defaults = {"bid": 4.0, "ask": 4.2, "last": 4.1} + defaults.update(overrides) + quote = OptionQuote( + ticker="TEST", + expiry=date(2027, 1, 15), + strike=100.0, + right="call", + **defaults, # type: ignore[arg-type] + ) + chain = OptionChain( + ticker="TEST", + expiry=date(2027, 1, 15), + spot=100.0, + as_of=datetime(2026, 9, 13, 15, tzinfo=UTC), + quotes=(quote,), + ) + return build_chain_frame(chain, RATE, 0.0, chain.as_of) + + def test_two_sided_book_uses_mid(self) -> None: + row = self.one_quote().iloc[0] + assert row["price_source"] == "mid" + assert row["price"] == pytest.approx(4.1) + + def test_one_sided_book_falls_back_to_last_and_says_so(self) -> None: + row = self.one_quote(bid=0.0).iloc[0] + assert row["price_source"] == "last" + assert row["price"] == pytest.approx(4.1) + + def test_crossed_book_falls_back_to_last(self) -> None: + row = self.one_quote(bid=5.0, ask=3.0).iloc[0] + assert row["price_source"] == "last" + + def test_no_quote_at_all_yields_no_iv_and_a_reason(self) -> None: + row = self.one_quote(bid=None, ask=None, last=None).iloc[0] + assert pd.isna(row["iv"]) + assert row["iv_status"] == "no_quote" + assert pd.isna(row["price_source"]) + + def test_spread_is_none_for_a_one_sided_book(self) -> None: + assert pd.isna(self.one_quote(bid=None).iloc[0]["spread"]) + assert self.one_quote().iloc[0]["spread"] == pytest.approx(0.2) + + +class TestSummary: + def test_counts_add_up(self, frame: pd.DataFrame) -> None: + stats = summarise_chain(frame) + assert stats.contracts == len(frame) + assert stats.solved == int(frame["iv"].notna().sum()) + assert stats.solve_rate == pytest.approx(stats.solved / stats.contracts) + + def test_unsolved_reasons_are_tallied(self, frame: pd.DataFrame) -> None: + stats = summarise_chain(frame) + assert sum(stats.unsolved_reasons.values()) == stats.contracts - stats.solved + + def test_atm_iv_is_plausible(self, frame: pd.DataFrame) -> None: + stats = summarise_chain(frame) + assert stats.atm_iv is not None + assert 0.05 < stats.atm_iv < 2.0 + + def test_empty_chain_summarises_without_dividing_by_zero(self) -> None: + chain = OptionChain( + ticker="TEST", + expiry=date(2027, 1, 15), + spot=100.0, + as_of=datetime(2026, 9, 13, tzinfo=UTC), + quotes=(), + ) + stats = summarise_chain(build_chain_frame(chain, RATE)) + assert stats.contracts == 0 + assert stats.solve_rate == 0.0 + assert stats.atm_iv is None diff --git a/api/tests/test_cli.py b/api/tests/test_cli.py new file mode 100644 index 0000000..6a6ee13 --- /dev/null +++ b/api/tests/test_cli.py @@ -0,0 +1,198 @@ +"""CLI behaviour. + +The CLI is how the daily snapshot job runs, so its exit codes matter: a cron +timer that cannot tell success from failure will silently stop building the IV +history and nobody will notice for weeks. +""" + +from __future__ import annotations + +import pytest + +from options_tool.cli import build_parser, main + + +def run(capsys: pytest.CaptureFixture[str], *args: str) -> tuple[int, str, str]: + code = main(["--provider", "fixture", *args]) + captured = capsys.readouterr() + return code, captured.out, captured.err + + +def flat(text: str) -> str: + """Collapse whitespace. + + Rich word-wraps table captions to the terminal width, so a phrase that reads + as one sentence on screen arrives split across lines with padding. Asserting + on the wrapped form would make these tests depend on the column count. + """ + return " ".join(text.split()) + + +class TestParser: + def test_every_command_is_registered(self) -> None: + parser = build_parser() + actions = [a for a in parser._actions if hasattr(a, "choices") and a.choices] + commands = set() + for action in actions: + if isinstance(action.choices, dict): + commands |= set(action.choices) + assert { + "quote", + "expiries", + "chain", + "watchlist", + "snapshot", + "ivrank", + "capture-fixture", + } <= commands + + def test_a_command_is_required(self) -> None: + with pytest.raises(SystemExit): + build_parser().parse_args([]) + + def test_help_exits_cleanly(self) -> None: + with pytest.raises(SystemExit) as info: + build_parser().parse_args(["--help"]) + assert info.value.code == 0 + + +class TestReadOnlyCommands: + def test_quote(self, capsys: pytest.CaptureFixture[str], settings: object) -> None: + code, out, _ = run(capsys, "quote", "AAPL") + assert code == 0 + assert "AAPL" in out + + def test_expiries(self, capsys: pytest.CaptureFixture[str], settings: object) -> None: + code, out, _ = run(capsys, "expiries", "AAPL") + assert code == 0 + assert "3 listed expiries" in out + assert "2027-01-15" in out + + def test_chain_prints_self_computed_greeks( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + code, out, _ = run( + capsys, "chain", "AAPL", "--expiry", "2027-01-15", "--near-the-money", "3" + ) + assert code == 0 + assert "delta" in out and "gamma" in out and "vega" in out + assert "computed locally, not vendor-supplied" in flat(out) + + def test_chain_shows_the_disclaimer( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + _, out, _ = run(capsys, "chain", "AAPL", "--near-the-money", "2") + assert "Not investment advice" in flat(out) + + def test_chain_reports_the_solve_rate( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + _, out, _ = run(capsys, "chain", "AAPL", "--expiry", "2027-01-15") + assert "strikes solved" in flat(out) + + def test_chain_right_filter(self, capsys: pytest.CaptureFixture[str], settings: object) -> None: + _, out, _ = run(capsys, "chain", "AAPL", "--right", "put", "--near-the-money", "2") + assert "put" in out + assert " call " not in out + + def test_chain_csv_export( + self, capsys: pytest.CaptureFixture[str], settings: object, tmp_path: object + ) -> None: + from pathlib import Path + + target = Path(str(tmp_path)) / "out" / "chain.csv" + code, _, _ = run(capsys, "chain", "AAPL", "--expiry", "2027-01-15", "--csv", str(target)) + assert code == 0 + assert target.exists() + + header = target.read_text().splitlines()[0] + assert "iv" in header and "delta" in header and "iv_status" in header + + +class TestErrorsAreReportedNotRaised: + def test_unknown_ticker_exits_two_without_a_traceback( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + code, _, err = run(capsys, "quote", "NOSUCHTICKER") + assert code == 2 + assert err.startswith("error:") + assert "Traceback" not in err + + def test_unlisted_expiry_suggests_the_alternatives( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + code, _, err = run(capsys, "chain", "AAPL", "--expiry", "1999-01-01") + assert code == 2 + assert "Available" in err + + def test_a_malformed_date_is_rejected_by_the_parser(self) -> None: + with pytest.raises(SystemExit): + build_parser().parse_args(["chain", "AAPL", "--expiry", "not-a-date"]) + + +class TestStatefulCommands: + def test_watchlist_add_list_remove( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + assert run(capsys, "watchlist", "--add", "AAPL", "SPY")[0] == 0 + + _, out, _ = run(capsys, "watchlist") + assert "AAPL" in out and "SPY" in out + assert "no snapshots yet" in out + + _, removed_output, _ = run(capsys, "watchlist", "--remove", "SPY") + assert "removed SPY" in removed_output + + _, out, _ = run(capsys, "watchlist") + assert "SPY" not in out + + def test_removing_an_untracked_symbol_says_so( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + _, out, _ = run(capsys, "watchlist", "--remove", "ZZZZ") + assert "was not tracked" in out + + def test_empty_watchlist_suggests_what_to_do( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + for command in ("watchlist", "snapshot", "ivrank"): + _, out, _ = run(capsys, command) + assert "watchlist is empty" in out + + def test_snapshot_then_rerun_reports_a_refresh( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + code, out, _ = run(capsys, "snapshot", "AAPL", "--expiries", "3") + assert code == 0 + assert "captured" in out + + code, out, _ = run(capsys, "snapshot", "AAPL", "--expiries", "3") + assert code == 0 + assert "refreshed" in out + + def test_snapshot_prints_the_row_count_before_and_after( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + """The idempotency proof a human can read in the terminal.""" + run(capsys, "snapshot", "AAPL") + _, out, _ = run(capsys, "snapshot", "AAPL") + + line = next(x for x in out.splitlines() if "stored contract rows" in x) + before, after = (int(part.strip()) for part in line.split(":")[1].split("->")) + assert before == after + + def test_ivrank_refuses_rather_than_inventing( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + run(capsys, "snapshot", "AAPL") + code, out, _ = run(capsys, "ivrank", "AAPL") + assert code == 0 + assert "IV rank unavailable: insufficient history (1/20 days)" in flat(out) + assert "ATM IV" in out, "today's vol is still shown" + + def test_snapshot_registers_unknown_symbols_it_is_given( + self, capsys: pytest.CaptureFixture[str], settings: object + ) -> None: + run(capsys, "snapshot", "SPY") + _, out, _ = run(capsys, "watchlist") + assert "SPY" in out diff --git a/api/tests/test_config.py b/api/tests/test_config.py new file mode 100644 index 0000000..9be0f25 --- /dev/null +++ b/api/tests/test_config.py @@ -0,0 +1,76 @@ +"""Settings validation. + +The pricing assumptions live in config precisely so they can be stated in the +README. These tests keep them from silently accepting nonsense. +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from options_tool.config import Settings + + +class TestDefaults: + def test_a_clean_clone_works_with_no_configuration( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + for key in list(Settings.model_fields): + monkeypatch.delenv(key.upper(), raising=False) + settings = Settings(_env_file=None) # type: ignore[call-arg] + + assert settings.options_provider == "yfinance" + assert settings.options_min_history_days == 20 + assert settings.options_iv_window_days == 252 + assert settings.options_fetch_risk_free_rate is False + + +class TestValidation: + @pytest.mark.parametrize("name", ["bloomberg", "", "yahoo"]) + def test_unknown_provider_rejected(self, name: str) -> None: + with pytest.raises(ValidationError, match="unknown provider"): + Settings(options_provider=name, _env_file=None) # type: ignore[call-arg] + + @pytest.mark.parametrize("name", ["YFINANCE", " Fixture ", "fixture"]) + def test_provider_name_is_normalised(self, name: str) -> None: + assert Settings(options_provider=name, _env_file=None).options_provider in ( # type: ignore[call-arg] + "yfinance", + "fixture", + ) + + def test_min_history_cannot_exceed_the_window(self) -> None: + """Otherwise the rank could never be reported at all.""" + with pytest.raises(ValidationError, match="could never be reported"): + Settings( # type: ignore[call-arg] + options_iv_window_days=10, options_min_history_days=50, _env_file=None + ) + + @pytest.mark.parametrize("rate", [-0.5, 1.5]) + def test_absurd_risk_free_rates_rejected(self, rate: float) -> None: + with pytest.raises(ValidationError): + Settings(options_risk_free_rate=rate, _env_file=None) # type: ignore[call-arg] + + @pytest.mark.parametrize("yield_", [-0.01, 0.9]) + def test_absurd_dividend_yields_rejected(self, yield_: float) -> None: + with pytest.raises(ValidationError): + Settings(options_dividend_yield=yield_, _env_file=None) # type: ignore[call-arg] + + def test_min_history_must_be_at_least_one(self) -> None: + with pytest.raises(ValidationError): + Settings(options_min_history_days=0, _env_file=None) # type: ignore[call-arg] + + +class TestEnvironmentOverrides: + def test_env_vars_are_read(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPTIONS_PROVIDER", "fixture") + monkeypatch.setenv("OPTIONS_MIN_HISTORY_DAYS", "45") + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.options_provider == "fixture" + assert settings.options_min_history_days == 45 + + def test_no_secret_has_a_default_value(self) -> None: + """Keys must come from the environment, never be baked into the source.""" + settings = Settings(_env_file=None) # type: ignore[call-arg] + assert settings.polygon_api_key is None + assert settings.tradier_api_key is None diff --git a/api/tests/test_implied_vol.py b/api/tests/test_implied_vol.py new file mode 100644 index 0000000..d5754d0 --- /dev/null +++ b/api/tests/test_implied_vol.py @@ -0,0 +1,247 @@ +"""The implied-volatility solver. + +The contract under test is not "is it accurate" but **"does it ever return a +wrong number"**. Accuracy is checked by round-tripping price -> IV -> price; the +rest of this file is a catalogue of the ways a chain breaks a solver, each one +asserting `None` and a named reason. +""" + +from __future__ import annotations + +import math + +import pytest + +from options_tool.analytics.black_scholes import Right, price +from options_tool.analytics.implied_vol import ( + MAX_SIGMA, + MIN_SIGMA, + IVStatus, + implied_vol, + solve_implied_vol, +) + +RATE = 0.05 + + +class TestRoundTrip: + """price(sigma) -> solve -> sigma. The strongest available correctness check.""" + + SIGMAS = [0.01, 0.05, 0.1, 0.2, 0.35, 0.5, 1.0, 2.5, 4.5] + MATURITIES = [0.0001, 0.01, 0.08, 0.5, 1.0, 2.0] + + @pytest.mark.parametrize("sigma", SIGMAS) + @pytest.mark.parametrize("right", [Right.CALL, Right.PUT]) + def test_at_the_money(self, sigma: float, right: Right) -> None: + target = price(100.0, 100.0, 1.0, RATE, sigma, 0.0, right) + assert implied_vol(target, 100.0, 100.0, 1.0, RATE, 0.0, right) == pytest.approx( + sigma, abs=1e-8 + ) + + @pytest.mark.parametrize("T", MATURITIES) + @pytest.mark.parametrize("strike", [60.0, 90.0, 100.0, 110.0, 160.0]) + @pytest.mark.parametrize("right", [Right.CALL, Right.PUT]) + def test_across_the_surface(self, T: float, strike: float, right: Right) -> None: + """Every point that produces a representable price must invert exactly. + + Deep wings at short maturities underflow to a zero price, which carries + no information about volatility -- those are asserted separately below, + not quietly skipped as a solver failure. + """ + sigma = 0.30 + target = price(100.0, strike, T, RATE, sigma, 0.0, right) + result = solve_implied_vol(target, 100.0, strike, T, RATE, 0.0, right) + + if result.status is IVStatus.OK: + assert result.sigma == pytest.approx(sigma, abs=1e-7) + else: + assert result.sigma is None + assert result.status in { + IVStatus.NON_POSITIVE_PRICE, + IVStatus.BELOW_INTRINSIC, + IVStatus.ABOVE_MAX, + } + + @pytest.mark.parametrize("q", [0.0, 0.015, 0.05]) + def test_with_a_dividend_yield(self, q: float) -> None: + target = price(100.0, 105.0, 0.75, RATE, 0.28, q, Right.PUT) + assert implied_vol(target, 100.0, 105.0, 0.75, RATE, q, Right.PUT) == pytest.approx( + 0.28, abs=1e-8 + ) + + def test_reprices_to_the_original_input(self) -> None: + """The round trip that actually matters to a user: price is recovered.""" + original = price(100.0, 95.0, 0.4, RATE, 0.42, 0.0, Right.CALL) + sigma = implied_vol(original, 100.0, 95.0, 0.4, RATE, 0.0, Right.CALL) + assert sigma is not None + assert price(100.0, 95.0, 0.4, RATE, sigma, 0.0, Right.CALL) == pytest.approx( + original, abs=1e-10 + ) + + +class TestBisectionFallback: + """Wing contracts where Newton's step explodes must still solve.""" + + WING_CASES = [ + (100.0, 150.0, 0.01, 0.80, Right.CALL), + (100.0, 160.0, 0.02, 1.20, Right.CALL), + (100.0, 40.0, 0.05, 0.90, Right.PUT), + (100.0, 250.0, 0.25, 1.50, Right.CALL), + (50.0, 500.0, 2.00, 0.60, Right.CALL), + (100.0, 130.0, 0.003, 2.00, Right.CALL), + ] + + @pytest.mark.parametrize(("S", "K", "T", "sigma", "right"), WING_CASES) + def test_wings_converge_via_bisection( + self, S: float, K: float, T: float, sigma: float, right: Right + ) -> None: + target = price(S, K, T, RATE, sigma, 0.0, right) + result = solve_implied_vol(target, S, K, T, RATE, 0.0, right) + assert result.status is IVStatus.OK + assert result.sigma == pytest.approx(sigma, abs=1e-6) + + def test_at_least_one_case_actually_uses_bisection(self) -> None: + """Guards the fallback against becoming dead code no test exercises.""" + methods = set() + for S, K, T, sigma, right in self.WING_CASES: + target = price(S, K, T, RATE, sigma, 0.0, right) + methods.add(solve_implied_vol(target, S, K, T, RATE, 0.0, right).method) + assert "bisection" in methods + + def test_newton_handles_the_ordinary_case(self) -> None: + target = price(100.0, 100.0, 1.0, RATE, 0.25, 0.0, Right.CALL) + result = solve_implied_vol(target, 100.0, 100.0, 1.0, RATE, 0.0, Right.CALL) + assert result.method == "newton" + assert result.iterations < 10 + + +class TestEveryFailureReturnsNone: + """SPEC.md section 4: return None, never a silently wrong number.""" + + def test_expired_contract(self) -> None: + result = solve_implied_vol(5.0, 100.0, 100.0, 0.0, RATE) + assert result.sigma is None + assert result.status is IVStatus.EXPIRED + + def test_negative_time(self) -> None: + assert solve_implied_vol(5.0, 100.0, 100.0, -1.0, RATE).status is IVStatus.EXPIRED + + @pytest.mark.parametrize("bad_price", [0.0, -0.01, -100.0]) + def test_zero_or_negative_price(self, bad_price: float) -> None: + """The zero-bid case: a contract nobody will pay for implies nothing.""" + result = solve_implied_vol(bad_price, 100.0, 100.0, 1.0, RATE) + assert result.sigma is None + assert result.status is IVStatus.NON_POSITIVE_PRICE + + def test_price_below_intrinsic(self) -> None: + """A crossed or stale quote on a deep ITM strike.""" + result = solve_implied_vol(0.01, 100.0, 50.0, 1.0, RATE, right=Right.CALL) + assert result.sigma is None + assert result.status is IVStatus.BELOW_INTRINSIC + + def test_price_above_the_no_arbitrage_maximum(self) -> None: + result = solve_implied_vol(500.0, 100.0, 100.0, 1.0, RATE, right=Right.CALL) + assert result.sigma is None + assert result.status is IVStatus.ABOVE_MAX + + def test_put_above_its_own_maximum(self) -> None: + """A put cannot be worth more than its discounted strike.""" + result = solve_implied_vol(200.0, 100.0, 100.0, 1.0, RATE, right=Right.PUT) + assert result.sigma is None + assert result.status is IVStatus.ABOVE_MAX + + @pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) + def test_non_finite_inputs(self, bad: float) -> None: + assert solve_implied_vol(bad, 100.0, 100.0, 1.0, RATE).status is IVStatus.INVALID_INPUT + assert solve_implied_vol(5.0, bad, 100.0, 1.0, RATE).status is IVStatus.INVALID_INPUT + + @pytest.mark.parametrize(("S", "K"), [(0.0, 100.0), (-10.0, 100.0), (100.0, 0.0)]) + def test_non_positive_spot_or_strike(self, S: float, K: float) -> None: + assert solve_implied_vol(5.0, S, K, 1.0, RATE).status is IVStatus.INVALID_INPUT + + def test_unparseable_right(self) -> None: + assert ( + solve_implied_vol(5.0, 100.0, 100.0, 1.0, RATE, right="banana").status + is IVStatus.INVALID_INPUT + ) + + def test_vol_above_the_search_bracket_is_not_invented(self) -> None: + """A price implying >500% vol reports no bracket rather than clamping. + + Clamping to MAX_SIGMA would return a plausible-looking 5.0 that is simply + the edge of the search space, not a measurement. + """ + just_under_max = price(100.0, 100.0, 1.0, RATE, MAX_SIGMA, 0.0, Right.CALL) + upper_bound = 100.0 # a call cannot exceed spot when q = 0 + target = (just_under_max + upper_bound) / 2 + result = solve_implied_vol(target, 100.0, 100.0, 1.0, RATE, right=Right.CALL) + assert result.sigma is None + assert result.status in {IVStatus.NO_BRACKET, IVStatus.ABOVE_MAX} + + +class TestResultInvariants: + def test_sigma_and_status_never_disagree(self) -> None: + """Either a number with status OK, or None with a reason. Never both.""" + cases = [ + (10.45, 100.0, 100.0, 1.0), + (0.0, 100.0, 100.0, 1.0), + (5.0, 100.0, 100.0, 0.0), + (999.0, 100.0, 100.0, 1.0), + (0.001, 100.0, 40.0, 1.0), + ] + for target, S, K, T in cases: + result = solve_implied_vol(target, S, K, T, RATE) + if result.status is IVStatus.OK: + assert result.sigma is not None + assert MIN_SIGMA <= result.sigma <= MAX_SIGMA + assert result.ok + else: + assert result.sigma is None + assert not result.ok + + def test_every_status_has_an_explanation(self) -> None: + for status in IVStatus: + assert status.explanation + assert not status.explanation.endswith(".") + + def test_solved_sigma_is_always_inside_the_bracket(self) -> None: + for sigma in (0.02, 0.5, 3.0): + target = price(100.0, 100.0, 1.0, RATE, sigma, 0.0, Right.CALL) + solved = implied_vol(target, 100.0, 100.0, 1.0, RATE) + assert solved is not None + assert MIN_SIGMA <= solved <= MAX_SIGMA + + +class TestPrecision: + """Guards the tolerance fix: an absolute price tolerance is not enough. + + A sub-penny wing contract satisfies a $1e-8 absolute price tolerance while + its sigma is still wrong in the fourth decimal, because out there the price + is nearly flat in sigma. The solver scales its tolerance to the quote and + also stops on bracket width. + """ + + @pytest.mark.parametrize( + ("S", "K", "T", "sigma", "right"), + [ + (100.0, 150.0, 0.01, 0.80, Right.CALL), + (100.0, 40.0, 0.05, 0.90, Right.PUT), + (100.0, 101.0, 0.0001, 0.30, Right.CALL), + ], + ) + def test_sub_penny_contracts_still_solve_to_eight_decimals( + self, S: float, K: float, T: float, sigma: float, right: Right + ) -> None: + target = price(S, K, T, RATE, sigma, 0.0, right) + assert target < 0.01, "this case is meant to be a sub-penny quote" + solved = implied_vol(target, S, K, T, RATE, 0.0, right) + assert solved is not None + assert abs(solved - sigma) < 1e-7 + + def test_monotonicity_of_price_in_sigma(self) -> None: + """The property bisection relies on: without it, bracketing is invalid.""" + previous = -math.inf + for i in range(1, 60): + current = price(100.0, 110.0, 0.5, RATE, i * 0.05, 0.0, Right.CALL) + assert current > previous + previous = current diff --git a/api/tests/test_iv_rank.py b/api/tests/test_iv_rank.py new file mode 100644 index 0000000..ad50b2f --- /dev/null +++ b/api/tests/test_iv_rank.py @@ -0,0 +1,178 @@ +"""IV rank and percentile. + +The arithmetic is one line. These tests are overwhelmingly about the cases where +the tool must **refuse to produce a number** -- which is the actual requirement +(SPEC.md section 3). +""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from options_tool.analytics.iv_rank import ( + IVObservation, + IVRankStatus, + compute_iv_rank, +) +from tests.conftest import make_observations + + +class TestInsufficientHistory: + """The headline behaviour: no rank until the window is met.""" + + @pytest.mark.parametrize("days", [0, 1, 2, 5, 19]) + def test_below_the_minimum_returns_none(self, days: int) -> None: + result = compute_iv_rank(make_observations([0.2 + i * 0.01 for i in range(days)])) + assert result.rank is None + assert result.percentile is None + assert not result.ok + + def test_exactly_at_the_minimum_produces_a_rank(self) -> None: + result = compute_iv_rank( + make_observations([0.2 + i * 0.01 for i in range(20)]), min_history_days=20 + ) + assert result.ok + assert result.rank is not None + + def test_reason_is_renderable_verbatim(self) -> None: + """The UI shows this string as-is; it must read as a sentence.""" + result = compute_iv_rank(make_observations([0.2, 0.25, 0.3]), min_history_days=20) + assert result.reason == "insufficient history (3/20 days)" + assert result.status is IVRankStatus.INSUFFICIENT_HISTORY + assert result.days_available == 3 + assert result.days_required == 20 + + def test_current_iv_is_still_reported(self) -> None: + """Today's vol is known even when its rank is not -- show what we have.""" + result = compute_iv_rank(make_observations([0.2, 0.25, 0.31]), min_history_days=20) + assert result.current_iv == pytest.approx(0.31) + assert result.rank is None + + def test_the_arithmetic_would_have_succeeded(self) -> None: + """Proves the None is a deliberate refusal, not a computation failure.""" + values = [0.10, 0.20, 0.30] + refused = compute_iv_rank(make_observations(values), min_history_days=20) + allowed = compute_iv_rank(make_observations(values), min_history_days=3) + assert refused.rank is None + assert allowed.rank == pytest.approx(100.0) + + +class TestRankArithmetic: + def test_current_at_the_maximum_is_one_hundred(self) -> None: + result = compute_iv_rank(make_observations([0.1] * 19 + [0.5]), min_history_days=20) + assert result.rank == pytest.approx(100.0) + + def test_current_at_the_minimum_is_zero(self) -> None: + result = compute_iv_rank(make_observations([0.5] * 19 + [0.1]), min_history_days=20) + assert result.rank == pytest.approx(0.0) + + def test_midpoint_is_fifty(self) -> None: + values = [0.10] + [0.30] * 18 + [0.20] + result = compute_iv_rank(make_observations(values), min_history_days=20) + assert result.rank == pytest.approx(50.0) + + def test_matches_the_formula_exactly(self) -> None: + values = [0.12, 0.34, 0.21, 0.45, 0.19] * 5 + result = compute_iv_rank(make_observations(values), min_history_days=20) + expected = (values[-1] - min(values)) / (max(values) - min(values)) * 100 + assert result.rank == pytest.approx(expected) + assert result.iv_min == pytest.approx(min(values)) + assert result.iv_max == pytest.approx(max(values)) + assert result.iv_mean == pytest.approx(sum(values) / len(values)) + + def test_rank_is_always_within_zero_and_one_hundred(self) -> None: + import random + + rng = random.Random(20260913) + for _ in range(50): + values = [rng.uniform(0.05, 1.5) for _ in range(40)] + result = compute_iv_rank(make_observations(values), min_history_days=20) + assert result.rank is not None + assert 0.0 <= result.rank <= 100.0 + + +class TestPercentile: + """Percentile answers a different question from rank, and is more robust.""" + + def test_percentile_counts_days_at_or_below_today(self) -> None: + values = [0.1, 0.2, 0.3, 0.4] * 5 + [0.25] + result = compute_iv_rank(make_observations(values), min_history_days=20) + expected = sum(1 for v in values if v <= values[-1]) / len(values) * 100 + assert result.percentile == pytest.approx(expected) + + def test_a_single_outlier_moves_rank_but_barely_moves_percentile(self) -> None: + """The reason both are reported rather than just rank.""" + ordinary = [0.15 + 0.002 * i for i in range(24)] + with_spike = [1.20, *ordinary] + + base = compute_iv_rank(make_observations(ordinary), min_history_days=20) + spiked = compute_iv_rank(make_observations(with_spike), min_history_days=20) + + assert base.rank is not None and spiked.rank is not None + assert base.percentile is not None and spiked.percentile is not None + assert base.rank - spiked.rank > 50, "one outlier should crush the rank" + assert abs(base.percentile - spiked.percentile) < 10, "percentile should barely move" + + +class TestDegenerateCases: + def test_flat_history_is_undefined_not_fifty(self) -> None: + """0/0 has no answer. Convention says 50; convention is inventing data.""" + result = compute_iv_rank(make_observations([0.25] * 30), min_history_days=20) + assert result.rank is None + assert result.status is IVRankStatus.DEGENERATE_RANGE + assert result.current_iv == pytest.approx(0.25) + + def test_no_observations_at_all(self) -> None: + result = compute_iv_rank([]) + assert result.rank is None + assert result.status is IVRankStatus.NO_CURRENT_IV + assert result.days_available == 0 + + def test_non_positive_values_are_discarded_not_zero_filled(self) -> None: + """A day with no ATM vol must not contribute an artificial minimum.""" + values = [0.2 + 0.01 * i for i in range(25)] + polluted = compute_iv_rank(make_observations([*values, 0.0]), min_history_days=20) + clean = compute_iv_rank(make_observations(values), min_history_days=20) + assert polluted.iv_min == clean.iv_min + assert polluted.days_available == clean.days_available + + +class TestWindow: + def test_only_the_trailing_window_is_ranked(self) -> None: + """An ancient extreme must age out rather than anchor the rank forever.""" + recent = [0.20 + 0.001 * i for i in range(60)] + with_ancient_spike = [5.0, *recent] + + result = compute_iv_rank(make_observations(with_ancient_spike), window_days=30) + + assert result.iv_max == pytest.approx(max(recent[-30:])) + assert result.iv_max < 1.0, "the spike must have fallen out of the window" + assert result.days_available == 30 + + def test_window_boundaries_are_reported(self) -> None: + result = compute_iv_rank( + make_observations([0.2 + 0.001 * i for i in range(25)], start=date(2026, 3, 1)), + min_history_days=20, + ) + assert result.first_observed == date(2026, 3, 1) + assert result.last_observed == date(2026, 3, 25) + assert result.window_days == 252 + + def test_observations_are_sorted_regardless_of_input_order(self) -> None: + ordered = make_observations([0.1 + 0.01 * i for i in range(25)]) + shuffled = list(reversed(ordered)) + assert compute_iv_rank(ordered, min_history_days=20).rank == pytest.approx( + compute_iv_rank(shuffled, min_history_days=20).rank + ) + + def test_latest_observation_by_date_is_the_current_one(self) -> None: + observations = [ + IVObservation(date(2026, 5, 3), 0.9), + IVObservation(date(2026, 5, 1), 0.1), + IVObservation(date(2026, 5, 2), 0.5), + ] + result = compute_iv_rank(observations, min_history_days=3) + assert result.current_iv == pytest.approx(0.9) + assert result.rank == pytest.approx(100.0) diff --git a/api/tests/test_offline.py b/api/tests/test_offline.py new file mode 100644 index 0000000..55b0e10 --- /dev/null +++ b/api/tests/test_offline.py @@ -0,0 +1,69 @@ +"""Proof that the suite really is offline. + +SPEC.md M4: *"the suite must pass offline with no network."* Configuration can +drift and a comment in `pyproject.toml` proves nothing, so the guarantee is +asserted here as a test: if someone removes `--disable-socket`, or a code path +quietly acquires a network dependency, this file goes red. +""" + +from __future__ import annotations + +import socket + +import pytest + + +class TestNetworkIsBlocked: + def test_opening_a_tcp_connection_fails(self) -> None: + with pytest.raises(Exception) as info: + socket.create_connection(("142.250.72.46", 80), timeout=1) + assert "socket" in type(info.value).__name__.lower() or "Blocked" in str(info.value) + + def test_creating_an_inet_socket_fails(self) -> None: + with pytest.raises(Exception): # noqa: B017 -- pytest_socket raises its own type + socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + def test_dns_resolution_fails(self) -> None: + with pytest.raises(Exception): # noqa: B017 + socket.gethostbyname("finance.yahoo.com") + + def test_unix_sockets_are_still_allowed(self) -> None: + """Local IPC is permitted so asyncio's event loop can start.""" + left, right = socket.socketpair() + left.close() + right.close() + + +class TestNoHiddenVendorImport: + def test_importing_the_provider_package_does_not_import_yfinance(self) -> None: + """yfinance is imported inside methods, not at module scope. + + Beyond speed, this is what stops a stray `from options_tool.providers + import ...` anywhere in the codebase from dragging a networking library + into a process that never asked for one. + """ + import subprocess + import sys + + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import options_tool.providers; " + "import options_tool.analytics.chain; import options_tool.api.main; " + "print('yfinance' in sys.modules)", + ], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip() == "False", result.stdout + + def test_analytics_import_nothing_that_does_io(self) -> None: + import options_tool.analytics.black_scholes as bs + import options_tool.analytics.implied_vol as iv + from options_tool.analytics import payoff + + for module in (bs, iv, payoff): + source = module.__doc__ or "" + assert source, f"{module.__name__} should document itself" diff --git a/api/tests/test_payoff.py b/api/tests/test_payoff.py new file mode 100644 index 0000000..a156eb2 --- /dev/null +++ b/api/tests/test_payoff.py @@ -0,0 +1,315 @@ +"""Multi-leg payoff. + +Three layers of check: + +1. **hand-computed values** for each standard structure -- a spread's maximum + profit is width minus debit, and that is arithmetic a reader can verify +2. **the golden file**, shared with the TypeScript suite so the client-side + preview cannot drift from the server engine +3. **structural invariants** -- the extremes and breakevens are derived + analytically, so they must not depend on the plotting grid at all +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import Any + +import pytest + +from options_tool.analytics.payoff import ( + OPTION_MULTIPLIER, + Leg, + LegKind, + build_payoff_curve, + payoff_at_expiry, + value_today, +) + +GOLDEN = Path(__file__).parent / "fixtures" / "payoff_golden.json" + +SPOT, RATE, T, Q = 100.0, 0.05, 0.25, 0.0 + + +def call(qty: int, premium: float, strike: float, vol: float | None = 0.25) -> Leg: + return Leg(LegKind.CALL, qty, premium, strike, vol) + + +def put(qty: int, premium: float, strike: float, vol: float | None = 0.25) -> Leg: + return Leg(LegKind.PUT, qty, premium, strike, vol) + + +def stock(qty: int, premium: float) -> Leg: + return Leg(LegKind.STOCK, qty, premium) + + +def curve_for(legs: list[Leg], **kwargs: Any): # type: ignore[no-untyped-def] + return build_payoff_curve(legs, SPOT, T, RATE, Q, **kwargs) + + +class TestLegValidation: + def test_zero_quantity_rejected(self) -> None: + with pytest.raises(ValueError, match="non-zero"): + call(0, 5.0, 100.0) + + def test_negative_premium_rejected_with_a_useful_message(self) -> None: + """A short leg is expressed by a negative quantity, not a negative premium.""" + with pytest.raises(ValueError, match="negative quantity"): + call(1, -5.0, 100.0) + + def test_option_without_a_strike_rejected(self) -> None: + with pytest.raises(ValueError, match="positive strike"): + Leg(LegKind.CALL, 1, 5.0, None) + + def test_stock_with_a_strike_rejected(self) -> None: + with pytest.raises(ValueError, match="no strike"): + Leg(LegKind.STOCK, 100, 100.0, 95.0) + + def test_multiplier(self) -> None: + assert call(1, 5.0, 100.0).multiplier == OPTION_MULTIPLIER + assert stock(100, 100.0).multiplier == 1 + + def test_cost_sign_follows_quantity(self) -> None: + assert call(1, 5.0, 100.0).cost == pytest.approx(500.0) + assert call(-1, 5.0, 100.0).cost == pytest.approx(-500.0) + + +class TestHandComputedStructures: + """Each expected value is arithmetic a reader can check without running code.""" + + def test_long_call(self) -> None: + c = curve_for([call(1, 5.0, 100.0)]) + assert c.net_cost == pytest.approx(500.0) + assert c.max_loss == pytest.approx(-500.0) + assert c.max_profit is None, "a long call's upside is genuinely unbounded" + assert c.breakevens == pytest.approx([105.0]) + + def test_short_call_has_unlimited_loss(self) -> None: + c = curve_for([call(-1, 5.0, 100.0)]) + assert c.max_profit == pytest.approx(500.0) + assert c.max_loss is None + assert c.unlimited_loss + + def test_long_put_profit_is_large_but_finite(self) -> None: + """The case a naive implementation calls unlimited: the stock stops at 0.""" + c = curve_for([put(1, 4.0, 100.0)]) + assert c.max_profit == pytest.approx(100.0 * OPTION_MULTIPLIER - 400.0) + assert not c.unlimited_profit + assert c.max_loss == pytest.approx(-400.0) + assert c.breakevens == pytest.approx([96.0]) + + def test_bull_call_spread(self) -> None: + """Max profit = width - net debit.""" + c = curve_for([call(1, 5.0, 100.0), call(-1, 1.5, 110.0, 0.23)]) + debit = 350.0 + assert c.net_cost == pytest.approx(debit) + assert c.max_profit == pytest.approx(10.0 * OPTION_MULTIPLIER - debit) + assert c.max_loss == pytest.approx(-debit) + assert c.breakevens == pytest.approx([103.5]) + + def test_iron_condor(self) -> None: + """Max loss = wing width - net credit, on either side.""" + c = curve_for( + [ + put(1, 0.50, 90.0, 0.30), + put(-1, 1.50, 95.0, 0.27), + call(-1, 1.60, 105.0, 0.24), + call(1, 0.60, 110.0, 0.23), + ] + ) + credit = 200.0 + assert c.net_cost == pytest.approx(-credit) + assert c.max_profit == pytest.approx(credit) + assert c.max_loss == pytest.approx(-(5.0 * OPTION_MULTIPLIER - credit)) + assert c.breakevens == pytest.approx([93.0, 107.0]) + + def test_covered_call_loss_is_bounded(self) -> None: + """P/L falls as the stock falls, but the stock bottoms out at zero.""" + c = curve_for([stock(100, 100.0), call(-1, 3.0, 105.0, 0.24)]) + assert c.max_loss == pytest.approx(-(100 * 100.0) + 300.0) + assert not c.unlimited_loss + assert c.max_profit == pytest.approx(5.0 * OPTION_MULTIPLIER + 300.0) + + def test_call_ratio_spread_has_two_breakevens_and_unlimited_loss(self) -> None: + """Opened for a debit, yet loses without limit -- the shape that surprises.""" + c = curve_for([call(1, 5.0, 100.0), call(-2, 1.5, 110.0, 0.23)]) + assert c.net_cost == pytest.approx(200.0) + assert c.max_profit == pytest.approx(800.0) + assert c.max_loss is None + assert c.breakevens == pytest.approx([102.0, 118.0]) + + def test_collar(self) -> None: + c = curve_for([stock(100, 100.0), put(1, 3.0, 95.0, 0.28), call(-1, 2.5, 110.0, 0.23)]) + assert c.max_profit == pytest.approx(950.0) + assert c.max_loss == pytest.approx(-550.0) + assert not c.unlimited_profit and not c.unlimited_loss + + +class TestGoldenFile: + """Shared with vitest. Drift between the two implementations fails here.""" + + @staticmethod + def load() -> dict[str, Any]: + with GOLDEN.open() as handle: + return json.load(handle) # type: ignore[no-any-return] + + def test_golden_file_exists_and_covers_the_structures(self) -> None: + data = self.load() + names = {case["name"] for case in data["cases"]} + assert {"long_call", "iron_condor", "covered_call", "call_ratio_spread"} <= names + assert data["parameters"]["option_multiplier"] == OPTION_MULTIPLIER + + def test_every_case_reproduces_exactly(self) -> None: + data = self.load() + params = data["parameters"] + + for case in data["cases"]: + legs = [ + Leg( + LegKind(item["kind"]), + item["quantity"], + item["premium"], + item.get("strike"), + item.get("volatility"), + ) + for item in case["legs"] + ] + curve = build_payoff_curve( + legs, + params["spot"], + params["time_to_expiry"], + params["risk_free_rate"], + params["dividend_yield"], + tuple(params["price_range"]), # type: ignore[arg-type] + params["points"], + ) + expected = case["expected"] + name = case["name"] + + assert curve.net_cost == pytest.approx(expected["net_cost"]), name + assert curve.breakevens == pytest.approx(expected["breakevens"]), name + assert curve.max_profit == pytest.approx(expected["max_profit"]), name + assert curve.max_loss == pytest.approx(expected["max_loss"]), name + assert curve.unlimited_profit == expected["unlimited_profit"], name + assert curve.unlimited_loss == expected["unlimited_loss"], name + assert curve.pnl_at_expiry == pytest.approx(expected["pnl_at_expiry"]), name + + +class TestTodayCurve: + def test_today_requires_a_volatility_for_every_option_leg(self) -> None: + c = curve_for([call(1, 5.0, 100.0, None)]) + assert c.pnl_today is None + assert c.today_unavailable_reason is not None + assert "implied volatility" in c.today_unavailable_reason + + def test_missing_vol_does_not_suppress_the_expiry_curve(self) -> None: + """The expiry curve needs no volatility, so it is still exact.""" + c = curve_for([call(1, 5.0, 100.0, None)]) + assert c.pnl_at_expiry + assert c.max_loss == pytest.approx(-500.0) + + def test_stock_only_position_needs_no_volatility(self) -> None: + c = curve_for([stock(100, 100.0)]) + assert c.pnl_today is not None + + def test_today_exceeds_expiry_for_a_long_option_before_expiry(self) -> None: + """Time value is still there, so a long option is worth more than intrinsic.""" + legs = [call(1, 5.0, 100.0, 0.25)] + at_spot_expiry = payoff_at_expiry(legs, 100.0) + at_spot_today = value_today(legs, 100.0, T, RATE, Q) + assert at_spot_today is not None + assert at_spot_today > at_spot_expiry + + def test_today_converges_to_expiry_as_time_runs_out(self) -> None: + """The gap must shrink monotonically toward zero as T does. + + Not asserted at a single small T: an at-the-money option's time value + decays as sqrt(T), not T, so at T=1e-9 a contract still legitimately + holds ~3 cents. Convergence is the property worth pinning down; an + arbitrary tolerance at one T would only encode that quirk. + """ + legs = [call(1, 5.0, 100.0, 0.25), call(-1, 1.5, 110.0, 0.23)] + + for underlying in (85.0, 100.0, 115.0): + expiry = payoff_at_expiry(legs, underlying) + gaps = [] + for years in (1e-2, 1e-4, 1e-6, 1e-8): + today = value_today(legs, underlying, years, RATE, Q) + assert today is not None + gaps.append(abs(today - expiry)) + + assert gaps == sorted(gaps, reverse=True), f"not converging at S={underlying}" + + # The remaining gap is time value, bounded by the standard + # at-the-money approximation 0.4 * sigma * S * sqrt(T) per share. + # At T=1e-8 that is ~$0.10 per contract, and the worst case here + # lands within a few percent of it. + bound = 0.4 * 0.25 * 100.0 * math.sqrt(1e-8) * OPTION_MULTIPLIER + assert gaps[-1] <= bound * 1.1, f"{gaps[-1]:.4f} exceeds the sqrt(T) bound {bound:.4f}" + + def test_at_expiry_today_equals_the_expiry_payoff_exactly(self) -> None: + legs = [call(1, 5.0, 100.0, 0.25), call(-1, 1.5, 110.0, 0.23)] + for underlying in (85.0, 100.0, 105.0, 115.0): + assert value_today(legs, underlying, 0.0, RATE, Q) == pytest.approx( + payoff_at_expiry(legs, underlying), abs=1e-9 + ) + + def test_at_the_money_time_value_decays_as_the_square_root_of_time(self) -> None: + """Quartering the time left should halve an ATM option's time value.""" + legs = [call(1, 0.0, 100.0, 0.25)] + long_dated = value_today(legs, 100.0, 4e-4, RATE, Q) + short_dated = value_today(legs, 100.0, 1e-4, RATE, Q) + assert long_dated is not None and short_dated is not None + assert long_dated / short_dated == pytest.approx(2.0, rel=1e-2) + + def test_value_today_returns_none_rather_than_assuming_a_vol(self) -> None: + assert value_today([call(1, 5.0, 100.0, None)], 100.0, T, RATE, Q) is None + + +class TestStructuralInvariants: + def test_extremes_do_not_depend_on_the_plotting_grid(self) -> None: + """Derived analytically, so more points must not change the answer.""" + legs = [call(1, 5.0, 100.0), call(-1, 1.5, 110.0, 0.23)] + coarse = curve_for(legs, points=5) + fine = curve_for(legs, points=1001) + assert coarse.max_profit == pytest.approx(fine.max_profit) + assert coarse.max_loss == pytest.approx(fine.max_loss) + assert coarse.breakevens == pytest.approx(fine.breakevens) + + def test_breakeven_outside_the_plotted_range_is_still_found(self) -> None: + """A grid scan would miss this; solving the terminal segment does not.""" + c = curve_for([call(1, 5.0, 100.0)], price_range=(60.0, 102.0), points=11) + assert c.breakevens == pytest.approx([105.0]) + assert max(c.underlying_prices) < 105.0 + + def test_net_cost_equals_pnl_at_zero_plus_intrinsic(self) -> None: + legs = [put(1, 4.0, 100.0), call(1, 5.0, 100.0)] + assert payoff_at_expiry(legs, 100.0) == pytest.approx(-sum(leg.cost for leg in legs)) + + def test_curve_length_matches_requested_points(self) -> None: + c = curve_for([call(1, 5.0, 100.0)], points=37) + assert len(c.underlying_prices) == 37 + assert len(c.pnl_at_expiry) == 37 + assert len(c.pnl_today or []) == 37 + + def test_short_and_long_are_exact_mirrors(self) -> None: + long_leg = curve_for([call(1, 5.0, 100.0)]) + short_leg = curve_for([call(-1, 5.0, 100.0)]) + for a, b in zip(long_leg.pnl_at_expiry, short_leg.pnl_at_expiry, strict=True): + assert a == pytest.approx(-b) + + +class TestInvalidCurves: + def test_empty_position(self) -> None: + with pytest.raises(ValueError, match="at least one leg"): + curve_for([]) + + def test_non_positive_spot(self) -> None: + with pytest.raises(ValueError, match="positive"): + build_payoff_curve([call(1, 5.0, 100.0)], 0.0, T, RATE, Q) + + def test_too_few_points(self) -> None: + with pytest.raises(ValueError, match="at least 3 points"): + curve_for([call(1, 5.0, 100.0)], points=2) diff --git a/api/tests/test_providers.py b/api/tests/test_providers.py new file mode 100644 index 0000000..4c193b5 --- /dev/null +++ b/api/tests/test_providers.py @@ -0,0 +1,413 @@ +"""The market-data boundary. + +The yfinance provider is tested against a fake `yfinance` module injected into +`sys.modules`. That is what lets the retry logic, the NaN handling and -- most +importantly -- the rule that **no vendor greek may cross this boundary** be +covered with sockets disabled. +""" + +from __future__ import annotations + +import sys +import types +from dataclasses import fields +from datetime import date + +import pandas as pd +import pytest + +from options_tool.providers import get_provider +from options_tool.providers.base import ( + ExpiryNotFoundError, + OptionChain, + OptionQuote, + ProviderError, + RateLimitedError, + UnknownTickerError, +) +from options_tool.providers.fixture_provider import FixtureProvider +from options_tool.providers.yfinance_provider import YFinanceProvider + + +class TestTheBoundaryItself: + def test_option_quote_has_nowhere_to_put_a_vendor_greek(self) -> None: + """SPEC.md section 4, enforced structurally rather than by discipline. + + If this test fails, someone has added a field that lets a vendor's own + delta or implied volatility into the system, and the central claim of + the project stops being true. + """ + names = {f.name for f in fields(OptionQuote)} + forbidden = { + "iv", + "implied_volatility", + "impliedVolatility", + "delta", + "gamma", + "theta", + "vega", + "rho", + "in_the_money", + } + assert names & forbidden == set() + + def test_mid_requires_a_two_sided_book(self) -> None: + base = {"ticker": "T", "expiry": date(2027, 1, 15), "strike": 100.0, "right": "call"} + assert OptionQuote(**base, bid=4.0, ask=4.2).mid == pytest.approx(4.1) + assert OptionQuote(**base, bid=None, ask=4.2).mid is None + assert OptionQuote(**base, bid=4.0, ask=None).mid is None + assert OptionQuote(**base, bid=0.0, ask=4.2).mid is None + assert OptionQuote(**base, bid=5.0, ask=3.0).mid is None, "crossed book has no mid" + + def test_mid_never_silently_borrows_last(self) -> None: + quote = OptionQuote( + ticker="T", + expiry=date(2027, 1, 15), + strike=100.0, + right="call", + bid=None, + ask=None, + last=4.1, + ) + assert quote.mid is None + + +class TestFixtureProvider: + def test_replays_the_capture(self, provider: FixtureProvider) -> None: + chain = provider.get_chain("AAPL", date(2027, 1, 15)) + assert isinstance(chain, OptionChain) + assert len(chain) > 100 + assert chain.spot > 0 + + def test_is_deterministic(self, provider: FixtureProvider) -> None: + """Same input, same output -- what makes it usable as a test double.""" + first = provider.get_chain("AAPL", date(2027, 1, 15)) + second = provider.get_chain("AAPL", date(2027, 1, 15)) + assert first.quotes == second.quotes + assert first.as_of == second.as_of + + def test_expiries_are_sorted(self, provider: FixtureProvider) -> None: + expiries = provider.get_expiries("AAPL") + assert list(expiries) == sorted(expiries) + + def test_captured_at_is_exposed(self, provider: FixtureProvider) -> None: + """Callers must be able to show how stale the data is.""" + assert provider.captured_at.year >= 2026 + + def test_unknown_ticker(self, provider: FixtureProvider) -> None: + with pytest.raises(UnknownTickerError, match="Captured tickers"): + provider.get_quote("NOSUCHTICKER") + + def test_unknown_expiry_lists_the_ones_that_exist(self, provider: FixtureProvider) -> None: + with pytest.raises(ExpiryNotFoundError) as info: + provider.get_chain("AAPL", date(1999, 1, 1)) + assert info.value.available + + def test_missing_file_explains_how_to_regenerate(self, tmp_path: object) -> None: + from pathlib import Path + + with pytest.raises(FileNotFoundError, match="capture-fixture"): + FixtureProvider(Path(str(tmp_path)) / "nope.json") + + def test_every_captured_quote_has_a_usable_strike(self, provider: FixtureProvider) -> None: + for expiry in provider.get_expiries("SPY"): + for quote in provider.get_chain("SPY", expiry).quotes: + assert quote.strike > 0 + assert quote.right in ("call", "put") + + +class TestResolveExpiry: + def test_none_selects_the_nearest(self, provider: FixtureProvider) -> None: + assert provider.resolve_expiry("AAPL", None) == provider.get_expiries("AAPL")[0] + + def test_an_explicit_listed_expiry_is_honoured(self, provider: FixtureProvider) -> None: + assert provider.resolve_expiry("AAPL", date(2027, 1, 15)) == date(2027, 1, 15) + + def test_an_unlisted_expiry_raises_with_the_alternatives( + self, provider: FixtureProvider + ) -> None: + with pytest.raises(ExpiryNotFoundError) as info: + provider.resolve_expiry("AAPL", date(2030, 6, 21)) + assert "Available" in str(info.value) + assert len(info.value.available) == 3 + + +# -------------------------------------------------------------------------- +# yfinance, against a fake vendor module +# -------------------------------------------------------------------------- + + +class FakeFastInfo(dict): + pass + + +class FakeChain: + def __init__(self, calls: pd.DataFrame, puts: pd.DataFrame, underlying: dict) -> None: + self.calls = calls + self.puts = puts + self.underlying = underlying + + +class FakeTicker: + """Stands in for `yfinance.Ticker`, including its failure modes.""" + + def __init__(self, symbol: str) -> None: + self.symbol = symbol + self.calls_made = 0 + + @property + def fast_info(self) -> FakeFastInfo: + return FakeFastInfo(lastPrice=100.0, previousClose=99.0, currency="USD") + + @property + def options(self) -> tuple[str, ...]: + return ("2027-01-15", "2026-09-14") # deliberately unsorted + + def option_chain(self, expiry: str) -> FakeChain: + # Includes the vendor columns we must ignore, plus the NaN/None soup a + # real Yahoo response contains. + frame = pd.DataFrame( + [ + { + "contractSymbol": "FAKE260914C00100000", + "strike": 100.0, + "bid": 4.0, + "ask": 4.2, + "lastPrice": 4.1, + "volume": 12, + "openInterest": 345, + "impliedVolatility": 9.99, + "inTheMoney": True, + }, + { + "contractSymbol": "FAKE260914C00105000", + "strike": 105.0, + "bid": float("nan"), + "ask": None, + "lastPrice": 2.0, + "volume": None, + "openInterest": float("nan"), + "impliedVolatility": 8.88, + "inTheMoney": False, + }, + { + "contractSymbol": "BROKEN", + "strike": float("nan"), + "bid": 1.0, + "ask": 1.2, + "lastPrice": 1.1, + "volume": 1, + "openInterest": 1, + "impliedVolatility": 0.5, + "inTheMoney": False, + }, + ] + ) + return FakeChain(frame, frame.iloc[0:1], {"regularMarketPrice": 101.5}) + + +@pytest.fixture +def fake_yfinance(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType: + module = types.ModuleType("yfinance") + module.Ticker = FakeTicker # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + return module + + +class TestYFinanceMapping: + def test_quote(self, fake_yfinance: types.ModuleType) -> None: + quote = YFinanceProvider().get_quote("aapl") + assert quote.ticker == "AAPL" + assert quote.price == pytest.approx(100.0) + assert quote.previous_close == pytest.approx(99.0) + + def test_expiries_are_parsed_and_sorted(self, fake_yfinance: types.ModuleType) -> None: + assert YFinanceProvider().get_expiries("AAPL") == ( + date(2026, 9, 14), + date(2027, 1, 15), + ) + + def test_vendor_greeks_are_discarded(self, fake_yfinance: types.ModuleType) -> None: + """The fake reports impliedVolatility=9.99. Nothing may carry it forward.""" + chain = YFinanceProvider().get_chain("AAPL", date(2026, 9, 14)) + for quote in chain.quotes: + assert not hasattr(quote, "impliedVolatility") + assert 9.99 not in (quote.bid, quote.ask, quote.last) + + def test_nan_and_none_become_none(self, fake_yfinance: types.ModuleType) -> None: + chain = YFinanceProvider().get_chain("AAPL", date(2026, 9, 14)) + by_strike = {q.strike: q for q in chain.quotes if q.right == "call"} + assert by_strike[105.0].bid is None + assert by_strike[105.0].ask is None + assert by_strike[105.0].volume is None + assert by_strike[105.0].open_interest is None + + def test_rows_without_a_strike_are_dropped(self, fake_yfinance: types.ModuleType) -> None: + """A strike-less row is corrupt, as distinct from merely unquoted.""" + chain = YFinanceProvider().get_chain("AAPL", date(2026, 9, 14)) + assert all(q.contract_symbol != "BROKEN" for q in chain.quotes) + + def test_spot_comes_from_the_chain_response(self, fake_yfinance: types.ModuleType) -> None: + """Using the embedded spot keeps greeks consistent with their own chain.""" + assert YFinanceProvider().get_chain("AAPL", date(2026, 9, 14)).spot == pytest.approx(101.5) + + +class TestYFinanceFailureHandling: + @staticmethod + def provider_raising(message: str, attempts: list[int]) -> YFinanceProvider: + class Raiser(FakeTicker): + def option_chain(self, expiry: str) -> FakeChain: + attempts.append(1) + raise Exception(message) + + @property + def options(self) -> tuple[str, ...]: + attempts.append(1) + raise Exception(message) + + module = types.ModuleType("yfinance") + module.Ticker = Raiser # type: ignore[attr-defined] + sys.modules["yfinance"] = module + return YFinanceProvider(max_retries=3, backoff_seconds=0.0) + + def test_rate_limits_are_retried_then_reported_as_retryable( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + attempts: list[int] = [] + provider = self.provider_raising("Too Many Requests", attempts) + + with pytest.raises(RateLimitedError, match="--provider fixture"): + provider.get_expiries("AAPL") + assert len(attempts) == 3, "should have retried up to max_retries" + + def test_invalid_crumb_is_treated_as_a_rate_limit( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Yahoo's throttling surfaces as a crumb error; it is still transient.""" + attempts: list[int] = [] + provider = self.provider_raising("Invalid Crumb", attempts) + with pytest.raises(RateLimitedError): + provider.get_expiries("AAPL") + assert len(attempts) == 3 + + def test_other_failures_are_not_retried(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A schema change will not fix itself by waiting.""" + attempts: list[int] = [] + provider = self.provider_raising("KeyError: 'strike'", attempts) + + with pytest.raises(ProviderError) as info: + provider.get_expiries("AAPL") + assert not isinstance(info.value, RateLimitedError) + assert len(attempts) == 1, "non-transient failures must fail fast" + + def test_a_priceless_quote_is_an_unknown_ticker(self, monkeypatch: pytest.MonkeyPatch) -> None: + class Priceless(FakeTicker): + @property + def fast_info(self) -> FakeFastInfo: + return FakeFastInfo(lastPrice=None) + + module = types.ModuleType("yfinance") + module.Ticker = Priceless # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + + with pytest.raises(UnknownTickerError): + YFinanceProvider().get_quote("NOPE") + + def test_no_listed_expiries_is_an_unknown_ticker(self, monkeypatch: pytest.MonkeyPatch) -> None: + class NoOptions(FakeTicker): + @property + def options(self) -> tuple[str, ...]: + return () + + module = types.ModuleType("yfinance") + module.Ticker = NoOptions # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + + with pytest.raises(UnknownTickerError): + YFinanceProvider().get_expiries("BRK-A") + + +class TestProviderFactory: + def test_builds_the_fixture_provider(self, settings: object) -> None: + assert get_provider("fixture").name == "fixture" + + def test_builds_the_yfinance_provider_without_importing_it(self, settings: object) -> None: + """Constructing the provider must not pull in the vendor library.""" + assert get_provider("yfinance").name == "yfinance" + + def test_unknown_provider_name(self, settings: object) -> None: + with pytest.raises(ValueError, match="unknown provider"): + get_provider("bloomberg") + + def test_defaults_to_the_configured_provider(self, settings: object) -> None: + assert get_provider().name == "fixture" + + +class TestRiskFreeRate: + def test_uses_the_configured_constant_by_default(self, settings) -> None: # type: ignore[no-untyped-def] + from options_tool.providers.rates import resolve_risk_free_rate + + assert resolve_risk_free_rate(settings) == pytest.approx(0.04) + + def test_a_failed_fetch_falls_back_rather_than_aborting( + self, + settings, + monkeypatch: pytest.MonkeyPatch, # type: ignore[no-untyped-def] + ) -> None: + from options_tool.providers.rates import resolve_risk_free_rate + + class Broken: + def __init__(self, symbol: str) -> None: + raise RuntimeError("network is down") + + module = types.ModuleType("yfinance") + module.Ticker = Broken # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + object.__setattr__(settings, "options_fetch_risk_free_rate", True) + + assert resolve_risk_free_rate(settings) == pytest.approx(0.04) + + def test_an_implausible_rate_is_rejected( + self, + settings, + monkeypatch: pytest.MonkeyPatch, # type: ignore[no-untyped-def] + ) -> None: + """A garbage quote must not silently reprice every option in the tool.""" + from options_tool.providers.rates import resolve_risk_free_rate + + class Absurd: + def __init__(self, symbol: str) -> None: + pass + + @property + def fast_info(self) -> FakeFastInfo: + return FakeFastInfo(lastPrice=9999.0) + + module = types.ModuleType("yfinance") + module.Ticker = Absurd # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + object.__setattr__(settings, "options_fetch_risk_free_rate", True) + + assert resolve_risk_free_rate(settings) == pytest.approx(0.04) + + def test_a_plausible_rate_is_used_and_converted_from_percent( + self, + settings, + monkeypatch: pytest.MonkeyPatch, # type: ignore[no-untyped-def] + ) -> None: + from options_tool.providers.rates import resolve_risk_free_rate + + class Bill: + def __init__(self, symbol: str) -> None: + assert symbol == "^IRX" + + @property + def fast_info(self) -> FakeFastInfo: + return FakeFastInfo(lastPrice=4.25) + + module = types.ModuleType("yfinance") + module.Ticker = Bill # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + object.__setattr__(settings, "options_fetch_risk_free_rate", True) + + assert resolve_risk_free_rate(settings) == pytest.approx(0.0425) diff --git a/api/tests/test_snapshot.py b/api/tests/test_snapshot.py new file mode 100644 index 0000000..9c3fc6f --- /dev/null +++ b/api/tests/test_snapshot.py @@ -0,0 +1,365 @@ +"""Snapshot persistence and idempotency. + +SPEC.md section 5, M2: *"two runs on the same day produce no duplicate rows."* + +Idempotency is checked from both directions — that the writer behaves, and that +the database would stop it even if the writer did not. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from options_tool.analytics.chain import market_date +from options_tool.db.models import Contract, Snapshot, Ticker +from options_tool.db.queries import ( + add_to_watchlist, + contract_count, + history_depth, + iv_observations, + iv_rank_for, + latest_snapshot, + list_watchlist, + remove_from_watchlist, +) +from options_tool.db.snapshot import snapshot_ticker, snapshot_watchlist +from options_tool.providers.base import ProviderError +from options_tool.providers.fixture_provider import FixtureProvider + +RATE = 0.04 + +# The fixture's nearest expiry is 2026-09-14. Simulated history has to sit close +# enough to it that a 30-day constant-maturity point can be bracketed by the +# captured expiries -- otherwise atm_iv_30d is (correctly) null and no IV series +# accumulates at all. +HISTORY_START = datetime(2026, 8, 20, 15, tzinfo=UTC) + + +def run(session: Session, provider: FixtureProvider, symbol: str = "AAPL", **kwargs: object): # type: ignore[no-untyped-def] + return snapshot_ticker(session, provider, symbol, RATE, 0.0, **kwargs) # type: ignore[arg-type] + + +class TestIdempotency: + def test_two_runs_the_same_day_produce_no_duplicate_rows( + self, session: Session, provider: FixtureProvider + ) -> None: + """The headline requirement.""" + first = run(session, provider) + after_first = contract_count(session) + + second = run(session, provider) + after_second = contract_count(session) + + assert after_first == after_second + assert first.contracts_written == second.contracts_written + assert session.scalar(select(func.count(Snapshot.id))) == 1 + + def test_the_second_run_reports_a_refresh_not_a_creation( + self, session: Session, provider: FixtureProvider + ) -> None: + assert run(session, provider).created is True + assert run(session, provider).created is False + + def test_ten_runs_are_the_same_as_one( + self, session: Session, provider: FixtureProvider + ) -> None: + run(session, provider) + baseline = contract_count(session) + for _ in range(9): + run(session, provider) + assert contract_count(session) == baseline + + def test_no_duplicate_key_groups_exist_in_sql( + self, session: Session, provider: FixtureProvider + ) -> None: + """Checks the spec's key directly: (ticker, date, expiry, strike, right).""" + run(session, provider) + run(session, provider) + + duplicates = session.execute( + select( + Snapshot.ticker_id, + Snapshot.snapshot_date, + Contract.expiry, + Contract.strike, + Contract.right, + func.count(Contract.id).label("n"), + ) + .join(Contract, Contract.snapshot_id == Snapshot.id) + .group_by( + Snapshot.ticker_id, + Snapshot.snapshot_date, + Contract.expiry, + Contract.strike, + Contract.right, + ) + .having(func.count(Contract.id) > 1) + ).all() + assert duplicates == [] + + def test_the_database_rejects_a_duplicate_even_without_the_upsert( + self, session: Session, provider: FixtureProvider + ) -> None: + """Idempotency is a constraint, not merely careful application code.""" + run(session, provider) + existing = session.scalars(select(Contract)).first() + assert existing is not None + + session.add( + Contract( + snapshot_id=existing.snapshot_id, + expiry=existing.expiry, + strike=existing.strike, + right=existing.right, + time_to_expiry=existing.time_to_expiry, + moneyness=existing.moneyness, + iv_status="ok", + ) + ) + with pytest.raises(IntegrityError): + session.flush() + session.rollback() + + def test_two_snapshots_for_one_ticker_on_one_day_are_rejected( + self, session: Session, provider: FixtureProvider + ) -> None: + run(session, provider) + ticker = session.scalar(select(Ticker).where(Ticker.symbol == "AAPL")) + assert ticker is not None + + session.add( + Snapshot( + ticker_id=ticker.id, + snapshot_date=market_date(), + as_of=datetime.now(UTC), + provider="fixture", + spot=1.0, + risk_free_rate=RATE, + dividend_yield=0.0, + ) + ) + with pytest.raises(IntegrityError): + session.flush() + session.rollback() + + def test_a_rerun_refreshes_changed_values_in_place( + self, session: Session, provider: FixtureProvider + ) -> None: + """Re-running must update, not merely skip -- a refresh has to refresh.""" + run(session, provider) + contract = session.scalars(select(Contract).where(Contract.bid.is_not(None))).first() + assert contract is not None + original_bid = contract.bid + + contract.bid = -999.0 + session.flush() + + run(session, provider) + session.expire_all() + refreshed = session.get(Contract, contract.id) + assert refreshed is not None + assert refreshed.bid == pytest.approx(original_bid) + + +class TestSeparateDays: + def test_different_days_accumulate(self, session: Session, provider: FixtureProvider) -> None: + base = HISTORY_START + for offset in range(3): + run(session, provider, as_of=base + timedelta(days=offset)) + + assert session.scalar(select(func.count(Snapshot.id))) == 3 + days, first, last = history_depth(session, "AAPL") + assert days == 3 + assert first == market_date(base) + assert last == market_date(base + timedelta(days=2)) + + def test_different_tickers_do_not_collide( + self, session: Session, provider: FixtureProvider + ) -> None: + run(session, provider, "AAPL") + run(session, provider, "SPY") + assert session.scalar(select(func.count(Snapshot.id))) == 2 + assert contract_count(session, "AAPL") < contract_count(session) + + +class TestWhatIsStored: + def test_our_own_implied_vol_and_greeks_are_persisted( + self, session: Session, provider: FixtureProvider + ) -> None: + run(session, provider) + solved = session.scalars(select(Contract).where(Contract.iv.is_not(None))).first() + assert solved is not None + assert solved.iv is not None and 0 < solved.iv < 5 + assert solved.delta is not None + assert solved.vega is not None + assert solved.iv_status == "ok" + + def test_unsolvable_contracts_are_kept_with_a_reason( + self, session: Session, provider: FixtureProvider + ) -> None: + """A strike that cannot be priced is information, not noise to discard.""" + run(session, provider) + unsolved = session.scalars(select(Contract).where(Contract.iv.is_(None))).all() + assert unsolved, "the fixture contains stale deep-ITM quotes; some must fail" + for contract in unsolved: + assert contract.iv_status not in ("", "ok") + assert contract.bid is not None or contract.ask is not None or contract.last is not None + + def test_pricing_assumptions_are_stored_with_the_snapshot( + self, session: Session, provider: FixtureProvider + ) -> None: + """Without them a stored IV cannot be interpreted later.""" + run(session, provider) + snapshot = latest_snapshot(session, "AAPL") + assert snapshot is not None + assert snapshot.risk_free_rate == pytest.approx(RATE) + assert snapshot.dividend_yield == pytest.approx(0.0) + assert snapshot.provider == "fixture" + + def test_solve_rate_is_recorded(self, session: Session, provider: FixtureProvider) -> None: + result = run(session, provider) + snapshot = latest_snapshot(session, "AAPL") + assert snapshot is not None + assert snapshot.contract_count == result.contracts_written + assert snapshot.solved_count == result.contracts_solved + assert 0.0 < snapshot.solve_rate <= 1.0 + + def test_atm_iv_is_computed_and_plausible( + self, session: Session, provider: FixtureProvider + ) -> None: + result = run(session, provider) + assert result.atm_iv_30d is not None + assert 0.01 < result.atm_iv_30d < 3.0 + + def test_no_nan_reaches_the_database(self, session: Session, provider: FixtureProvider) -> None: + """pandas NA is not a SQLite value; it must be normalised to NULL.""" + import math + + run(session, provider) + for contract in session.scalars(select(Contract)).all(): + for field in ("bid", "ask", "last", "mid", "iv", "delta", "gamma", "vega"): + value = getattr(contract, field) + assert value is None or not math.isnan(value), f"{field} is NaN" + + +class TestWatchlistIntegration: + def test_snapshot_watchlist_covers_active_symbols_only( + self, session: Session, provider: FixtureProvider + ) -> None: + add_to_watchlist(session, "AAPL") + add_to_watchlist(session, "SPY") + remove_from_watchlist(session, "SPY") + + results = snapshot_watchlist(session, provider, RATE, 0.0) + assert [r.ticker for r in results] == ["AAPL"] + + def test_one_failing_ticker_does_not_abort_the_run( + self, session: Session, provider: FixtureProvider + ) -> None: + """A rate limit on one symbol must not cost the history of the others.""" + add_to_watchlist(session, "AAPL") + add_to_watchlist(session, "NOSUCHTICKER") + add_to_watchlist(session, "SPY") + + results = snapshot_watchlist(session, provider, RATE, 0.0) + by_symbol = {r.ticker: r for r in results} + + assert by_symbol["AAPL"].contracts_written > 0 + assert by_symbol["SPY"].contracts_written > 0 + assert by_symbol["NOSUCHTICKER"].errors + assert by_symbol["NOSUCHTICKER"].contracts_written == 0 + + def test_removing_a_symbol_keeps_its_history( + self, session: Session, provider: FixtureProvider + ) -> None: + """A soft delete: months of irreplaceable observations must survive.""" + add_to_watchlist(session, "AAPL") + run(session, provider) + before = contract_count(session, "AAPL") + + remove_from_watchlist(session, "AAPL") + + assert contract_count(session, "AAPL") == before + assert [t.symbol for t in list_watchlist(session)] == [] + assert [t.symbol for t in list_watchlist(session, include_inactive=True)] == ["AAPL"] + + def test_re_adding_reactivates_rather_than_duplicating( + self, session: Session, provider: FixtureProvider + ) -> None: + add_to_watchlist(session, "AAPL") + remove_from_watchlist(session, "AAPL") + add_to_watchlist(session, "AAPL") + + assert session.scalar(select(func.count(Ticker.id))) == 1 + assert [t.symbol for t in list_watchlist(session)] == ["AAPL"] + + def test_symbols_are_normalised(self, session: Session) -> None: + add_to_watchlist(session, " aapl ") + assert [t.symbol for t in list_watchlist(session)] == ["AAPL"] + + def test_removing_an_untracked_symbol_reports_false(self, session: Session) -> None: + assert remove_from_watchlist(session, "ZZZZ") is False + + +class TestIVHistoryAccumulation: + def test_rank_stays_unavailable_until_the_window_is_met( + self, session: Session, provider: FixtureProvider + ) -> None: + """The end-to-end version of the degraded state.""" + base = HISTORY_START + for offset in range(5): + run(session, provider, as_of=base + timedelta(days=offset)) + + result = iv_rank_for(session, "AAPL", min_history_days=20) + assert result.rank is None + assert result.reason == "insufficient history (5/20 days)" + + def test_rank_becomes_available_once_enough_days_exist( + self, session: Session, provider: FixtureProvider + ) -> None: + base = HISTORY_START + for offset in range(20): + run(session, provider, as_of=base + timedelta(days=offset)) + + observations = iv_observations(session, "AAPL") + assert len(observations) == 20 + # Every day replays the same fixture, so the series is flat by + # construction: the honest answer is "undefined", not a fabricated 50. + result = iv_rank_for(session, "AAPL", min_history_days=20) + assert result.days_available == 20 + assert result.status.value in ("ok", "degenerate_range") + + def test_observations_come_back_oldest_first( + self, session: Session, provider: FixtureProvider + ) -> None: + base = HISTORY_START + for offset in range(4): + run(session, provider, as_of=base + timedelta(days=offset)) + + dates = [o.observed_on for o in iv_observations(session, "AAPL")] + assert dates == sorted(dates) + + def test_unknown_symbol_has_no_history(self, session: Session) -> None: + assert iv_observations(session, "ZZZZ") == [] + assert history_depth(session, "ZZZZ") == (0, None, None) + + +class TestFailureModes: + def test_unknown_ticker_raises_a_provider_error( + self, session: Session, provider: FixtureProvider + ) -> None: + with pytest.raises(ProviderError): + run(session, provider, "NOSUCHTICKER") + + def test_nothing_is_written_when_the_snapshot_fails( + self, session: Session, provider: FixtureProvider + ) -> None: + """A half-written day would look complete to IV rank.""" + with pytest.raises(ProviderError): + run(session, provider, "NOSUCHTICKER") + assert contract_count(session) == 0 From 999ba45ba5c1addd2827d89fe6864d5926c02fae Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:07:36 -0400 Subject: [PATCH 05/11] M4: unpin mypy's python_version so the 3.12 matrix entry passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught this on the first run: pinning python_version = "3.11" made mypy reject numpy's own stubs under 3.12 with "Type statement is only supported in Python 3.12 and greater" — the stubs installed for 3.12 use PEP 695 syntax that a 3.11 target cannot parse. Following the running interpreter checks each matrix entry against the stubs actually installed for it. Python 3.11 was already fully green: lint, format, types, 459 tests, the migration up/down/up check and the offline CLI smoke test. Co-Authored-By: Claude Opus 5 (1M context) --- api/pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 6442ad6..965195c 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -110,7 +110,11 @@ ignore = [ "options_tool/analytics/implied_vol.py" = ["PLR0911"] [tool.mypy] -python_version = "3.11" +# Deliberately not pinned to a python_version. The CI matrix runs 3.11 and 3.12, +# and pinning to 3.11 made mypy reject numpy's own 3.12 stubs, which use PEP 695 +# `type` statements: "Type statement is only supported in Python 3.12 and +# greater". Letting mypy follow the interpreter it is running under checks each +# matrix entry against the stubs actually installed for it. warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true From 48039f699c11c78ea425847776eb445999eb67a8 Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:17:29 -0400 Subject: [PATCH 06/11] M5: Next.js front end, with the degraded state as a first-class view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pages — watchlist, chain, spread builder — verified end to end against a running API with real data, not just a passing build. The IV-rank card is the visible half of the project's central decision. When there is not enough stored history it renders "insufficient history (1/20 days)" with a progress bar toward the window and today's ATM IV alongside, at the same visual weight as a real reading. A blank would look like a loading bug and a zero would read as "volatility is at its lows"; neither is what the data says. Nullable analytics stay nullable the whole way to the screen. lib/format.ts renders every missing value as an em dash and nothing coalesces null to zero, so no component can print a zero that looks like a measurement. Chain rows whose IV did not solve are kept, greyed, and labelled with the reason in place of the number. lib/payoff.ts duplicates the Python payoff maths so the builder redraws instantly while a leg is edited. Duplication is exactly how two implementations drift, so both are pinned to the same golden fixture: vitest reads api/tests/fixtures/payoff_golden.json and reproduces all eleven structures to nine decimals, including the long put (finite profit) and the ratio spread (two breakevens, unlimited loss on a debit). Errors are typed end to end. The client distinguishes a throttled vendor (retryable, with a "this usually clears within a minute" note) from an unknown ticker (not retryable, so no retry button), and an unreachable API says how to start it rather than surfacing "Failed to fetch". Two fixes found by screenshotting the running app rather than trusting the build: Recharts animates lines on mount, so the first paint depended on wall time and rendered an empty chart — now isAnimationActive={false}, which is also the deterministic choice. And the presets carried no per-leg volatility, so a new user's first view was the today-curve's unavailable state rather than the working one. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 5 + README.md | 32 +- docs/images/chain.png | Bin 0 -> 233036 bytes docs/images/payoff.png | Bin 0 -> 120301 bytes docs/images/watchlist.png | Bin 0 -> 71739 bytes web/README.md | 22 + web/app/chain/[ticker]/page.tsx | 177 + web/app/globals.css | 16 + web/app/layout.tsx | 55 + web/app/page.tsx | 194 + web/app/payoff/page.tsx | 293 ++ web/components/ChainTable.tsx | 177 + web/components/IVRankCard.tsx | 122 + web/components/PayoffChart.tsx | 178 + web/components/states.tsx | 84 + web/eslint.config.mjs | 16 + web/lib/api.ts | 242 + web/lib/format.ts | 92 + web/lib/payoff.test.ts | 226 + web/lib/payoff.ts | 234 + web/next.config.ts | 14 + web/package-lock.json | 8117 +++++++++++++++++++++++++++++++ web/package.json | 33 + web/postcss.config.mjs | 5 + web/tsconfig.json | 22 + web/vitest.config.ts | 12 + 26 files changed, 10366 insertions(+), 2 deletions(-) create mode 100644 docs/images/chain.png create mode 100644 docs/images/payoff.png create mode 100644 docs/images/watchlist.png create mode 100644 web/README.md create mode 100644 web/app/chain/[ticker]/page.tsx create mode 100644 web/app/globals.css create mode 100644 web/app/layout.tsx create mode 100644 web/app/page.tsx create mode 100644 web/app/payoff/page.tsx create mode 100644 web/components/ChainTable.tsx create mode 100644 web/components/IVRankCard.tsx create mode 100644 web/components/PayoffChart.tsx create mode 100644 web/components/states.tsx create mode 100644 web/eslint.config.mjs create mode 100644 web/lib/api.ts create mode 100644 web/lib/format.ts create mode 100644 web/lib/payoff.test.ts create mode 100644 web/lib/payoff.ts create mode 100644 web/next.config.ts create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.mjs create mode 100644 web/tsconfig.json create mode 100644 web/vitest.config.ts diff --git a/.gitignore b/.gitignore index e5276c7..ca98b60 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,8 @@ data/ .DS_Store .idea/ .vscode/ + +# Next.js +.next/ +out/ +next-env.d.ts diff --git a/README.md b/README.md index bafc3a4..b119f50 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,16 @@ A personal options-analytics tool. Option chains with **Black-Scholes greeks and implied volatility computed locally**, and an IV-rank history the tool builds itself by snapshotting chains daily. +![Option chain with self-computed greeks, and IV rank honestly reporting insufficient history](docs/images/chain.png) + > Not investment advice. A personal analytics tool, built for my own use. +Two details in that screenshot are the whole project. Every greek in the table +was computed here rather than read from the data vendor. And the IV-rank panel +says **"insufficient history (1/20 days)"** instead of showing a number — because +on day one there is no honest rank to show, and a zero would read as "volatility +is at its lows". + --- ## Why I built it @@ -47,8 +55,8 @@ rather than rendering a misleading zero. | M2 — persistence + daily snapshots + IV rank | done | | M3 — FastAPI | done | | M4 — tests + CI | done | -| M5 — Next.js front end | in progress | -| M6 — Docker + Linux deploy | planned | +| M5 — Next.js front end | done | +| M6 — Docker + Linux deploy | in progress | | M7 — polish (earnings flag, screener, CSV export) | planned | --- @@ -163,6 +171,26 @@ rather than read off the plotted grid, so `"max_loss": null` means genuinely unlimited — and a long put's profit is correctly reported as large but finite, because the underlying cannot fall below zero. +### Run the front end + +```bash +cd web +npm install +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 npm run dev +``` + +Three pages: a watchlist showing how much history each symbol has accumulated, a +chain view, and a spread builder. + +![Spread builder showing P/L at expiry and today](docs/images/payoff.png) + +The payoff maths exists twice — once in Python, once in TypeScript — so the +builder redraws instantly while you edit a leg instead of waiting on a request +per keystroke. That is exactly the setup where two implementations quietly drift +apart, so **both are tested against the same golden fixture** +(`api/tests/fixtures/payoff_golden.json`, generated by the Python engine and read +by the vitest suite). If they ever disagree, CI goes red. + --- ## How the IV history works diff --git a/docs/images/chain.png b/docs/images/chain.png new file mode 100644 index 0000000000000000000000000000000000000000..dff10da5a40292dd666e699eaec2dd7b3a8927bf GIT binary patch literal 233036 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 RJ36hh5IMEyMbAv${tp%}2MhoJ literal 0 HcmV?d00001 diff --git a/docs/images/watchlist.png b/docs/images/watchlist.png new file mode 100644 index 0000000000000000000000000000000000000000..ffd01c5379c09256a13d56384e4b2749decc9fa8 GIT binary patch literal 71739 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 literal 0 HcmV?d00001 diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..6aeb07c --- /dev/null +++ b/web/README.md @@ -0,0 +1,22 @@ +# web + +Next.js front end for the options analytics API. + +```bash +npm install +NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 npm run dev +``` + +The API must be running separately (`cd ../api && uvicorn options_tool.api.main:app --reload`). + +## Notes + +- `lib/payoff.ts` is a **second implementation** of the payoff maths that already + exists in Python, kept so the spread builder redraws instantly while editing a + leg. The API remains the source of truth. Both are tested against the same + golden fixture (`../api/tests/fixtures/payoff_golden.json`), so they cannot + drift apart without `npm run test:run` going red. +- Nullable analytics stay nullable all the way to the screen. `lib/format.ts` + renders a missing value as an em dash; nothing coalesces null to zero, because + a zero IV rank reads as "volatility is at its lows" and a null one means "not + enough history yet". diff --git a/web/app/chain/[ticker]/page.tsx b/web/app/chain/[ticker]/page.tsx new file mode 100644 index 0000000..f500ab4 --- /dev/null +++ b/web/app/chain/[ticker]/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +/** + * Chain view for one ticker: IV rank alongside the full chain. + * + * The two are shown together deliberately — the chain tells you what volatility + * is being priced right now, and IV rank (when there is enough history) tells + * you whether that is high or low for this name. + */ + +import Link from "next/link"; +import { use, useCallback, useEffect, useState } from "react"; + +import { api, type ChainResponse, type IVRankResponse } from "@/lib/api"; +import { days, percent, shortDate } from "@/lib/format"; +import { ChainTable } from "@/components/ChainTable"; +import { IVRankCard } from "@/components/IVRankCard"; +import { ErrorState, Skeleton } from "@/components/states"; + +export default function ChainPage({ + params, +}: { + params: Promise<{ ticker: string }>; +}) { + const { ticker } = use(params); + const symbol = ticker.toUpperCase(); + + const [chain, setChain] = useState(null); + const [ivRank, setIvRank] = useState(null); + const [expiry, setExpiry] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback( + async (targetExpiry?: string) => { + setLoading(true); + setError(null); + try { + // IV rank reads only stored history, so it must not fail just because + // the vendor is throttling the live chain. + const [chainResult, rankResult] = await Promise.allSettled([ + api.chain(symbol, targetExpiry ? { expiry: targetExpiry } : undefined), + api.ivRank(symbol), + ]); + + if (chainResult.status === "fulfilled") { + setChain(chainResult.value); + setExpiry(chainResult.value.expiry); + } else { + setError(chainResult.reason); + } + + setIvRank(rankResult.status === "fulfilled" ? rankResult.value : null); + } finally { + setLoading(false); + } + }, + [symbol], + ); + + useEffect(() => { + void load(); + }, [load]); + + return ( +
+
+

+ {symbol} +

+ {chain && ( + <> + + {chain.spot.toFixed(2)} + + + as of {shortDate(chain.as_of)} · via {chain.provider} + + + )} + + Build a spread → + +
+ + {error != null && void load()} />} + + {chain && ( +
+ + + {days(chain.time_to_expiry)} to expiry · r ={" "} + {percent(chain.risk_free_rate, 2)} · q ={" "} + {percent(chain.dividend_yield, 2)} + +
+ )} + +
+
+ {ivRank ? ( + + ) : loading ? ( + + ) : ( +

+ IV rank is unavailable — no stored history for {symbol} yet. Take a + snapshot from the watchlist to begin accumulating it. +

+ )} + + {chain && ( +
+

This chain

+
+ + + +
+ {Object.keys(chain.summary.unsolved_reasons).length > 0 && ( +

+ Unsolved:{" "} + {Object.entries(chain.summary.unsolved_reasons) + .map(([reason, count]) => `${count} ${reason.replace(/_/g, " ")}`) + .join(", ")} + . +

+ )} +
+ )} +
+ +
+ {loading && !chain ? ( + + ) : chain ? ( + + ) : null} +
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..ede5649 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,16 @@ +@import "tailwindcss"; + + +:root { + color-scheme: dark; +} + +body { + @apply bg-slate-950 text-slate-200 antialiased; +} + +/* Tabular figures everywhere numbers are compared down a column. Proportional + digits make a strike ladder impossible to scan. */ +.tnum { + font-variant-numeric: tabular-nums; +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..0031840 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +import "./globals.css"; + +export const metadata: Metadata = { + title: "Options Analytics", + description: + "Personal options analytics with self-computed Black-Scholes greeks and a locally accumulated implied-volatility history.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + +
+ +
+ +
{children}
+ +
+
+

+ Not investment advice. A personal analytics tool. +

+

+ Market data is delayed and comes from an unofficial source. Prices + and greeks are model estimates: Black-Scholes assumes European + exercise, but US equity options are American and can be exercised + early. Nothing here is a recommendation to trade. +

+
+
+ + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..66e1531 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,194 @@ +"use client"; + +/** + * Watchlist. + * + * The `stored_days` column is the important one: it shows how much IV history + * each symbol has accumulated, which is what determines whether its IV rank is + * available at all. + */ + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; + +import { api, type WatchlistItem } from "@/lib/api"; +import { shortDate } from "@/lib/format"; +import { EmptyState, ErrorState, Skeleton } from "@/components/states"; + +const MIN_HISTORY_DAYS = 20; + +export default function WatchlistPage() { + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + const [symbol, setSymbol] = useState(""); + const [busy, setBusy] = useState(false); + + const load = useCallback(async () => { + setError(null); + try { + setItems(await api.watchlist()); + } catch (cause) { + setError(cause); + setItems(null); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function add(event: React.FormEvent) { + event.preventDefault(); + const trimmed = symbol.trim().toUpperCase(); + if (!trimmed || busy) return; + + setBusy(true); + setError(null); + try { + await api.addToWatchlist(trimmed); + setSymbol(""); + await load(); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + async function remove(target: string) { + setBusy(true); + try { + await api.removeFromWatchlist(target); + await load(); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + async function snapshot(target: string) { + setBusy(true); + setError(null); + try { + await api.snapshot(target); + await load(); + } catch (cause) { + setError(cause); + } finally { + setBusy(false); + } + } + + return ( +
+
+

+ Watchlist +

+

+ Each snapshot adds one day of implied-volatility history. IV rank + becomes available at {MIN_HISTORY_DAYS} days. +

+
+ +
+ setSymbol(e.target.value)} + placeholder="Add a ticker, e.g. SPY" + aria-label="Ticker symbol" + maxLength={16} + className="w-56 rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm uppercase placeholder:normal-case placeholder:text-slate-600 focus:border-sky-600 focus:outline-none" + /> + +
+ + {error != null && } + + {items === null && !error && } + + {items !== null && items.length === 0 && ( + + Add one above, then take a snapshot to start building its volatility + history. + + )} + + {items !== null && items.length > 0 && ( +
+ + + + + + + + + + + {items.map((item) => ( + + + + + + + ))} + +
SymbolStored daysHistoryActions
+ + {item.symbol} + + + = MIN_HISTORY_DAYS + ? "text-slate-200" + : "text-slate-500" + } + > + {item.stored_days} + + /{MIN_HISTORY_DAYS} + + {item.stored_days === 0 + ? "no snapshots yet" + : `${shortDate(item.first_snapshot)} — ${shortDate(item.last_snapshot)}`} + + + +
+
+ )} + +

+ Removing a symbol stops the daily job fetching it but keeps its stored + history — those observations cannot be re-downloaded from anywhere. +

+
+ ); +} diff --git a/web/app/payoff/page.tsx b/web/app/payoff/page.tsx new file mode 100644 index 0000000..81125ad --- /dev/null +++ b/web/app/payoff/page.tsx @@ -0,0 +1,293 @@ +"use client"; + +/** + * Spread builder. + * + * Editing a leg redraws immediately from `lib/payoff.ts` (the local preview), + * while the server is asked for the authoritative curve — including the + * today-line, which needs Black-Scholes and a volatility per leg that only the + * API can solve from the live chain. Both implementations are pinned to the same + * golden fixture, so the instant preview cannot disagree with what arrives. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { api, type PayoffResponse } from "@/lib/api"; +import { + analysePayoff, + describePosition, + LegError, + type Leg, + type LegKind, +} from "@/lib/payoff"; +import { money } from "@/lib/format"; +import { PayoffChart } from "@/components/PayoffChart"; +import { ErrorState } from "@/components/states"; + +// Presets carry a volatility per leg so the today-curve is available on first +// load. Without one the expiry curve still draws, but the more interesting line +// -- what the position is worth if you close it now -- would be missing, and a +// new user would see the degraded state before ever seeing the working one. +const PRESETS: Record = { + "Bull call spread": [ + { kind: "call", quantity: 1, premium: 5, strike: 100, volatility: 0.25 }, + { kind: "call", quantity: -1, premium: 1.5, strike: 110, volatility: 0.23 }, + ], + "Iron condor": [ + { kind: "put", quantity: 1, premium: 0.5, strike: 90, volatility: 0.3 }, + { kind: "put", quantity: -1, premium: 1.5, strike: 95, volatility: 0.27 }, + { kind: "call", quantity: -1, premium: 1.6, strike: 105, volatility: 0.24 }, + { kind: "call", quantity: 1, premium: 0.6, strike: 110, volatility: 0.23 }, + ], + "Covered call": [ + { kind: "stock", quantity: 100, premium: 100 }, + { kind: "call", quantity: -1, premium: 3, strike: 105, volatility: 0.24 }, + ], + "Long straddle": [ + { kind: "call", quantity: 1, premium: 5, strike: 100, volatility: 0.25 }, + { kind: "put", quantity: 1, premium: 4.5, strike: 100, volatility: 0.25 }, + ], +}; + +export default function PayoffPage() { + const [legs, setLegs] = useState(PRESETS["Bull call spread"] as Leg[]); + const [spot, setSpot] = useState(100); + const [timeToExpiry, setTimeToExpiry] = useState(0.25); + const [server, setServer] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + // Instant local shape, so dragging a strike does not wait on the network. + const preview = useMemo(() => { + try { + return { shape: analysePayoff(legs), error: null as string | null }; + } catch (cause) { + return { + shape: null, + error: cause instanceof LegError ? cause.message : "Invalid position.", + }; + } + }, [legs]); + + const fetchCurve = useCallback(async () => { + if (preview.shape == null) return; + setPending(true); + setError(null); + try { + setServer( + await api.payoff({ + spot, + time_to_expiry: timeToExpiry, + points: 161, + legs: legs.map((leg) => ({ + kind: leg.kind, + quantity: leg.quantity, + premium: leg.premium, + strike: leg.kind === "stock" ? null : leg.strike, + volatility: leg.volatility ?? null, + })), + }), + ); + } catch (cause) { + setError(cause); + setServer(null); + } finally { + setPending(false); + } + }, [legs, spot, timeToExpiry, preview.shape]); + + useEffect(() => { + const timer = setTimeout(() => void fetchCurve(), 300); + return () => clearTimeout(timer); + }, [fetchCurve]); + + function update(index: number, patch: Partial) { + setLegs((current) => + current.map((leg, i) => (i === index ? { ...leg, ...patch } : leg)), + ); + } + + function changeKind(index: number, kind: LegKind) { + update(index, { kind, strike: kind === "stock" ? null : (legs[index]?.strike ?? 100) }); + } + + return ( +
+
+

+ Spread builder +

+

+ {describePosition(legs) || "Add a leg to begin."} +

+
+ +
+ {Object.keys(PRESETS).map((name) => ( + + ))} +
+ +
+
+
+ + +
+ +
+ {legs.map((leg, index) => ( +
+
+ + +
+ +
+ update(index, { quantity: Math.round(value) })} + step={1} + /> + {leg.kind !== "stock" && ( + update(index, { strike: value })} + step={1} + /> + )} + update(index, { premium: value })} + step={0.05} + /> +
+

+ Negative quantity is a short leg. Premium is always positive. +

+
+ ))} +
+ + + + {preview.error && ( +

+ {preview.error} +

+ )} + + {preview.shape && ( +

+ Local preview:{" "} + {preview.shape.netCost >= 0 ? "net debit" : "net credit"}{" "} + {money(Math.abs(preview.shape.netCost))} + {pending && " · fetching the priced curve…"} +

+ )} +
+ +
+ {error != null && void fetchCurve()} />} + {server ? ( + + ) : ( + !error && ( +
+ {preview.error ? "Fix the position to see a curve." : "Pricing…"} +
+ ) + )} +
+
+ +

+ Leave a leg’s volatility unset and the server solves it from the + live chain when a ticker is supplied. Where no volatility can be + established, the today-curve is omitted rather than drawn at an assumed + one — the expiry curve needs no volatility and is always exact. +

+
+ ); +} + +function NumberField({ + label, + value, + onChange, + step, +}: { + label: string; + value: number; + onChange: (value: number) => void; + step: number; +}) { + return ( + + ); +} diff --git a/web/components/ChainTable.tsx b/web/components/ChainTable.tsx new file mode 100644 index 0000000..f9ab1e5 --- /dev/null +++ b/web/components/ChainTable.tsx @@ -0,0 +1,177 @@ +"use client"; + +/** + * The option chain. + * + * Rows whose implied volatility did not solve are **kept and labelled**, not + * dropped: a strike the model cannot price is information, and silently removing + * it would make the chain look healthier than it is. The header carries the + * solve rate for the same reason. + */ + +import { useMemo, useState } from "react"; + +import type { Contract } from "@/lib/api"; +import { + integer, + ivStatusLabel, + money, + number, + percent, + thetaPerDay, + vegaPerPoint, +} from "@/lib/format"; + +type SortKey = "strike" | "iv" | "delta" | "volume" | "open_interest"; + +export function ChainTable({ + contracts, + spot, +}: { + contracts: Contract[]; + spot: number; +}) { + const [right, setRight] = useState<"call" | "put">("call"); + const [sortKey, setSortKey] = useState("strike"); + const [nearOnly, setNearOnly] = useState(true); + + const rows = useMemo(() => { + let filtered = contracts.filter((c) => c.right === right); + if (nearOnly) { + filtered = filtered.filter((c) => Math.abs(c.moneyness - 1) <= 0.15); + } + return [...filtered].sort((a, b) => { + if (sortKey === "strike") return a.strike - b.strike; + // Unsolved rows sort last rather than being treated as zero. + const left = a[sortKey] ?? -Infinity; + const rightValue = b[sortKey] ?? -Infinity; + return rightValue - left; + }); + }, [contracts, right, sortKey, nearOnly]); + + const unsolved = rows.filter((r) => r.iv == null).length; + + return ( +
+
+
+ {(["call", "put"] as const).map((option) => ( + + ))} +
+ + + + + + + {rows.length - unsolved}/{rows.length} strikes solved + +
+ +
+ + + + + + + + + + + + + + + + + {rows.map((contract) => { + const solved = contract.iv != null; + const atTheMoney = Math.abs(contract.strike - spot) < 2.5; + return ( + + + + + + + + + + + + + ); + })} + +
StrikeBidAskIVDeltaGamma + Vega + + Theta + VolOI
+ {number(contract.strike)} + {money(contract.bid)}{money(contract.ask)} + {solved ? ( + percent(contract.iv) + ) : ( + + {ivStatusLabel(contract.iv_status)} + + )} + {number(contract.delta, 4)}{number(contract.gamma, 5)}{vegaPerPoint(contract.vega)}{thetaPerDay(contract.theta)}{integer(contract.volume)}{integer(contract.open_interest)}
+
+ +
+ Vega is per volatility point; theta is per calendar day. Greyed rows had + no solvable implied volatility — the reason is shown in place of the + number, and no greek is estimated for them. +
+
+ ); +} diff --git a/web/components/IVRankCard.tsx b/web/components/IVRankCard.tsx new file mode 100644 index 0000000..d74748f --- /dev/null +++ b/web/components/IVRankCard.tsx @@ -0,0 +1,122 @@ +/** + * IV rank, including the state where there is no rank to show. + * + * This component is the visible half of the project's core design decision. IV + * rank needs history the tool accumulates one day at a time, and before there is + * enough of it there is no honest number to render. So the degraded state gets + * the same visual weight as a real reading — a progress bar toward the required + * window and an explicit day count — rather than a blank, a dash, or the zero + * that a naive implementation would show and that reads as "volatility is at its + * lows". + */ + +import type { IVRankResponse } from "@/lib/api"; +import { percent, shortDate } from "@/lib/format"; + +function rankTone(rank: number): string { + if (rank >= 70) return "text-amber-300"; + if (rank <= 30) return "text-sky-300"; + return "text-slate-200"; +} + +export function IVRankCard({ data }: { data: IVRankResponse }) { + if (data.rank == null) { + return ; + } + + return ( +
+
+

IV rank

+ + {data.days_available} days · {data.window_days}-day window + +
+ +

+ {data.rank.toFixed(1)} +

+ +
+ + + + +
+ +

+ Ranked against {data.days_available} stored days,{" "} + {shortDate(data.first_observed)} to {shortDate(data.last_observed)}. + Percentile is the share of those days below today, and is the more robust + of the two — a single outlier sets the range that rank is measured + against. +

+
+ ); +} + +function UnavailableRank({ data }: { data: IVRankResponse }) { + const progress = + data.days_required > 0 + ? Math.min(data.days_available / data.days_required, 1) + : 0; + const insufficient = data.status === "insufficient_history"; + + return ( +
+
+

IV rank

+ + unavailable + +
+ +

+ {insufficient + ? `${data.days_available} / ${data.days_required} days` + : "No rank"} +

+

{data.reason}

+ + {insufficient && ( +
+
+
+ )} + + {data.current_iv != null && ( +

+ Today’s at-the-money IV is{" "} + {percent(data.current_iv)}. + That much is known — what is missing is the history to rank it against. +

+ )} + +

+ Historical implied volatility is not available from free data sources, so + this tool builds its own: one snapshot per day, accumulating from the + first run. Until there are {data.days_required} days, any rank would be + an artefact of too few points rather than a measurement. +

+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( + <> +
{label}
+
{value}
+ + ); +} diff --git a/web/components/PayoffChart.tsx b/web/components/PayoffChart.tsx new file mode 100644 index 0000000..fcede7a --- /dev/null +++ b/web/components/PayoffChart.tsx @@ -0,0 +1,178 @@ +"use client"; + +/** + * Payoff diagram: P/L at expiry and, where it can be computed, P/L today. + * + * The today-curve is only drawn when the server could price every leg. When a + * leg has no implied volatility it is **omitted with a stated reason** rather + * than drawn at an assumed vol — on a chart the two look identical, and there + * would be nothing to tell the reader which they were looking at. + */ + +import { + CartesianGrid, + Legend, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import type { PayoffResponse } from "@/lib/api"; +import { money, signedMoney } from "@/lib/format"; + +export function PayoffChart({ data }: { data: PayoffResponse }) { + const points = data.underlying_prices.map((price, i) => ({ + price, + expiry: data.pnl_at_expiry[i], + today: data.pnl_today?.[i] ?? null, + })); + + return ( +
+
+ + + + value.toFixed(0)} + stroke="#64748b" + fontSize={12} + label={{ + value: "Underlying at expiry", + position: "insideBottom", + offset: -4, + fill: "#64748b", + fontSize: 12, + }} + /> + `${value >= 0 ? "" : "−"}${Math.abs(value).toFixed(0)}`} + stroke="#64748b" + fontSize={12} + width={64} + /> + `Underlying ${money(value)}`} + // Recharts types the tooltip value as a loose ValueType union, so + // it is narrowed here rather than asserted away. + formatter={(value, name) => [ + typeof value === "number" ? signedMoney(value) : "—", + String(name), + ]} + /> + + + {data.spot != null && ( + + )} + {data.breakevens.map((breakeven) => ( + + ))} + + {data.pnl_today && ( + + )} + + +
+ + {data.today_unavailable_reason && ( +

+ Today’s curve is not shown: {data.today_unavailable_reason} +

+ )} + +
+ = 0 ? "Net debit" : "Net credit"} + value={money(Math.abs(data.net_cost))} + /> + + + b.toFixed(2)).join(" / ") + } + /> +
+ + {(data.unlimited_loss || data.unlimited_profit) && ( +

+ “Unlimited” is reported from the position’s exact + slope beyond the outermost strike, not from where this chart happens to + stop. Only the upside can be unbounded — the underlying can rise + without limit but cannot fall below zero. +

+ )} +
+ ); +} + +function Stat({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: string; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/web/components/states.tsx b/web/components/states.tsx new file mode 100644 index 0000000..ec20f8f --- /dev/null +++ b/web/components/states.tsx @@ -0,0 +1,84 @@ +/** + * Loading, empty and error states. + * + * Every async surface in this app uses these, because the failure modes here are + * specific and worth naming: the API might not be running, the vendor might be + * throttling (retryable), or a ticker might simply not exist (not retryable). + * A spinner that never resolves tells the user none of that. + */ + +import { ApiError } from "@/lib/api"; + +export function Skeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ Loading… + {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ); +} + +export function ErrorState({ + error, + onRetry, +}: { + error: unknown; + onRetry?: () => void; +}) { + const apiError = error instanceof ApiError ? error : null; + const message = + apiError?.message ?? + (error instanceof Error ? error.message : "Something went wrong."); + + // Only offer a retry where retrying could actually help. A button that + // re-runs a request guaranteed to fail the same way is worse than none. + const canRetry = onRetry && (apiError?.retryable ?? true); + + return ( +
+

{message}

+ {apiError?.code === "provider_rate_limited" && ( +

+ This is normal for the free data source. It usually clears within a + minute. +

+ )} + {apiError?.code === "expiry_not_found" && + Array.isArray(apiError.detail?.available_expiries) && ( +

+ Available expiries:{" "} + {(apiError.detail.available_expiries as string[]).join(", ")} +

+ )} + {canRetry && ( + + )} +
+ ); +} + +export function EmptyState({ + title, + children, +}: { + title: string; + children?: React.ReactNode; +}) { + return ( +
+

{title}

+ {children &&
{children}
} +
+ ); +} diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..d4c222e --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const compat = new FlatCompat({ + baseDirectory: dirname(fileURLToPath(import.meta.url)), +}); + +const config = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [".next/**", "node_modules/**", "next-env.d.ts"], + }, +]; + +export default config; diff --git a/web/lib/api.ts b/web/lib/api.ts new file mode 100644 index 0000000..460ec28 --- /dev/null +++ b/web/lib/api.ts @@ -0,0 +1,242 @@ +/** + * Typed client for the analytics API. + * + * Two things this file insists on: + * + * 1. **Nullable fields stay nullable.** `iv`, `delta` and `rank` are + * `number | null`, and nothing here coalesces a null to zero. A zero IV rank + * reads as "volatility is at its lows"; a null one means "we do not know + * yet", and the UI must be able to tell them apart. + * 2. **Errors are values, not thrown strings.** The API returns a typed body + * with a machine-readable `code`; `ApiError` carries it through so a + * component can distinguish a throttled vendor from a bad ticker. + */ + +export const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; + +export interface ErrorBody { + code: string; + message: string; + detail?: Record | null; +} + +export class ApiError extends Error { + readonly code: string; + readonly status: number; + readonly detail?: Record | null; + + constructor(body: ErrorBody, status: number) { + super(body.message); + this.name = "ApiError"; + this.code = body.code; + this.status = status; + this.detail = body.detail; + } + + /** Whether retrying the same request could plausibly succeed. */ + get retryable(): boolean { + return this.code === "provider_rate_limited" || this.status >= 500; + } +} + +async function request(path: string, init?: RequestInit): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + headers: { "Content-Type": "application/json", ...init?.headers }, + cache: "no-store", + }); + } catch { + // The API not being up at all is the most common failure in development, + // and "Failed to fetch" tells the user nothing actionable, so the original + // cause is deliberately replaced with something a reader can act on. + throw new ApiError( + { + code: "api_unreachable", + message: `Could not reach the API at ${API_BASE_URL}. Is it running? Try: cd api && uvicorn options_tool.api.main:app --reload`, + }, + 0, + ); + } + + if (response.status === 204) return undefined as T; + + const body = await response.json().catch(() => null); + + if (!response.ok) { + if (body && typeof body === "object" && "code" in body) { + throw new ApiError(body as ErrorBody, response.status); + } + // FastAPI's own 422 validation errors have a different shape. + throw new ApiError( + { + code: "invalid_request", + message: + response.status === 422 + ? "The request was rejected as invalid." + : `Request failed with status ${response.status}.`, + detail: body as Record | null, + }, + response.status, + ); + } + + return body as T; +} + +// --- responses ------------------------------------------------------------- + +export interface WatchlistItem { + symbol: string; + active: boolean; + note: string | null; + stored_days: number; + first_snapshot: string | null; + last_snapshot: string | null; +} + +export interface Contract { + expiry: string; + strike: number; + right: "call" | "put"; + bid: number | null; + ask: number | null; + last: number | null; + mid: number | null; + volume: number | null; + open_interest: number | null; + price: number | null; + price_source: "mid" | "last" | null; + time_to_expiry: number; + moneyness: number; + in_the_money: boolean; + iv: number | null; + iv_status: string; + delta: number | null; + gamma: number | null; + vega: number | null; + theta: number | null; + rho: number | null; +} + +export interface ChainSummary { + contracts: number; + solved: number; + solve_rate: number; + atm_iv: number | null; + total_volume: number; + total_open_interest: number; + unsolved_reasons: Record; +} + +export interface ChainResponse { + ticker: string; + expiry: string; + spot: number; + as_of: string; + provider: string; + risk_free_rate: number; + dividend_yield: number; + time_to_expiry: number; + available_expiries: string[]; + summary: ChainSummary; + contracts: Contract[]; + disclaimer: string; +} + +export interface IVRankResponse { + ticker: string; + rank: number | null; + percentile: number | null; + current_iv: number | null; + iv_min: number | null; + iv_max: number | null; + iv_mean: number | null; + days_available: number; + days_required: number; + window_days: number; + status: string; + reason: string; + first_observed: string | null; + last_observed: string | null; +} + +export interface PayoffResponse { + underlying_prices: number[]; + pnl_at_expiry: number[]; + pnl_today: number[] | null; + today_unavailable_reason: string | null; + net_cost: number; + breakevens: number[]; + max_profit: number | null; + max_loss: number | null; + unlimited_profit: boolean; + unlimited_loss: boolean; + spot: number | null; + time_to_expiry: number; +} + +export interface SnapshotResponse { + ticker: string; + snapshot_date: string; + contracts_written: number; + contracts_solved: number; + solve_rate: number; + expiries: string[]; + atm_iv_30d: number | null; + created: boolean; + errors: string[]; +} + +export interface HealthResponse { + status: string; + version: string; + provider: string; + database_reachable: boolean; +} + +// --- calls ----------------------------------------------------------------- + +export const api = { + health: () => request("/health"), + + watchlist: () => + request<{ items: WatchlistItem[] }>("/watchlist").then((r) => r.items), + + addToWatchlist: (symbol: string) => + request("/watchlist", { + method: "POST", + body: JSON.stringify({ symbol }), + }), + + removeFromWatchlist: (symbol: string) => + request(`/watchlist/${encodeURIComponent(symbol)}`, { method: "DELETE" }), + + chain: (ticker: string, options?: { expiry?: string; nearTheMoney?: number }) => { + const params = new URLSearchParams(); + if (options?.expiry) params.set("expiry", options.expiry); + if (options?.nearTheMoney != null) { + params.set("near_the_money", String(options.nearTheMoney)); + } + const query = params.toString(); + return request( + `/chain/${encodeURIComponent(ticker)}${query ? `?${query}` : ""}`, + ); + }, + + ivRank: (ticker: string) => + request(`/ivrank/${encodeURIComponent(ticker)}`), + + snapshot: (ticker: string) => + request(`/snapshot/${encodeURIComponent(ticker)}`, { + method: "POST", + }), + + payoff: (payload: unknown) => + request("/payoff", { + method: "POST", + body: JSON.stringify(payload), + }), +}; diff --git a/web/lib/format.ts b/web/lib/format.ts new file mode 100644 index 0000000..ae0faf7 --- /dev/null +++ b/web/lib/format.ts @@ -0,0 +1,92 @@ +/** + * Display formatting. + * + * The rule running through this file: **a missing value renders as an em dash, + * never as 0.00 or 0.0%.** Every formatter takes `number | null | undefined` and + * returns a placeholder for the missing case, so no component has to remember + * to guard — and none can accidentally print a zero that looks like a reading. + */ + +export const MISSING = "—"; + +export function money(value: number | null | undefined, digits = 2): string { + if (value == null || !Number.isFinite(value)) return MISSING; + return value.toLocaleString("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +export function signedMoney(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return MISSING; + return `${value >= 0 ? "+" : "−"}${money(Math.abs(value))}`; +} + +export function percent(value: number | null | undefined, digits = 1): string { + if (value == null || !Number.isFinite(value)) return MISSING; + return `${(value * 100).toFixed(digits)}%`; +} + +export function number( + value: number | null | undefined, + digits = 2, +): string { + if (value == null || !Number.isFinite(value)) return MISSING; + return value.toFixed(digits); +} + +export function integer(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return MISSING; + return Math.round(value).toLocaleString("en-US"); +} + +/** Theta is quoted per calendar day, not per year. */ +export function thetaPerDay(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return MISSING; + return (value / 365).toFixed(3); +} + +/** Vega is quoted per volatility point, not per 1.00 of vol. */ +export function vegaPerPoint(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return MISSING; + return (value / 100).toFixed(3); +} + +export function days(years: number | null | undefined): string { + if (years == null || !Number.isFinite(years)) return MISSING; + const value = years * 365; + return value < 1 ? `${(value * 24).toFixed(0)}h` : `${Math.round(value)}d`; +} + +export function shortDate(value: string | null | undefined): string { + if (!value) return MISSING; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); +} + +/** + * Plain-language label for why a contract has no implied volatility. + * + * Shown in place of the number, so the table never has an unexplained blank. + */ +export function ivStatusLabel(status: string): string { + const labels: Record = { + no_quote: "no quote", + non_positive_price: "no bid", + below_intrinsic: "stale quote", + above_max: "crossed", + expired: "expired", + no_bracket: "unsolvable", + not_converged: "no solution", + invalid_input: "bad data", + }; + return labels[status] ?? status.replace(/_/g, " "); +} diff --git a/web/lib/payoff.test.ts b/web/lib/payoff.test.ts new file mode 100644 index 0000000..f670245 --- /dev/null +++ b/web/lib/payoff.test.ts @@ -0,0 +1,226 @@ +/** + * The cross-language contract test. + * + * `api/tests/fixtures/payoff_golden.json` is generated by the Python engine and + * read by both suites. If this TypeScript preview and the Python implementation + * ever disagree, this file goes red — which is the only reason it is safe to + * have the same maths written twice. + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + analysePayoff, + buildPayoffCurve, + describePosition, + LegError, + legCost, + OPTION_MULTIPLIER, + payoffAtExpiry, + type Leg, +} from "./payoff"; + +interface GoldenCase { + name: string; + legs: Leg[]; + expected: { + net_cost: number; + breakevens: number[]; + max_profit: number | null; + max_loss: number | null; + unlimited_profit: boolean; + unlimited_loss: boolean; + upside_slope: number; + downside_slope: number; + underlying_prices: number[]; + pnl_at_expiry: number[]; + }; +} + +const golden = JSON.parse( + readFileSync( + fileURLToPath(new URL("../../api/tests/fixtures/payoff_golden.json", import.meta.url)), + "utf8", + ), +) as { + parameters: { + spot: number; + price_range: [number, number]; + points: number; + option_multiplier: number; + }; + cases: GoldenCase[]; +}; + +describe("golden file agreement with the Python engine", () => { + it("uses the same contract multiplier", () => { + expect(OPTION_MULTIPLIER).toBe(golden.parameters.option_multiplier); + }); + + it("covers the structures that are easy to get wrong", () => { + const names = golden.cases.map((c) => c.name); + expect(names).toContain("long_put"); + expect(names).toContain("covered_call"); + expect(names).toContain("call_ratio_spread"); + }); + + it.each(golden.cases.map((c) => [c.name, c] as const))( + "reproduces %s exactly", + (_name, testCase) => { + const { spot, price_range: range, points } = golden.parameters; + const curve = buildPayoffCurve(testCase.legs, spot, range, points); + const expected = testCase.expected; + + expect(curve.netCost).toBeCloseTo(expected.net_cost, 9); + expect(curve.maxProfit).toEqual( + expected.max_profit === null ? null : expect.closeTo(expected.max_profit, 9), + ); + expect(curve.maxLoss).toEqual( + expected.max_loss === null ? null : expect.closeTo(expected.max_loss, 9), + ); + expect(curve.upsideSlope).toBeCloseTo(expected.upside_slope, 9); + expect(curve.downsideSlope).toBeCloseTo(expected.downside_slope, 9); + + expect(curve.breakevens).toHaveLength(expected.breakevens.length); + curve.breakevens.forEach((value, i) => { + expect(value).toBeCloseTo(expected.breakevens[i] as number, 9); + }); + + expect(curve.pnlAtExpiry).toHaveLength(expected.pnl_at_expiry.length); + curve.pnlAtExpiry.forEach((value, i) => { + expect(value).toBeCloseTo(expected.pnl_at_expiry[i] as number, 9); + }); + }, + ); + + it("agrees on what is unlimited", () => { + for (const testCase of golden.cases) { + const shape = analysePayoff(testCase.legs); + expect(shape.maxProfit === null).toBe(testCase.expected.unlimited_profit); + expect(shape.maxLoss === null).toBe(testCase.expected.unlimited_loss); + } + }); +}); + +describe("bounded versus unbounded", () => { + it("a long put's profit is large but finite, because the stock stops at zero", () => { + const shape = analysePayoff([ + { kind: "put", quantity: 1, premium: 4, strike: 100 }, + ]); + expect(shape.maxProfit).toBeCloseTo(100 * OPTION_MULTIPLIER - 400, 9); + expect(shape.maxLoss).toBeCloseTo(-400, 9); + }); + + it("a naked short call loses without limit", () => { + const shape = analysePayoff([ + { kind: "call", quantity: -1, premium: 5, strike: 100 }, + ]); + expect(shape.maxLoss).toBeNull(); + expect(shape.maxProfit).toBeCloseTo(500, 9); + }); + + it("a covered call's loss is bounded even though P/L falls with the stock", () => { + const shape = analysePayoff([ + { kind: "stock", quantity: 100, premium: 100 }, + { kind: "call", quantity: -1, premium: 3, strike: 105 }, + ]); + expect(shape.maxLoss).toBeCloseTo(-9700, 9); + expect(shape.maxProfit).toBeCloseTo(800, 9); + }); +}); + +describe("breakevens", () => { + it("does not depend on the plotting grid", () => { + const legs: Leg[] = [ + { kind: "call", quantity: 1, premium: 5, strike: 100 }, + { kind: "call", quantity: -1, premium: 1.5, strike: 110 }, + ]; + const coarse = buildPayoffCurve(legs, 100, undefined, 5); + const fine = buildPayoffCurve(legs, 100, undefined, 1001); + expect(coarse.breakevens).toEqual(fine.breakevens); + expect(coarse.maxProfit).toEqual(fine.maxProfit); + }); + + it("finds one beyond the plotted range", () => { + const curve = buildPayoffCurve( + [{ kind: "call", quantity: 1, premium: 5, strike: 100 }], + 100, + [60, 102], + 11, + ); + expect(curve.breakevens).toHaveLength(1); + expect(curve.breakevens[0]).toBeCloseTo(105, 9); + expect(Math.max(...curve.underlyingPrices)).toBeLessThan(105); + }); + + it("finds both sides of a ratio spread", () => { + const shape = analysePayoff([ + { kind: "call", quantity: 1, premium: 5, strike: 100 }, + { kind: "call", quantity: -2, premium: 1.5, strike: 110 }, + ]); + expect(shape.breakevens).toHaveLength(2); + expect(shape.breakevens[0]).toBeCloseTo(102, 9); + expect(shape.breakevens[1]).toBeCloseTo(118, 9); + }); +}); + +describe("leg validation", () => { + it("rejects a zero quantity", () => { + expect(() => analysePayoff([{ kind: "call", quantity: 0, premium: 5, strike: 100 }])) + .toThrow(LegError); + }); + + it("explains that a short leg uses a negative quantity", () => { + expect(() => analysePayoff([{ kind: "call", quantity: 1, premium: -5, strike: 100 }])) + .toThrow(/negative quantity/); + }); + + it("rejects an option with no strike", () => { + expect(() => analysePayoff([{ kind: "put", quantity: 1, premium: 5 }])) + .toThrow(/positive strike/); + }); + + it("rejects a stock leg carrying a strike", () => { + expect(() => + analysePayoff([{ kind: "stock", quantity: 100, premium: 100, strike: 95 }]), + ).toThrow(/no strike/); + }); + + it("rejects an empty position", () => { + expect(() => analysePayoff([])).toThrow(/at least one leg/); + }); + + it("rejects a non-positive spot", () => { + expect(() => + buildPayoffCurve([{ kind: "call", quantity: 1, premium: 5, strike: 100 }], 0), + ).toThrow(/positive/); + }); +}); + +describe("cost and labelling", () => { + it("signs the cash flow from the quantity", () => { + expect(legCost({ kind: "call", quantity: 1, premium: 5, strike: 100 })).toBe(500); + expect(legCost({ kind: "call", quantity: -1, premium: 5, strike: 100 })).toBe(-500); + expect(legCost({ kind: "stock", quantity: 100, premium: 100 })).toBe(10000); + }); + + it("describes a position readably", () => { + expect( + describePosition([ + { kind: "call", quantity: 1, premium: 5, strike: 105 }, + { kind: "call", quantity: -2, premium: 1.5, strike: 110 }, + { kind: "stock", quantity: 100, premium: 100 }, + ]), + ).toBe("Long 1 105C / Short 2 110C / Long 100 shares"); + }); + + it("prices a straddle symmetrically around the strike", () => { + const legs: Leg[] = [ + { kind: "call", quantity: 1, premium: 5, strike: 100 }, + { kind: "put", quantity: 1, premium: 5, strike: 100 }, + ]; + expect(payoffAtExpiry(legs, 90)).toBeCloseTo(payoffAtExpiry(legs, 110), 9); + }); +}); diff --git a/web/lib/payoff.ts b/web/lib/payoff.ts new file mode 100644 index 0000000..97c0a51 --- /dev/null +++ b/web/lib/payoff.ts @@ -0,0 +1,234 @@ +/** + * Client-side payoff preview. + * + * This is a **second implementation** of maths that already exists in + * `api/options_tool/analytics/payoff.py`, and that duplication is deliberate: it + * lets the spread builder redraw instantly as you drag a strike, without a round + * trip per keystroke. The API remains the source of truth — what is rendered on + * submit comes from the server. + * + * Two implementations of the same formulas is exactly the setup where they + * silently drift apart, so both are tested against the *same* golden file, + * `api/tests/fixtures/payoff_golden.json`. If these ever disagree with the + * Python engine, `lib/payoff.test.ts` goes red. + * + * Only the expiry curve lives here. The today-curve needs Black-Scholes and a + * volatility per leg, which the server solves from the live chain — reproducing + * that in the browser would be duplicating the part that actually matters. + */ + +export const OPTION_MULTIPLIER = 100; + +export type LegKind = "call" | "put" | "stock"; + +export interface Leg { + kind: LegKind; + /** Signed: positive is long, negative is short. */ + quantity: number; + /** Paid or received per share; always positive. */ + premium: number; + strike?: number | null; + volatility?: number | null; +} + +export interface PayoffShape { + netCost: number; + breakevens: number[]; + /** `null` means genuinely unlimited, never a large placeholder number. */ + maxProfit: number | null; + maxLoss: number | null; + upsideSlope: number; + downsideSlope: number; +} + +export interface PayoffCurve extends PayoffShape { + underlyingPrices: number[]; + pnlAtExpiry: number[]; +} + +export class LegError extends Error {} + +/** Shares (or share-equivalents) one unit of this leg controls. */ +export function multiplier(leg: Leg): number { + return leg.kind === "stock" ? 1 : OPTION_MULTIPLIER; +} + +/** Signed cash flow to open. Positive is a debit paid, negative a credit. */ +export function legCost(leg: Leg): number { + return leg.quantity * leg.premium * multiplier(leg); +} + +export function validateLeg(leg: Leg): void { + if (!Number.isFinite(leg.quantity) || leg.quantity === 0) { + throw new LegError("Leg quantity must be a non-zero whole number."); + } + if (!Number.isFinite(leg.premium) || leg.premium < 0) { + throw new LegError( + "Premium is quoted positive — use a negative quantity for a short leg.", + ); + } + if (leg.kind === "stock") { + if (leg.strike != null) throw new LegError("A stock leg has no strike."); + } else if (leg.strike == null || leg.strike <= 0) { + throw new LegError(`A ${leg.kind} leg needs a positive strike.`); + } +} + +/** Value of one share of this leg at expiry. */ +function intrinsic(leg: Leg, underlying: number): number { + if (leg.kind === "stock") return underlying; + const strike = leg.strike as number; + return leg.kind === "call" + ? Math.max(underlying - strike, 0) + : Math.max(strike - underlying, 0); +} + +/** Position P/L at expiry for a given settlement price. */ +export function payoffAtExpiry(legs: Leg[], underlying: number): number { + let value = 0; + let cost = 0; + for (const leg of legs) { + value += leg.quantity * intrinsic(leg, underlying) * multiplier(leg); + cost += legCost(leg); + } + return value - cost; +} + +/** + * dP/dS above every strike, and below every strike. + * + * Above all strikes each call is in the money and each put is worthless; below + * all strikes the reverse. Stock is linear throughout. + */ +function terminalSlopes(legs: Leg[]): { upside: number; downside: number } { + let upside = 0; + let downside = 0; + for (const leg of legs) { + const weight = leg.quantity * multiplier(leg); + if (leg.kind === "stock") { + upside += weight; + downside += weight; + } else if (leg.kind === "call") { + upside += weight; + } else { + downside -= weight; + } + } + return { upside, downside }; +} + +/** + * Prices where the expiry payoff changes slope, plus both ends. + * + * Zero is included because the underlying can reach it. That bound is why a long + * put's profit is large but finite while a short call's loss is not. + */ +function kinkPrices(legs: Leg[]): number[] { + const strikes = [ + ...new Set( + legs + .map((leg) => leg.strike) + .filter((strike): strike is number => strike != null), + ), + ].sort((a, b) => a - b); + const beyond = strikes.length > 0 ? (strikes.at(-1) as number) * 2 : 1; + return [0, ...strikes, beyond]; +} + +/** + * Exact extremes and breakevens, derived from the payoff's shape rather than + * scanned off the plotting grid — so they do not change with the point count, + * and a breakeven beyond the last strike is still found. + */ +export function analysePayoff(legs: Leg[]): PayoffShape { + if (legs.length === 0) throw new LegError("A position needs at least one leg."); + legs.forEach(validateLeg); + + const { upside, downside } = terminalSlopes(legs); + const kinks = kinkPrices(legs); + const values = kinks.map((price) => payoffAtExpiry(legs, price)); + + const breakevens: number[] = []; + for (let i = 0; i < kinks.length - 1; i += 1) { + const lo = values[i] as number; + const hi = values[i + 1] as number; + if (lo === 0) { + breakevens.push(kinks[i] as number); + } else if (lo * hi < 0) { + const weight = Math.abs(lo) / (Math.abs(lo) + Math.abs(hi)); + breakevens.push( + (kinks[i] as number) + + weight * ((kinks[i + 1] as number) - (kinks[i] as number)), + ); + } + } + + const lastKink = kinks.at(-1) as number; + const lastValue = values.at(-1) as number; + if (upside !== 0) { + const crossing = lastKink - lastValue / upside; + if (crossing > lastKink) breakevens.push(crossing); + } else if (lastValue === 0) { + breakevens.push(lastKink); + } + + return { + netCost: legs.reduce((total, leg) => total + legCost(leg), 0), + breakevens: [...new Set(breakevens)].sort((a, b) => a - b), + // Only the upside can be unbounded: the underlying can rise without limit + // but cannot fall below zero. + maxProfit: upside > 0 ? null : Math.max(...values), + maxLoss: upside < 0 ? null : Math.min(...values), + upsideSlope: upside, + downsideSlope: downside, + }; +} + +export function buildPayoffCurve( + legs: Leg[], + spot: number, + priceRange?: [number, number], + points = 201, +): PayoffCurve { + if (!Number.isFinite(spot) || spot <= 0) { + throw new LegError("Spot must be a positive number."); + } + if (points < 3) throw new LegError("Need at least 3 points to draw a curve."); + + const shape = analysePayoff(legs); + const [low, high] = priceRange ?? defaultRange(legs, spot); + const step = (high - low) / (points - 1); + + const underlyingPrices = Array.from( + { length: points }, + (_, i) => low + i * step, + ); + + return { + ...shape, + underlyingPrices, + pnlAtExpiry: underlyingPrices.map((price) => payoffAtExpiry(legs, price)), + }; +} + +function defaultRange(legs: Leg[], spot: number): [number, number] { + const strikes = legs + .map((leg) => leg.strike) + .filter((strike): strike is number => strike != null); + const low = Math.min(spot, ...strikes) * 0.7; + const high = Math.max(spot, ...strikes) * 1.3; + return [Math.max(low, 0.01), high]; +} + +/** Human label for a position, e.g. "Long 1 105C / Short 2 110C". */ +export function describePosition(legs: Leg[]): string { + return legs + .map((leg) => { + const side = leg.quantity > 0 ? "Long" : "Short"; + const size = Math.abs(leg.quantity); + if (leg.kind === "stock") return `${side} ${size} shares`; + const right = leg.kind === "call" ? "C" : "P"; + return `${side} ${size} ${leg.strike}${right}`; + }) + .join(" / "); +} diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000..74abc68 --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; + +const config: NextConfig = { + reactStrictMode: true, + // The API base URL is read at build time by the client bundle. It is declared + // here as well so `next build` fails loudly in CI if it is ever removed from + // the environment, rather than shipping a bundle that silently calls itself. + env: { + NEXT_PUBLIC_API_BASE_URL: + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000", + }, +}; + +export default config; diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..d7ff4d8 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,8117 @@ +{ + "name": "options-analytics-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "options-analytics-web", + "version": "0.1.0", + "dependencies": { + "next": "15.5.3", + "react": "19.1.1", + "react-dom": "19.1.1", + "recharts": "2.15.4" + }, + "devDependencies": { + "@eslint/eslintrc": "3.3.1", + "@tailwindcss/postcss": "4.1.13", + "@types/node": "22.15.3", + "@types/react": "19.1.10", + "@types/react-dom": "19.1.7", + "eslint": "9.35.0", + "eslint-config-next": "15.5.3", + "postcss": "8.5.6", + "tailwindcss": "4.1.13", + "typescript": "5.9.2", + "vitest": "3.2.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", + "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@next/env": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.3.tgz", + "integrity": "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.3.tgz", + "integrity": "sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.3.tgz", + "integrity": "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.3.tgz", + "integrity": "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.3.tgz", + "integrity": "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.3.tgz", + "integrity": "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.3.tgz", + "integrity": "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.3.tgz", + "integrity": "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.3.tgz", + "integrity": "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.3.tgz", + "integrity": "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz", + "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz", + "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz", + "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz", + "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz", + "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz", + "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz", + "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz", + "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz", + "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz", + "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz", + "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz", + "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz", + "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz", + "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz", + "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz", + "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz", + "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz", + "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz", + "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz", + "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz", + "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz", + "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz", + "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz", + "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz", + "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", + "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.18", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", + "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-x64": "4.1.13", + "@tailwindcss/oxide-freebsd-x64": "4.1.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-x64-musl": "4.1.13", + "@tailwindcss/oxide-wasm32-wasi": "4.1.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", + "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", + "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", + "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", + "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", + "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", + "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", + "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", + "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", + "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", + "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", + "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.13", + "@tailwindcss/oxide": "4.1.13", + "postcss": "^8.4.41", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz", + "integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", + "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.0.tgz", + "integrity": "sha512-ghq3mhs649mvbarTCAlZn2wRhbfHmzAFiKxoWA14B3VtqnxtZt+wz8BroKXU0tF3GiJsnUldjVncQGZ8qk4rdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.35.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", + "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.35.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.3.tgz", + "integrity": "sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.3", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz", + "integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.5.3", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.3.tgz", + "integrity": "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.3", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.3", + "@next/swc-darwin-x64": "15.5.3", + "@next/swc-linux-arm64-gnu": "15.5.3", + "@next/swc-linux-arm64-musl": "15.5.3", + "@next/swc-linux-x64-gnu": "15.5.3", + "@next/swc-linux-x64-musl": "15.5.3", + "@next/swc-win32-arm64-msvc": "15.5.3", + "@next/swc-win32-x64-msvc": "15.5.3", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", + "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.2", + "@rollup/rollup-android-arm64": "4.63.2", + "@rollup/rollup-darwin-arm64": "4.63.2", + "@rollup/rollup-darwin-x64": "4.63.2", + "@rollup/rollup-freebsd-arm64": "4.63.2", + "@rollup/rollup-freebsd-x64": "4.63.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.2", + "@rollup/rollup-linux-arm-musleabihf": "4.63.2", + "@rollup/rollup-linux-arm64-gnu": "4.63.2", + "@rollup/rollup-linux-arm64-musl": "4.63.2", + "@rollup/rollup-linux-loong64-gnu": "4.63.2", + "@rollup/rollup-linux-loong64-musl": "4.63.2", + "@rollup/rollup-linux-ppc64-gnu": "4.63.2", + "@rollup/rollup-linux-ppc64-musl": "4.63.2", + "@rollup/rollup-linux-riscv64-gnu": "4.63.2", + "@rollup/rollup-linux-riscv64-musl": "4.63.2", + "@rollup/rollup-linux-s390x-gnu": "4.63.2", + "@rollup/rollup-linux-x64-gnu": "4.63.2", + "@rollup/rollup-linux-x64-musl": "4.63.2", + "@rollup/rollup-openbsd-x64": "4.63.2", + "@rollup/rollup-openharmony-arm64": "4.63.2", + "@rollup/rollup-win32-arm64-msvc": "4.63.2", + "@rollup/rollup-win32-ia32-msvc": "4.63.2", + "@rollup/rollup-win32-x64-gnu": "4.63.2", + "@rollup/rollup-win32-x64-msvc": "4.63.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..7837774 --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "options-analytics-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:run": "vitest run" + }, + "dependencies": { + "next": "15.5.3", + "react": "19.1.1", + "react-dom": "19.1.1", + "recharts": "2.15.4" + }, + "devDependencies": { + "@eslint/eslintrc": "3.3.1", + "@types/node": "22.15.3", + "@types/react": "19.1.10", + "@types/react-dom": "19.1.7", + "eslint": "9.35.0", + "eslint-config-next": "15.5.3", + "tailwindcss": "4.1.13", + "@tailwindcss/postcss": "4.1.13", + "postcss": "8.5.6", + "typescript": "5.9.2", + "vitest": "3.2.4" + } +} diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs new file mode 100644 index 0000000..4de2724 --- /dev/null +++ b/web/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: { "@tailwindcss/postcss": {} }, +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..343c47b --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..9be6b88 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "url"; + +export default defineConfig({ + test: { + environment: "node", + include: ["lib/**/*.test.ts"], + }, + resolve: { + alias: { "@": fileURLToPath(new URL(".", import.meta.url)) }, + }, +}); From 5bf30b441cf619a338434904c9b2fcbd02f6757b Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:19:56 -0400 Subject: [PATCH 07/11] M6: Docker images, nginx, and a systemd timer for the snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-stage builds for both services: wheels compiled in a stage that carries a toolchain so the runtime image does not ship one, Next.js standalone output so the web image copies the modules actually reached rather than the whole tree, non-root users, and health checks that test whether the service can actually serve rather than merely whether the process is alive. This machine has no Docker, so these would otherwise ship written and unbuilt. CI now builds both images and smoke-tests them: the API container must answer /health with database_reachable true, and the snapshot command must print "captured" on its first run and "refreshed" on its second inside the container, with the stored row count unchanged. That turns the deploy story from "the syntax looks right" into "this compiles and runs" — while still stopping short of claiming a deployment. The IV history is the only irreplaceable state, so it lives in a named volume rather than the container filesystem, and deploy/README.md documents a backup one-liner alongside the upgrade order (migrate, then start new code). The timer fires weekdays at 21:15 UTC, which is after the US equity close in both EST and EDT — capturing mid-session would make the stored volatility depend on what time the job happened to run. Persistent=true is set deliberately: a day missed to a reboot is a permanent gap, because historical implied volatility cannot be bought from a free source or backfilled. The README and deploy/README.md both state plainly that nothing has been deployed to a host yet, and no resume bullet claims otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 6 ++ .github/workflows/ci.yml | 71 ++++++++++++++++ Dockerfile.api | 55 ++++++++++++ Dockerfile.web | 47 +++++++++++ README.md | 30 ++++++- deploy/README.md | 144 ++++++++++++++++++++++++++++++++ deploy/nginx.conf | 82 ++++++++++++++++++ deploy/options-snapshot.service | 29 +++++++ deploy/options-snapshot.timer | 22 +++++ docker-compose.yml | 69 +++++++++++++++ web/next.config.ts | 4 + web/public/.gitkeep | 0 12 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 Dockerfile.api create mode 100644 Dockerfile.web create mode 100644 deploy/README.md create mode 100644 deploy/nginx.conf create mode 100644 deploy/options-snapshot.service create mode 100644 deploy/options-snapshot.timer create mode 100644 docker-compose.yml create mode 100644 web/public/.gitkeep diff --git a/.env.example b/.env.example index 68dd5d3..1d051cc 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,9 @@ OPTIONS_DATABASE_URL=sqlite:///./data/options.db # --- Web --------------------------------------------------------------------- NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 + +# --- Deployment (docker-compose) --------------------------------------------- +# The URL the BROWSER will call, baked into the client bundle at build time. +# Behind nginx this is https://your-domain/api — not the internal service name. +# Changing it requires rebuilding the web image, not just restarting it. +PUBLIC_API_URL=http://localhost:8000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c61a279..83066de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,77 @@ jobs: env: NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 + # The development machine has no Docker, so the images would otherwise ship + # written but never built. Building them here is the difference between + # "syntax looks right" and "this actually compiles", and it keeps the deploy + # story honest: built in CI, not yet deployed to a host. + docker: + name: docker images build + needs: detect + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build the API image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.api + push: false + load: true + tags: options-analytics-api:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: The API image must serve a healthy /health + run: | + docker run -d --name api -p 8000:8000 \ + -e OPTIONS_PROVIDER=fixture \ + -e OPTIONS_DATABASE_URL=sqlite:////data/options.db \ + options-analytics-api:ci + for _ in $(seq 1 30); do + if curl -fs localhost:8000/health > /dev/null; then break; fi + sleep 2 + done + curl -fs localhost:8000/health | tee health.json + grep -q '"database_reachable":true' health.json + docker rm -f api + + - name: The snapshot command must be idempotent inside the container + run: | + docker volume create ci-data + run_snapshot() { + docker run --rm -v ci-data:/data \ + -e OPTIONS_PROVIDER=fixture \ + -e OPTIONS_DATABASE_URL=sqlite:////data/options.db \ + options-analytics-api:ci python -m options_tool snapshot AAPL + } + run_snapshot | tee first.txt + run_snapshot | tee second.txt + grep -q "captured" first.txt + grep -q "refreshed" second.txt + # The stored row count must be identical across the two runs. + test "$(grep -o '[0-9]\+ -> [0-9]\+' second.txt | awk '{print $1}')" \ + = "$(grep -o '[0-9]\+ -> [0-9]\+' second.txt | awk '{print $3}')" + + - name: Build the web image + if: needs.detect.outputs.web == 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.web + push: false + load: true + tags: options-analytics-web:ci + build-args: NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Validate the compose file + run: docker compose config --quiet + secrets: name: no committed secrets runs-on: ubuntu-latest diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..7c9ffbb --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 + +# ---- builder --------------------------------------------------------------- +# Wheels are built in a stage that carries a compiler, so the runtime image does +# not have to ship one. numpy and pandas both need it on architectures without +# prebuilt wheels. +FROM python:3.11-slim AS builder + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 + +RUN apt-get update \ + && apt-get install --no-install-recommends -y build-essential \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY api/pyproject.toml ./ +COPY api/options_tool ./options_tool + +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir . + +# ---- runtime --------------------------------------------------------------- +FROM python:3.11-slim AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/opt/venv/bin:$PATH" + +# Never run as root. The snapshot job writes to /data, so that is the only path +# this user needs to own. +RUN useradd --create-home --uid 10001 options + +COPY --from=builder /opt/venv /opt/venv + +WORKDIR /app +COPY api/options_tool ./options_tool +COPY api/alembic ./alembic +COPY api/alembic.ini ./ +COPY api/tests/fixtures ./tests/fixtures + +RUN mkdir -p /data && chown -R options:options /data /app +USER options + +ENV OPTIONS_DATABASE_URL=sqlite:////data/options.db +VOLUME ["/data"] +EXPOSE 8000 + +# The API reports database and provider reachability, so the container is marked +# unhealthy when it is up but cannot actually serve, not merely when it is dead. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4).status == 200 else 1)" + +CMD ["uvicorn", "options_tool.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..ea6ec8e --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1 + +# ---- dependencies ---------------------------------------------------------- +FROM node:22-alpine AS deps +WORKDIR /app +COPY web/package.json web/package-lock.json ./ +RUN npm ci + +# ---- build ----------------------------------------------------------------- +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY web/ ./ + +# Baked into the client bundle at build time, so it must be supplied here rather +# than at run time. Changing the API URL means rebuilding this image. +ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN npm run build + +# ---- runtime --------------------------------------------------------------- +FROM node:22-alpine AS runtime +WORKDIR /app + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 + +RUN addgroup --system --gid 10002 nextjs \ + && adduser --system --uid 10002 --ingroup nextjs nextjs + +# `output: "standalone"` in next.config.ts emits a server bundle containing only +# the modules actually reached, which is why this copies three directories +# instead of the whole node_modules tree. +COPY --from=builder --chown=nextjs:nextjs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nextjs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nextjs /app/public ./public + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index b119f50..3561dfb 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ rather than rendering a misleading zero. | M3 — FastAPI | done | | M4 — tests + CI | done | | M5 — Next.js front end | done | -| M6 — Docker + Linux deploy | in progress | -| M7 — polish (earnings flag, screener, CSV export) | planned | +| M6 — Docker + Linux deploy | images build in CI; **not yet deployed to a host** | +| M7 — polish (earnings flag, screener, CSV export) | in progress | --- @@ -316,6 +316,32 @@ What the suite actually checks, beyond line coverage: --- +## Deployment + +```bash +cp .env.example .env # set PUBLIC_API_URL to the URL the browser will call +docker compose up --build +``` + +Multi-stage images for both services (non-root users, health checks, Next.js +standalone output), an nginx config with TLS and rate limiting, and a systemd +timer for the daily snapshot. Full walkthrough in +[`deploy/README.md`](deploy/README.md). + +**I have not deployed this to a server.** The images are built and smoke-tested +in CI — the API container must answer `/health` with a reachable database, and +the snapshot command must report `refreshed` on its second run inside the +container with an unchanged row count — but no host is running them. I would +rather say that than imply a deployment I cannot point at. + +The timer runs weekdays at 21:15 UTC, after the US close in both EST and EDT, so +the chain captured is the settled one rather than a mid-session reading whose +volatility depends on what time the job happened to run. `Persistent=true` is set +because a missed day is a permanent hole: historical implied volatility is not +purchasable from any free source and cannot be backfilled. + +--- + ## Limitations These are real and I would rather state them than hide them. diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..704f707 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,144 @@ +# Deploying + +Single host, Docker Compose behind nginx, with a systemd timer running the daily +snapshot. + +> **Status: written, not yet run.** These files were authored and checked for +> syntax, but they have not been built or deployed — the development machine has +> no Docker installed and there is no target host. Until someone runs the steps +> below successfully, nothing in this repository claims a live deployment. If you +> are that someone and a step is wrong, the fix belongs in this file. + +## What runs where + +| Piece | Where | Why | +|---|---|---| +| `api` | container, bound to `127.0.0.1:8000` | not exposed directly; nginx terminates TLS | +| `web` | container, bound to `127.0.0.1:3000` | same | +| `options-data` | named Docker volume | the accumulated IV history — the only irreplaceable state here | +| snapshot | systemd timer, `docker compose run --rm` | survives reboots and logs to the journal | + +## 1. Host setup + +Tested target: Ubuntu 24.04 LTS, 1 vCPU / 1 GB. SQLite and a handful of chains +need very little. + +```bash +sudo apt update && sudo apt install -y docker.io docker-compose-v2 nginx certbot python3-certbot-nginx +sudo systemctl enable --now docker +sudo usermod -aG docker "$USER" # log out and back in +``` + +## 2. Get the code + +```bash +sudo mkdir -p /opt/options-analytics +sudo chown "$USER":"$USER" /opt/options-analytics +git clone https://github.com/Tejas12972/stockwatchlist.git /opt/options-analytics +cd /opt/options-analytics +``` + +## 3. Configure + +```bash +cp .env.example .env +``` + +Set `PUBLIC_API_URL` to the URL **the browser** will call — that is +`https://your-domain/api`, not `http://api:8000`. The Next.js client bundle bakes +this in at build time, so changing it later means rebuilding the web image, not +just restarting it. This is the single most common way to get a working deploy +that shows an empty page. + +## 4. Build and start + +```bash +docker compose build +docker compose up -d +docker compose ps # both services should be healthy +curl -s localhost:8000/health +``` + +## 5. nginx and TLS + +```bash +sudo cp deploy/nginx.conf /etc/nginx/sites-available/options-analytics +sudo sed -i "s/options.example.com/YOUR-DOMAIN/g" /etc/nginx/sites-available/options-analytics +sudo ln -sf /etc/nginx/sites-available/options-analytics /etc/nginx/sites-enabled/ +sudo rm -f /etc/nginx/sites-enabled/default + +sudo nginx -t # must pass before reloading +sudo systemctl reload nginx +sudo certbot --nginx -d YOUR-DOMAIN +``` + +`certbot` rewrites the TLS certificate paths in place, so run it after copying +the config, not before. + +## 6. The daily snapshot + +```bash +sudo cp deploy/options-snapshot.service deploy/options-snapshot.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now options-snapshot.timer + +systemctl list-timers options-snapshot.timer +sudo systemctl start options-snapshot.service # run once now +journalctl -u options-snapshot.service -n 50 --no-pager +``` + +The timer fires weekdays at 21:15 UTC — after the US close in both EST and EDT, +so the captured chain is the settled one rather than a mid-session reading whose +volatility depends on what time the job happened to run. + +`Persistent=true` matters more than it looks: if the host is down at the +scheduled time the run happens at next boot instead of being skipped. **A missed +day is a permanent hole in the IV history** — historical implied volatility is +not purchasable from any free source, so it cannot be backfilled. + +## 7. Verify + +```bash +# Idempotency: the second run must not add rows. +sudo systemctl start options-snapshot.service +docker compose run --rm snapshot | tail -2 # expect "refreshed", not "captured" + +# Migrations are applied with the API stopped, against the same volume. +docker compose run --rm api alembic upgrade head +``` + +## Backups + +Only the volume matters. Everything else is rebuildable from git. + +```bash +docker run --rm -v options-analytics_options-data:/data -v "$PWD":/backup alpine \ + tar czf /backup/options-history-$(date +%F).tar.gz -C /data . +``` + +Worth putting on its own weekly timer. The database is small — a year of daily +snapshots for a handful of tickers is tens of megabytes — and it is the only +thing here that cannot be recreated. + +## Upgrading + +```bash +cd /opt/options-analytics +git pull +docker compose run --rm api alembic upgrade head # before starting new code +docker compose build +docker compose up -d +``` + +Run the migration before the new containers start, so the schema is never behind +the code reading it. + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| Pages load, all data empty | `PUBLIC_API_URL` wrong, or set after the web image was built. Rebuild the web image. | +| `provider_rate_limited` in the UI | Yahoo is throttling. It clears within minutes; the source is unofficial and has no SLA. | +| IV rank says "insufficient history" | Working as designed. It needs `OPTIONS_MIN_HISTORY_DAYS` (default 20) stored days. | +| Timer never fires | `systemctl list-timers`; check the unit is enabled and the host clock is in UTC. | +| Snapshot writes nothing | `journalctl -u options-snapshot.service`. A ticker failing does not abort the rest of the run, so check per-symbol warnings. | diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..dbdd586 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,82 @@ +# nginx site config for a single-host deployment. +# +# Install to /etc/nginx/sites-available/options-analytics and symlink into +# sites-enabled. Run `certbot --nginx -d options.example.com` afterwards; it +# rewrites the TLS lines below in place. + +upstream options_api { + server 127.0.0.1:8000; +} + +upstream options_web { + server 127.0.0.1:3000; +} + +# The unofficial upstream data source rate-limits, and every /api request can +# become a vendor request. Throttling here keeps an over-eager browser tab from +# getting the whole host throttled by Yahoo. +limit_req_zone $binary_remote_addr zone=options_api:10m rate=10r/s; + +server { + listen 80; + server_name options.example.com; + + # Certbot needs this to stay on port 80; everything else is redirected. + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name options.example.com; + + ssl_certificate /etc/letsencrypt/live/options.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/options.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + # This is a single-user analytics tool with no authentication. It should not + # be indexed, and it holds no data worth exposing to a crawler. + add_header X-Robots-Tag "noindex, nofollow" always; + + access_log /var/log/nginx/options-analytics.access.log; + error_log /var/log/nginx/options-analytics.error.log; + + client_max_body_size 1m; + + location /api/ { + limit_req zone=options_api burst=20 nodelay; + + proxy_pass http://options_api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # A cold chain request can take a while when the vendor is slow, and a + # 60s default cuts it off mid-fetch with a confusing gateway error. + proxy_connect_timeout 10s; + proxy_read_timeout 120s; + } + + location / { + proxy_pass http://options_web; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/deploy/options-snapshot.service b/deploy/options-snapshot.service new file mode 100644 index 0000000..e474621 --- /dev/null +++ b/deploy/options-snapshot.service @@ -0,0 +1,29 @@ +[Unit] +Description=Capture today's option chains into the local IV history +Documentation=https://github.com/Tejas12972/stockwatchlist +After=docker.service network-online.target +Requires=docker.service +Wants=network-online.target + +[Service] +Type=oneshot +WorkingDirectory=/opt/options-analytics + +# Runs the snapshot in a throwaway container against the same named volume the +# API uses. The command is idempotent: if the timer fires twice, or a failed run +# is retried, the day is refreshed in place rather than duplicated. +ExecStart=/usr/bin/docker compose run --rm snapshot + +# The unofficial data source throttles. Retry a few times with a real gap +# between attempts rather than losing the day. +Restart=on-failure +RestartSec=15min + +# The history is worth more than any single day's freshness, so a failure is +# logged loudly rather than silently swallowed. +StandardOutput=journal +StandardError=journal +SyslogIdentifier=options-snapshot + +[Install] +WantedBy=multi-user.target diff --git a/deploy/options-snapshot.timer b/deploy/options-snapshot.timer new file mode 100644 index 0000000..05dbdab --- /dev/null +++ b/deploy/options-snapshot.timer @@ -0,0 +1,22 @@ +[Unit] +Description=Daily options chain snapshot +Documentation=https://github.com/Tejas12972/stockwatchlist + +[Timer] +# 21:15 UTC is 17:15 New York during EDT and 16:15 during EST — after the US +# equity close either way, so the chain captured is the settled one rather than +# a mid-session snapshot that would make the IV series depend on what time the +# job happened to run. +OnCalendar=Mon..Fri 21:15 UTC + +# If the host was asleep or rebooted through the scheduled time, run on the next +# boot instead of skipping the day. A missing day is a permanent gap in the IV +# history — it cannot be backfilled from any free source. +Persistent=true + +# Spread the load off the exact minute; every scraper firing at 21:15:00 is +# precisely when the vendor throttles. +RandomizedDelaySec=10min + +[Install] +WantedBy=timers.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f834c70 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +# Local and single-host deployment. +# +# docker compose up --build +# +# The web image bakes NEXT_PUBLIC_API_BASE_URL into the client bundle at build +# time, so behind nginx it must be the public URL the *browser* will call, not +# the internal service name. That is the one thing about this file that catches +# people out. + +services: + api: + build: + context: . + dockerfile: Dockerfile.api + image: options-analytics-api + restart: unless-stopped + environment: + OPTIONS_PROVIDER: ${OPTIONS_PROVIDER:-yfinance} + OPTIONS_DATABASE_URL: sqlite:////data/options.db + OPTIONS_RISK_FREE_RATE: ${OPTIONS_RISK_FREE_RATE:-0.04} + OPTIONS_DIVIDEND_YIELD: ${OPTIONS_DIVIDEND_YIELD:-0.0} + OPTIONS_MIN_HISTORY_DAYS: ${OPTIONS_MIN_HISTORY_DAYS:-20} + OPTIONS_IV_WINDOW_DAYS: ${OPTIONS_IV_WINDOW_DAYS:-252} + volumes: + # The accumulated IV history is the one thing here that cannot be + # re-downloaded from anywhere. It lives in a named volume so `docker + # compose down` does not destroy it. + - options-data:/data + ports: + - "127.0.0.1:8000:8000" + + web: + build: + context: . + dockerfile: Dockerfile.web + args: + NEXT_PUBLIC_API_BASE_URL: ${PUBLIC_API_URL:-http://localhost:8000} + image: options-analytics-web + restart: unless-stopped + depends_on: + api: + condition: service_healthy + ports: + - "127.0.0.1:3000:3000" + + # The daily snapshot. Runs on the same image and the same volume as the API, + # so it shares one schema and one database with no coordination. + # + # Started only with `--profile snapshot`, because on a real host the systemd + # timer in deploy/ is the better scheduler: it survives reboots, logs to the + # journal, and will not silently stop when the container is recreated. + snapshot: + build: + context: . + dockerfile: Dockerfile.api + image: options-analytics-api + profiles: ["snapshot"] + depends_on: + api: + condition: service_healthy + environment: + OPTIONS_PROVIDER: ${OPTIONS_PROVIDER:-yfinance} + OPTIONS_DATABASE_URL: sqlite:////data/options.db + volumes: + - options-data:/data + command: ["python", "-m", "options_tool", "snapshot"] + +volumes: + options-data: diff --git a/web/next.config.ts b/web/next.config.ts index 74abc68..3927c2a 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -2,6 +2,10 @@ import type { NextConfig } from "next"; const config: NextConfig = { reactStrictMode: true, + // Emits a self-contained server bundle with only the node_modules actually + // reached at runtime, so the production image copies ~50MB instead of the + // whole dependency tree. + output: "standalone", // The API base URL is read at build time by the client bundle. It is declared // here as well so `next build` fails loudly in CI if it is ever removed from // the environment, rather than shipping a bundle that silently calls itself. diff --git a/web/public/.gitkeep b/web/public/.gitkeep new file mode 100644 index 0000000..e69de29 From 9c9a980b960f04ff48e87c6f4b4842a553c84bdc Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:25:53 -0400 Subject: [PATCH 08/11] M7: screener, earnings proximity flag, and CSV export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - screener: elevated or depressed IV rank, plus volume and open interest against their own trailing median. Median rather than mean because option volume is spiky — one expiry-week spike would drag a mean upward for weeks and suppress every later reading. Below ten stored days it declines to judge activity at all, on the same reasoning as the IV-rank guard: "2x the median" over six days describes the sample, not the market. It reports how many symbols it screened alongside the hits. An empty result would otherwise be ambiguous between "nothing was flagged" and "nothing could be checked", and the symbols it could not check are named with the reason. - earnings: an optional provider method defaulting to None, so a source without forward earnings dates does not break the chain view. The yfinance implementation parses the three shapes Yahoo's calendar returns and swallows its failures — losing a decorative flag is acceptable, losing the chain because the flag failed is not. The API returns 200 with known:false rather than a 404, since "we do not know" is a different answer from "none is scheduled". - export: daily and per-contract CSV. Missing values are written empty rather than zero — a blank cell reads as "not available" in a spreadsheet, a 0 reads as a measurement. The daily export carries the risk-free rate and dividend yield per row so a stored IV stays interpretable outside this tool. 509 tests, 97% coverage on analytics (screener 100%). README gains a "what I would do differently" section naming Black-Scholes vs American exercise, the zero dividend assumption, and SQLite's ceiling. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 64 ++++- api/options_tool/analytics/screener.py | 183 +++++++++++++ api/options_tool/api/routes.py | 68 +++++ api/options_tool/api/schemas.py | 45 ++++ api/options_tool/cli.py | 250 +++++++++++++++++- api/options_tool/db/queries.py | 65 +++++ api/options_tool/providers/base.py | 10 + .../providers/yfinance_provider.py | 33 +++ api/tests/test_api.py | 40 +++ api/tests/test_export.py | 174 ++++++++++++ api/tests/test_screener.py | 166 ++++++++++++ 11 files changed, 1092 insertions(+), 6 deletions(-) create mode 100644 api/options_tool/analytics/screener.py create mode 100644 api/tests/test_export.py create mode 100644 api/tests/test_screener.py diff --git a/README.md b/README.md index 3561dfb..a80b213 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Options & Markets Analytics [![CI](https://github.com/Tejas12972/stockwatchlist/actions/workflows/ci.yml/badge.svg)](https://github.com/Tejas12972/stockwatchlist/actions/workflows/ci.yml) -[![coverage](https://img.shields.io/badge/analytics%20coverage-96%25-brightgreen)](#tests) +[![coverage](https://img.shields.io/badge/analytics%20coverage-97%25-brightgreen)](#tests) [![python](https://img.shields.io/badge/python-3.11%20%7C%203.12-blue)](https://www.python.org/) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) @@ -57,7 +57,7 @@ rather than rendering a misleading zero. | M4 — tests + CI | done | | M5 — Next.js front end | done | | M6 — Docker + Linux deploy | images build in CI; **not yet deployed to a host** | -| M7 — polish (earnings flag, screener, CSV export) | in progress | +| M7 — polish (earnings flag, screener, CSV export) | done | --- @@ -269,7 +269,7 @@ cannot be priced is information, so it is shown rather than dropped. ## Tests -459 tests. **The suite runs with no network at all**, which is enforced rather +509 tests. **The suite runs with no network at all**, which is enforced rather than intended: `pytest-socket` is configured with `--disable-socket`, so any accidental outbound call fails the test that made it. `tests/test_offline.py` asserts the block is actually in place, so the guarantee cannot rot quietly if @@ -285,8 +285,8 @@ cd api | Module | Coverage | |---|---| -| `analytics/` (pricing, IV, IV rank, payoff) | **96%** | -| whole package | 94% | +| `analytics/` (pricing, IV, IV rank, payoff, screener) | **97%** | +| whole package | 93% | CI enforces ≥85% overall and ≥90% on `analytics/` — a gate, not a number typed into a README. @@ -316,6 +316,42 @@ What the suite actually checks, beyond line coverage: --- +## Screener and export + +```bash +options-tool screen # flag watchlist symbols where something changed +options-tool screen --earnings # also check earnings proximity (live requests) +options-tool export --out history.csv # one row per stored day +options-tool export AAPL --what contracts --out chains.csv +``` + +The screener runs entirely on stored history, so it never touches the vendor and +cannot be throttled. It flags elevated or depressed IV rank, and volume or open +interest well above its own trailing **median** — median rather than mean because +option volume is spiky, and one expiry-week spike would drag a mean upward for +weeks afterwards. + +It is descriptive. It narrows a watchlist; it does not rank opportunities or +suggest positions. And it reports how many symbols it screened alongside the +hits, because an empty result could otherwise mean "nothing was flagged" or +"nothing could be checked" — two very different things: + +``` +$ options-tool screen +screened 2 symbols · 0 flagged + + note: AAPL: insufficient history (1/20 days) + note: SPY: insufficient history (1/20 days) + nothing flagged. +``` + +CSV exports leave missing values genuinely empty rather than zero — a blank cell +reads as "not available" in any spreadsheet, while a `0` reads as a measurement. +The daily export carries the risk-free rate and dividend yield in force for each +row, so a stored IV remains interpretable by whoever opens the file later. + +--- + ## Deployment ```bash @@ -374,6 +410,24 @@ trading time. Weekend decay is therefore priced as if it were trading decay. --- +## What I would do differently + +**Black-Scholes is the wrong model for American options**, and I used it anyway +because it is the one an interviewer will ask me to derive. A binomial or +Bjerksund-Stensland model would price early exercise properly; the provider +abstraction means swapping the pricer is contained, and it is the first thing I +would add. + +**A constant dividend yield of zero is wrong for dividend payers.** Discrete +dividends would be more work and materially more correct for the names where it +matters — pre-ex-dividend calls especially. + +**SQLite will hold for years at this scale** — a handful of tickers, one snapshot +a day — and I chose it deliberately over Postgres because zero operations beats +scalability I do not need. It would need replacing for multiple users. + +--- + ## License MIT — see [LICENSE](LICENSE). diff --git a/api/options_tool/analytics/screener.py b/api/options_tool/analytics/screener.py new file mode 100644 index 0000000..cc942dd --- /dev/null +++ b/api/options_tool/analytics/screener.py @@ -0,0 +1,183 @@ +"""Screen stored snapshots for the things worth a second look. + +Two signals, both computed from the history this tool has accumulated rather +than from a single day's chain: + +- **elevated or depressed IV rank** -- where today's volatility sits against its + own past +- **unusual volume or open interest** -- today's activity against its own + trailing median + +Both are *descriptive*. Neither is a signal to trade, and nothing here ranks +opportunities or suggests a position -- it narrows a watchlist down to the names +where something changed. The median is used rather than the mean because option +volume is heavily skewed: one expiry-week spike would drag a mean upward for +weeks and suppress every subsequent reading. +""" + +from __future__ import annotations + +import statistics +from dataclasses import dataclass +from enum import StrEnum + +__all__ = ["ScreenFlag", "ScreenHit", "ScreenInput", "screen"] + +# An IV rank at or above this is "elevated", at or below the other "depressed". +HIGH_IV_RANK = 70.0 +LOW_IV_RANK = 30.0 + +# Today's activity must exceed this multiple of its trailing median to count. +UNUSUAL_ACTIVITY_MULTIPLE = 2.0 + +# Fewer stored days than this and a median is not worth computing. +MIN_ACTIVITY_HISTORY = 10 + + +class ScreenFlag(StrEnum): + HIGH_IV_RANK = "high_iv_rank" + LOW_IV_RANK = "low_iv_rank" + UNUSUAL_VOLUME = "unusual_volume" + UNUSUAL_OPEN_INTEREST = "unusual_open_interest" + NEAR_EARNINGS = "near_earnings" + + @property + def explanation(self) -> str: + return _EXPLANATIONS[self] + + +_EXPLANATIONS: dict[ScreenFlag, str] = { + ScreenFlag.HIGH_IV_RANK: "implied volatility is high against its own history", + ScreenFlag.LOW_IV_RANK: "implied volatility is low against its own history", + ScreenFlag.UNUSUAL_VOLUME: "option volume is well above its trailing median", + ScreenFlag.UNUSUAL_OPEN_INTEREST: "open interest is well above its trailing median", + ScreenFlag.NEAR_EARNINGS: "an earnings date falls before the near expiry", +} + + +@dataclass(frozen=True, slots=True) +class ScreenInput: + """One symbol's stored state, as the screener needs it.""" + + ticker: str + iv_rank: float | None + iv_rank_reason: str + current_iv: float | None + volume_today: int | None + volume_history: list[int] + open_interest_today: int | None + open_interest_history: list[int] + days_to_earnings: int | None = None + days_to_near_expiry: int | None = None + + +@dataclass(frozen=True, slots=True) +class ScreenHit: + """A symbol that tripped at least one flag, with the numbers behind it.""" + + ticker: str + flags: list[ScreenFlag] + iv_rank: float | None + current_iv: float | None + volume_today: int | None + volume_median: float | None + volume_multiple: float | None + open_interest_today: int | None + open_interest_median: float | None + days_to_earnings: int | None + notes: list[str] + + @property + def summary(self) -> str: + return ", ".join(flag.explanation for flag in self.flags) + + +def _activity_multiple(today: int | None, history: list[int]) -> tuple[float | None, float | None]: + """(median, today/median), or (None, None) when there is too little history. + + Returning None rather than a default is the same discipline as everywhere + else here: with six stored days a "2x the median" reading says more about the + sample than about the market. + """ + usable = [value for value in history if value is not None and value >= 0] + if today is None or len(usable) < MIN_ACTIVITY_HISTORY: + return None, None + + median = statistics.median(usable) + if median <= 0: + # Everything was zero historically; any activity today is technically + # infinite growth and tells you nothing useful. + return median, None + return median, today / median + + +def screen( + inputs: list[ScreenInput], + high_iv_rank: float = HIGH_IV_RANK, + low_iv_rank: float = LOW_IV_RANK, + activity_multiple: float = UNUSUAL_ACTIVITY_MULTIPLE, +) -> list[ScreenHit]: + """Flag symbols worth a look. Descriptive only -- not a recommendation. + + Results are ordered by how many flags tripped, then by IV rank, so the + busiest names surface first. A symbol with no flags is omitted entirely + rather than returned with an empty list. + """ + hits: list[ScreenHit] = [] + + for item in inputs: + flags: list[ScreenFlag] = [] + notes: list[str] = [] + + if item.iv_rank is None: + # Said out loud rather than silently skipped: a symbol absent from + # the results because it has no history looks identical to one that + # simply did not trip anything. + notes.append(f"IV rank unavailable — {item.iv_rank_reason}") + elif item.iv_rank >= high_iv_rank: + flags.append(ScreenFlag.HIGH_IV_RANK) + elif item.iv_rank <= low_iv_rank: + flags.append(ScreenFlag.LOW_IV_RANK) + + volume_median, volume_multiple = _activity_multiple(item.volume_today, item.volume_history) + if volume_multiple is not None and volume_multiple >= activity_multiple: + flags.append(ScreenFlag.UNUSUAL_VOLUME) + elif volume_median is None: + notes.append( + f"volume baseline needs {MIN_ACTIVITY_HISTORY} stored days " + f"({len(item.volume_history)} so far)" + ) + + oi_median, oi_multiple = _activity_multiple( + item.open_interest_today, item.open_interest_history + ) + if oi_multiple is not None and oi_multiple >= activity_multiple: + flags.append(ScreenFlag.UNUSUAL_OPEN_INTEREST) + + if ( + item.days_to_earnings is not None + and item.days_to_near_expiry is not None + and 0 <= item.days_to_earnings <= item.days_to_near_expiry + ): + flags.append(ScreenFlag.NEAR_EARNINGS) + + if not flags: + continue + + hits.append( + ScreenHit( + ticker=item.ticker, + flags=flags, + iv_rank=item.iv_rank, + current_iv=item.current_iv, + volume_today=item.volume_today, + volume_median=volume_median, + volume_multiple=volume_multiple, + open_interest_today=item.open_interest_today, + open_interest_median=oi_median, + days_to_earnings=item.days_to_earnings, + notes=notes, + ) + ) + + return sorted(hits, key=lambda hit: (-len(hit.flags), -(hit.iv_rank or 0.0))) diff --git a/api/options_tool/api/routes.py b/api/options_tool/api/routes.py index 1392383..f0788ea 100644 --- a/api/options_tool/api/routes.py +++ b/api/options_tool/api/routes.py @@ -17,18 +17,22 @@ from options_tool import __version__ from options_tool.analytics.chain import build_chain_frame, summarise_chain from options_tool.analytics.payoff import Leg, LegKind, build_payoff_curve +from options_tool.analytics.screener import screen from options_tool.api.deps import get_api_settings, get_market_provider, get_session from options_tool.api.errors import APIError from options_tool.api.schemas import ( ChainResponse, ChainSummary, ContractOut, + EarningsResponse, HealthResponse, IVRankResponse, LegIn, PayoffRequest, PayoffResponse, QuoteResponse, + ScreenHitOut, + ScreenResponse, SnapshotResponse, WatchlistAddRequest, WatchlistItem, @@ -41,6 +45,7 @@ iv_rank_for, list_watchlist, remove_from_watchlist, + screen_inputs, ) from options_tool.db.snapshot import snapshot_ticker from options_tool.providers.base import MarketDataProvider @@ -291,6 +296,69 @@ def post_snapshot( ) +@router.get("/screen", response_model=ScreenResponse, tags=["analytics"]) +def get_screen( + session: SessionDep, + settings: SettingsDep, + high: Annotated[float, Query(ge=0, le=100)] = 70.0, + low: Annotated[float, Query(ge=0, le=100)] = 30.0, + multiple: Annotated[float, Query(gt=1)] = 2.0, +) -> ScreenResponse: + """Flag watchlist symbols where something has changed. + + Runs entirely on stored history, so it never touches the vendor and cannot + be throttled. Purely descriptive — it narrows a list, it does not rank + opportunities or suggest positions. + """ + inputs = screen_inputs( + session, settings.options_iv_window_days, settings.options_min_history_days + ) + hits = screen(inputs, high, low, multiple) + + notes = [f"{item.ticker}: {item.iv_rank_reason}" for item in inputs if item.iv_rank is None] + + return ScreenResponse( + screened=len(inputs), + hits=[ + ScreenHitOut( + ticker=hit.ticker, + flags=[flag.value for flag in hit.flags], + summary=hit.summary, + iv_rank=hit.iv_rank, + current_iv=hit.current_iv, + volume_today=hit.volume_today, + volume_median=hit.volume_median, + volume_multiple=hit.volume_multiple, + open_interest_today=hit.open_interest_today, + open_interest_median=hit.open_interest_median, + days_to_earnings=hit.days_to_earnings, + ) + for hit in hits + ], + notes=notes, + thresholds={"high_iv_rank": high, "low_iv_rank": low, "activity_multiple": multiple}, + ) + + +@router.get("/earnings/{ticker}", response_model=EarningsResponse, tags=["market"]) +def get_earnings(ticker: str, provider: ProviderDep) -> EarningsResponse: + """Next scheduled earnings date, when the source has one. + + Returns 200 with `known: false` rather than a 404 when the date is unknown. + No free source publishes reliable forward earnings dates for every symbol, + and "we do not know" is a different answer from "none is scheduled". + """ + from datetime import UTC, datetime # noqa: PLC0415 + + when = provider.get_next_earnings_date(ticker) + return EarningsResponse( + ticker=ticker.upper(), + next_earnings=when, + days_away=(when - datetime.now(UTC).date()).days if when else None, + known=when is not None, + ) + + # -------------------------------------------------------------------------- # payoff # -------------------------------------------------------------------------- diff --git a/api/options_tool/api/schemas.py b/api/options_tool/api/schemas.py index fcbec9e..6ce8e83 100644 --- a/api/options_tool/api/schemas.py +++ b/api/options_tool/api/schemas.py @@ -31,6 +31,9 @@ "PayoffRequest", "PayoffResponse", "SnapshotResponse", + "ScreenHitOut", + "ScreenResponse", + "EarningsResponse", ] DISCLAIMER = "Not investment advice. Personal analytics only." @@ -236,3 +239,45 @@ class SnapshotResponse(BaseModel): description="False means an existing day was refreshed in place, not duplicated." ) errors: list[str] + + +class ScreenHitOut(BaseModel): + """One flagged symbol. Descriptive — not a recommendation to trade.""" + + ticker: str + flags: list[str] + summary: str + iv_rank: float | None + current_iv: float | None + volume_today: int | None + volume_median: float | None + volume_multiple: float | None + open_interest_today: int | None + open_interest_median: float | None + days_to_earnings: int | None + + +class ScreenResponse(BaseModel): + """`screened` is reported alongside `hits` on purpose. + + An empty result set could otherwise mean "nothing was flagged" or "nothing + was checked", and those are very different. `notes` carries the symbols that + could not be screened and why. + """ + + screened: int + hits: list[ScreenHitOut] + notes: list[str] + thresholds: dict[str, float] + disclaimer: str = DISCLAIMER + + +class EarningsResponse(BaseModel): + """`next_earnings` is null when unknown — which is not the same as "none".""" + + ticker: str + next_earnings: date | None + days_away: int | None + known: bool = Field( + description="False means the source had no date, not that none is scheduled." + ) diff --git a/api/options_tool/cli.py b/api/options_tool/cli.py index f60a6a5..e21c4a3 100644 --- a/api/options_tool/cli.py +++ b/api/options_tool/cli.py @@ -12,7 +12,7 @@ import argparse import logging import sys -from datetime import date +from datetime import UTC, date, datetime from pathlib import Path from typing import Any @@ -85,6 +85,27 @@ def build_parser() -> argparse.ArgumentParser: p_ivrank = sub.add_parser("ivrank", help="IV rank from locally stored history") p_ivrank.add_argument("tickers", nargs="*", help="default: the whole watchlist") + p_screen = sub.add_parser("screen", help="flag watchlist symbols where something has changed") + p_screen.add_argument("--high", type=float, default=70.0, metavar="RANK") + p_screen.add_argument("--low", type=float, default=30.0, metavar="RANK") + p_screen.add_argument("--multiple", type=float, default=2.0, help="unusual-activity threshold") + p_screen.add_argument( + "--earnings", + action="store_true", + help="also check earnings dates (one live request per symbol)", + ) + p_screen.add_argument("--csv", type=Path, default=None) + + p_export = sub.add_parser("export", help="write stored history to CSV") + p_export.add_argument("tickers", nargs="*", help="default: the whole watchlist") + p_export.add_argument("--out", type=Path, required=True, help="output CSV path") + p_export.add_argument( + "--what", + choices=("daily", "contracts"), + default="daily", + help="daily = one row per stored day; contracts = every stored contract", + ) + p_capture = sub.add_parser( "capture-fixture", help="refresh the offline test/demo fixture from the live vendor" ) @@ -116,6 +137,8 @@ def main(argv: list[str] | None = None) -> int: "watchlist": _cmd_watchlist, "snapshot": _cmd_snapshot, "ivrank": _cmd_ivrank, + "screen": _cmd_screen, + "export": _cmd_export, "capture-fixture": _cmd_capture, } return handlers[args.command](args, provider) @@ -378,6 +401,231 @@ def _cmd_ivrank(args: argparse.Namespace, provider: MarketDataProvider) -> int: return 0 +def _cmd_screen(args: argparse.Namespace, provider: MarketDataProvider) -> int: + from options_tool.analytics.chain import time_to_expiry # noqa: PLC0415 + from options_tool.analytics.screener import screen # noqa: PLC0415 + from options_tool.config import get_settings # noqa: PLC0415 + from options_tool.db.queries import list_watchlist, screen_inputs # noqa: PLC0415 + from options_tool.db.session import init_db, session_scope # noqa: PLC0415 + from options_tool.providers.base import ProviderError # noqa: PLC0415 + + settings = get_settings() + engine = init_db() + + with session_scope(engine) as session: + symbols = [t.symbol for t in list_watchlist(session)] + if not symbols: + print("watchlist is empty — add one with: options-tool watchlist --add SPY") + return 0 + + earnings: dict[str, int] = {} + near_expiry: dict[str, int] = {} + if args.earnings: + today = datetime.now(UTC).date() + for symbol in symbols: + try: + when = provider.get_next_earnings_date(symbol) + expiries = provider.get_expiries(symbol) + except ProviderError as exc: + # An optional flag must never cost you the screen itself. + print(f" warning: no earnings data for {symbol} ({exc})") + continue + if when is not None: + earnings[symbol] = (when - today).days + if expiries: + near_expiry[symbol] = round(time_to_expiry(expiries[0]) * 365) + + inputs = screen_inputs( + session, + settings.options_iv_window_days, + settings.options_min_history_days, + earnings, + near_expiry, + ) + hits = screen(inputs, args.high, args.low, args.multiple) + + # Symbols with nothing to report are still accounted for, so an empty + # screen cannot be confused with a screen that failed to run. + print(f"screened {len(inputs)} symbols · {len(hits)} flagged\n") + for note in {n for item in inputs for n in _input_notes(item)}: + print(f" note: {note}") + if not hits: + print(" nothing flagged.") + for hit in hits: + rank = f"{hit.iv_rank:.0f}" if hit.iv_rank is not None else "n/a" + print(f"{hit.ticker:<8} IV rank {rank:>4} {hit.summary}") + if hit.volume_multiple: + print( + f" volume {hit.volume_today:,} vs median " + f"{hit.volume_median:,.0f} ({hit.volume_multiple:.1f}x)" + ) + if hit.days_to_earnings is not None: + print(f" earnings in {hit.days_to_earnings} days") + + if args.csv: + _write_csv( + args.csv, + [ + "ticker", + "flags", + "iv_rank", + "current_iv", + "volume_today", + "volume_median", + "volume_multiple", + "days_to_earnings", + ], + [ + [ + h.ticker, + "|".join(f.value for f in h.flags), + h.iv_rank, + h.current_iv, + h.volume_today, + h.volume_median, + h.volume_multiple, + h.days_to_earnings, + ] + for h in hits + ], + ) + print(f"\nwrote {args.csv}") + + print(f"\n{DISCLAIMER}") + return 0 + + +def _input_notes(item: Any) -> list[str]: + """Baseline-not-ready notes, surfaced once rather than per symbol.""" + notes: list[str] = [] + if item.iv_rank is None: + notes.append(f"{item.ticker}: {item.iv_rank_reason}") + return notes + + +def _cmd_export(args: argparse.Namespace, provider: MarketDataProvider) -> int: # noqa: ARG001 + from sqlalchemy import select # noqa: PLC0415 + + from options_tool.db.models import Contract, Snapshot, Ticker # noqa: PLC0415 + from options_tool.db.queries import list_watchlist # noqa: PLC0415 + from options_tool.db.session import init_db, session_scope # noqa: PLC0415 + + engine = init_db() + with session_scope(engine) as session: + symbols = args.tickers or [t.symbol for t in list_watchlist(session)] + if not symbols: + print("nothing to export — the watchlist is empty") + return 0 + symbols = [s.upper() for s in symbols] + + if args.what == "daily": + rows = session.execute( + select( + Ticker.symbol, + Snapshot.snapshot_date, + Snapshot.spot, + Snapshot.atm_iv_30d, + Snapshot.atm_iv_front, + Snapshot.contract_count, + Snapshot.solved_count, + Snapshot.risk_free_rate, + Snapshot.dividend_yield, + Snapshot.provider, + ) + .join(Ticker, Snapshot.ticker_id == Ticker.id) + .where(Ticker.symbol.in_(symbols)) + .order_by(Ticker.symbol, Snapshot.snapshot_date) + ).all() + header = [ + "ticker", + "snapshot_date", + "spot", + "atm_iv_30d", + "atm_iv_front", + "contract_count", + "solved_count", + "risk_free_rate", + "dividend_yield", + "provider", + ] + else: + rows = session.execute( + select( + Ticker.symbol, + Snapshot.snapshot_date, + Contract.expiry, + Contract.strike, + Contract.right, + Contract.bid, + Contract.ask, + Contract.mid, + Contract.last, + Contract.volume, + Contract.open_interest, + Contract.iv, + Contract.iv_status, + Contract.delta, + Contract.gamma, + Contract.vega, + Contract.theta, + Contract.rho, + ) + .join(Snapshot, Contract.snapshot_id == Snapshot.id) + .join(Ticker, Snapshot.ticker_id == Ticker.id) + .where(Ticker.symbol.in_(symbols)) + .order_by( + Ticker.symbol, + Snapshot.snapshot_date, + Contract.expiry, + Contract.right, + Contract.strike, + ) + ).all() + header = [ + "ticker", + "snapshot_date", + "expiry", + "strike", + "right", + "bid", + "ask", + "mid", + "last", + "volume", + "open_interest", + "iv", + "iv_status", + "delta", + "gamma", + "vega", + "theta", + "rho", + ] + + _write_csv(args.out, header, [list(row) for row in rows]) + print(f"wrote {len(rows)} rows to {args.out}") + # Said explicitly: the greeks in this file are ours, and re-importing it + # elsewhere carries our assumptions with it. + print("greeks and implied volatility in this file were computed by this tool.") + return 0 + + +def _write_csv(path: Path, header: list[str], rows: list[list[Any]]) -> None: + """Write a CSV, leaving missing values genuinely empty rather than 0. + + An empty cell reads as "not available" in every spreadsheet; a 0 reads as a + measurement, which is the one thing it must not. + """ + import csv # noqa: PLC0415 + + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.writer(handle) + writer.writerow(header) + for row in rows: + writer.writerow(["" if value is None else value for value in row]) + + def _cmd_capture(args: argparse.Namespace, provider: MarketDataProvider) -> int: from options_tool.providers.capture import capture_fixture # noqa: PLC0415 from options_tool.providers.fixture_provider import DEFAULT_FIXTURE_PATH # noqa: PLC0415 diff --git a/api/options_tool/db/queries.py b/api/options_tool/db/queries.py index c974139..4da5628 100644 --- a/api/options_tool/db/queries.py +++ b/api/options_tool/db/queries.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from options_tool.analytics.iv_rank import IVObservation, IVRankResult, compute_iv_rank +from options_tool.analytics.screener import ScreenInput from options_tool.db.models import Contract, Snapshot, Ticker __all__ = [ @@ -23,6 +24,8 @@ "add_to_watchlist", "remove_from_watchlist", "contract_count", + "activity_history", + "screen_inputs", ] @@ -133,3 +136,65 @@ def contract_count(session: Session, symbol: str | None = None) -> int: .where(Ticker.symbol == symbol.strip().upper()) ) return int(session.scalar(statement) or 0) + + +def activity_history(session: Session, symbol: str, limit: int = 60) -> tuple[list[int], list[int]]: + """(volume, open interest) totals per stored day, oldest first. + + Aggregated in SQL rather than pulled row by row: a year of snapshots for one + symbol is hundreds of thousands of contract rows, and the screener only ever + wants two numbers a day from them. + """ + rows = session.execute( + select( + Snapshot.snapshot_date, + func.coalesce(func.sum(Contract.volume), 0), + func.coalesce(func.sum(Contract.open_interest), 0), + ) + .join(Contract, Contract.snapshot_id == Snapshot.id) + .join(Ticker, Snapshot.ticker_id == Ticker.id) + .where(Ticker.symbol == symbol.strip().upper()) + .group_by(Snapshot.snapshot_date) + .order_by(Snapshot.snapshot_date.desc()) + .limit(limit) + ).all() + + ordered = list(reversed(rows)) + return [int(row[1]) for row in ordered], [int(row[2]) for row in ordered] + + +def screen_inputs( + session: Session, + window_days: int = 252, + min_history_days: int = 20, + earnings: dict[str, int] | None = None, + days_to_near_expiry: dict[str, int] | None = None, +) -> list[ScreenInput]: + """Assemble what the screener needs for every active watchlist symbol. + + `earnings` and `days_to_near_expiry` are passed in rather than fetched here, + because they need the live provider and this module deliberately touches + only stored data. + """ + inputs = [] + for ticker in list_watchlist(session): + rank = iv_rank_for(session, ticker.symbol, window_days, min_history_days) + volume, open_interest = activity_history(session, ticker.symbol) + + inputs.append( + ScreenInput( + ticker=ticker.symbol, + iv_rank=rank.rank, + iv_rank_reason=rank.reason, + current_iv=rank.current_iv, + # The last stored day is "today"; the rest is its own baseline, + # so today is excluded from the median it is compared against. + volume_today=volume[-1] if volume else None, + volume_history=volume[:-1], + open_interest_today=open_interest[-1] if open_interest else None, + open_interest_history=open_interest[:-1], + days_to_earnings=(earnings or {}).get(ticker.symbol), + days_to_near_expiry=(days_to_near_expiry or {}).get(ticker.symbol), + ) + ) + return inputs diff --git a/api/options_tool/providers/base.py b/api/options_tool/providers/base.py index 48da45d..1554d89 100644 --- a/api/options_tool/providers/base.py +++ b/api/options_tool/providers/base.py @@ -129,6 +129,16 @@ def get_expiries(self, ticker: str) -> tuple[date, ...]: def get_chain(self, ticker: str, expiry: date) -> OptionChain: """Full option chain for one `ticker` and `expiry`.""" + def get_next_earnings_date(self, ticker: str) -> date | None: # noqa: ARG002 + """Next scheduled earnings date, or None if unknown. + + Optional: the default says "unknown" rather than raising, because no + free source publishes reliable forward earnings dates for every symbol + and a provider that cannot supply one should not break the chain view. + Callers must treat None as "no information", never as "no earnings". + """ + return None + def resolve_expiry(self, ticker: str, expiry: date | None) -> date: """Pick an expiry: the one given, or the nearest listed one if None. diff --git a/api/options_tool/providers/yfinance_provider.py b/api/options_tool/providers/yfinance_provider.py index 1eb4a5f..6408dbb 100644 --- a/api/options_tool/providers/yfinance_provider.py +++ b/api/options_tool/providers/yfinance_provider.py @@ -156,6 +156,39 @@ def get_expiries(self, ticker: str) -> tuple[date, ...]: raise UnknownTickerError(f"{symbol}: Yahoo lists no option expiries") return tuple(sorted(date.fromisoformat(str(value)) for value in raw)) + def get_next_earnings_date(self, ticker: str) -> date | None: + """Next earnings date from Yahoo's calendar, if it has one. + + Wrapped broadly on purpose: this is a decorative flag on the chain view, + and Yahoo's calendar endpoint is the flakiest part of an already + unofficial source. Losing the flag is acceptable; losing the chain + because the flag failed is not. + """ + symbol = ticker.upper() + try: + calendar = self._ticker(symbol).calendar + except Exception as exc: # noqa: BLE001 -- an optional extra, never fatal + logger.info("no earnings calendar for %s: %s", symbol, exc) + return None + + raw = None + if isinstance(calendar, dict): + raw = calendar.get("Earnings Date") or calendar.get("earningsDate") + if isinstance(raw, (list, tuple)): + raw = raw[0] if raw else None + if raw is None: + return None + + try: + if isinstance(raw, date) and not isinstance(raw, datetime): + return raw + if isinstance(raw, datetime): + return raw.date() + return date.fromisoformat(str(raw)[:10]) + except (ValueError, TypeError): + logger.info("unparseable earnings date for %s: %r", symbol, raw) + return None + def get_chain(self, ticker: str, expiry: date) -> OptionChain: symbol = ticker.upper() chain = self._with_retries( diff --git a/api/tests/test_api.py b/api/tests/test_api.py index 0391cc1..0f12b8b 100644 --- a/api/tests/test_api.py +++ b/api/tests/test_api.py @@ -330,3 +330,43 @@ def test_the_full_loop(self, api_client: TestClient) -> None: assert item["stored_days"] == 1 assert item["first_snapshot"] == item["last_snapshot"] assert date.fromisoformat(item["last_snapshot"]) + + +class TestScreenEndpoint: + def test_reports_how_many_were_screened(self, api_client: TestClient) -> None: + """`screened` distinguishes "nothing flagged" from "nothing checked".""" + api_client.post("/watchlist", json={"symbol": "AAPL"}) + api_client.post("/snapshot/AAPL") + + body = api_client.get("/screen").json() + assert body["screened"] == 1 + assert body["hits"] == [] + assert any("insufficient history" in note for note in body["notes"]) + + def test_thresholds_are_echoed_back(self, api_client: TestClient) -> None: + body = api_client.get("/screen", params={"high": 80, "low": 20}).json() + assert body["thresholds"]["high_iv_rank"] == 80 + assert body["thresholds"]["low_iv_rank"] == 20 + + def test_invalid_thresholds_are_422(self, api_client: TestClient) -> None: + assert api_client.get("/screen", params={"high": 500}).status_code == 422 + assert api_client.get("/screen", params={"multiple": 0.5}).status_code == 422 + + def test_carries_the_disclaimer(self, api_client: TestClient) -> None: + assert "Not investment advice" in api_client.get("/screen").json()["disclaimer"] + + def test_empty_watchlist_screens_zero_without_erroring(self, api_client: TestClient) -> None: + body = api_client.get("/screen").json() + assert body["screened"] == 0 + assert body["hits"] == [] + + +class TestEarningsEndpoint: + def test_unknown_is_200_with_known_false_not_a_404(self, api_client: TestClient) -> None: + """An unknown date is a different answer from no date being scheduled.""" + response = api_client.get("/earnings/AAPL") + assert response.status_code == 200 + body = response.json() + assert body["known"] is False + assert body["next_earnings"] is None + assert body["days_away"] is None diff --git a/api/tests/test_export.py b/api/tests/test_export.py new file mode 100644 index 0000000..731e1da --- /dev/null +++ b/api/tests/test_export.py @@ -0,0 +1,174 @@ +"""CSV export and the earnings flag.""" + +from __future__ import annotations + +import csv +import sys +import types +from datetime import date +from pathlib import Path + +import pytest + +from options_tool.cli import main +from options_tool.providers.fixture_provider import FixtureProvider +from options_tool.providers.yfinance_provider import YFinanceProvider + + +def run(*args: str) -> int: + return main(["--provider", "fixture", *args]) + + +@pytest.fixture +def seeded(settings: object, capsys: pytest.CaptureFixture[str]) -> None: + run("watchlist", "--add", "AAPL") + run("snapshot", "AAPL") + capsys.readouterr() + + +class TestExport: + def test_daily_export(self, seeded: None, tmp_path: Path) -> None: + target = tmp_path / "daily.csv" + assert run("export", "--out", str(target)) == 0 + + rows = list(csv.DictReader(target.open())) + assert len(rows) == 1 + assert rows[0]["ticker"] == "AAPL" + assert float(rows[0]["atm_iv_30d"]) > 0 + # The assumptions travel with the data; without them a stored IV cannot + # be interpreted by whoever opens this file later. + assert rows[0]["risk_free_rate"] == "0.04" + assert rows[0]["provider"] == "fixture" + + def test_contracts_export_carries_our_greeks(self, seeded: None, tmp_path: Path) -> None: + target = tmp_path / "contracts.csv" + assert run("export", "AAPL", "--what", "contracts", "--out", str(target)) == 0 + + rows = list(csv.DictReader(target.open())) + assert len(rows) > 100 + solved = [row for row in rows if row["iv"]] + assert solved + assert all(row["delta"] for row in solved) + + def test_missing_values_are_empty_not_zero(self, seeded: None, tmp_path: Path) -> None: + """A blank cell reads as "not available"; a 0 reads as a measurement.""" + target = tmp_path / "contracts.csv" + run("export", "AAPL", "--what", "contracts", "--out", str(target)) + + rows = list(csv.DictReader(target.open())) + unsolved = [row for row in rows if not row["iv"]] + assert unsolved, "the capture contains stale deep-ITM quotes" + for row in unsolved: + assert row["iv"] == "" + assert row["delta"] == "" + assert row["iv_status"] not in ("", "ok") + + def test_creates_missing_directories(self, seeded: None, tmp_path: Path) -> None: + target = tmp_path / "nested" / "deep" / "out.csv" + assert run("export", "--out", str(target)) == 0 + assert target.exists() + + def test_export_states_the_greeks_are_ours( + self, seeded: None, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + run("export", "--out", str(tmp_path / "x.csv")) + assert "computed by this tool" in capsys.readouterr().out + + def test_empty_watchlist_exports_nothing_gracefully( + self, settings: object, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + assert run("export", "--out", str(tmp_path / "x.csv")) == 0 + assert "nothing to export" in capsys.readouterr().out + + def test_csv_round_trips_through_a_reader(self, seeded: None, tmp_path: Path) -> None: + """Guards against an unquoted comma or newline corrupting the file.""" + target = tmp_path / "contracts.csv" + run("export", "AAPL", "--what", "contracts", "--out", str(target)) + + with target.open(newline="", encoding="utf-8") as handle: + rows = list(csv.reader(handle)) + widths = {len(row) for row in rows} + assert len(widths) == 1, "every row must have the same number of fields" + + +class TestScreenCommand: + def test_reports_how_many_were_screened( + self, seeded: None, capsys: pytest.CaptureFixture[str] + ) -> None: + """An empty screen must not look like a screen that failed to run.""" + assert run("screen") == 0 + out = capsys.readouterr().out + assert "screened 1 symbols" in out + assert "insufficient history" in out + + def test_empty_watchlist(self, settings: object, capsys: pytest.CaptureFixture[str]) -> None: + assert run("screen") == 0 + assert "watchlist is empty" in capsys.readouterr().out + + def test_csv_output(self, seeded: None, tmp_path: Path) -> None: + target = tmp_path / "screen.csv" + assert run("screen", "--csv", str(target)) == 0 + assert target.exists() + assert "ticker" in target.read_text().splitlines()[0] + + +class TestEarningsDate: + def test_the_default_provider_says_unknown_rather_than_raising( + self, provider: FixtureProvider + ) -> None: + """Unknown is a valid answer; a missing date must not break the chain.""" + assert provider.get_next_earnings_date("AAPL") is None + + @pytest.mark.parametrize( + "calendar", + [ + {"Earnings Date": [date(2026, 10, 30)]}, + {"Earnings Date": date(2026, 10, 30)}, + {"earningsDate": ["2026-10-30"]}, + ], + ) + def test_parses_the_shapes_yahoo_returns( + self, calendar: dict, monkeypatch: pytest.MonkeyPatch + ) -> None: + class Ticker: + def __init__(self, symbol: str) -> None: + self.calendar = calendar + + module = types.ModuleType("yfinance") + module.Ticker = Ticker # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + + assert YFinanceProvider().get_next_earnings_date("AAPL") == date(2026, 10, 30) + + @pytest.mark.parametrize( + "calendar", [{}, {"Earnings Date": []}, {"Earnings Date": ["not-a-date"]}, None] + ) + def test_unusable_calendars_yield_none_not_an_exception( + self, calendar: object, monkeypatch: pytest.MonkeyPatch + ) -> None: + class Ticker: + def __init__(self, symbol: str) -> None: + self.calendar = calendar + + module = types.ModuleType("yfinance") + module.Ticker = Ticker # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + + assert YFinanceProvider().get_next_earnings_date("AAPL") is None + + def test_a_raising_calendar_does_not_propagate(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Losing a decorative flag is fine; losing the chain because of it is not.""" + + class Ticker: + def __init__(self, symbol: str) -> None: + pass + + @property + def calendar(self) -> dict: + raise RuntimeError("Yahoo changed the endpoint again") + + module = types.ModuleType("yfinance") + module.Ticker = Ticker # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "yfinance", module) + + assert YFinanceProvider().get_next_earnings_date("AAPL") is None diff --git a/api/tests/test_screener.py b/api/tests/test_screener.py new file mode 100644 index 0000000..79a8dd9 --- /dev/null +++ b/api/tests/test_screener.py @@ -0,0 +1,166 @@ +"""The screener. + +It narrows a watchlist; it does not rank opportunities. These tests hold it to +that, and to the same refusal-to-guess discipline as the rest of the project: +with too little history it says so rather than flagging on a baseline of six +days. +""" + +from __future__ import annotations + +import pytest + +from options_tool.analytics.screener import ( + MIN_ACTIVITY_HISTORY, + ScreenFlag, + ScreenInput, + screen, +) + + +def make_input( + ticker: str = "AAPL", + iv_rank: float | None = 50.0, + volume_today: int | None = 1_000, + volume_history: list[int] | None = None, + open_interest_today: int | None = 5_000, + open_interest_history: list[int] | None = None, + days_to_earnings: int | None = None, + days_to_near_expiry: int | None = None, +) -> ScreenInput: + return ScreenInput( + ticker=ticker, + iv_rank=iv_rank, + iv_rank_reason="ranked against stored history" if iv_rank else "insufficient history", + current_iv=0.25, + volume_today=volume_today, + volume_history=volume_history if volume_history is not None else [1_000] * 20, + open_interest_today=open_interest_today, + open_interest_history=( + open_interest_history if open_interest_history is not None else [5_000] * 20 + ), + days_to_earnings=days_to_earnings, + days_to_near_expiry=days_to_near_expiry, + ) + + +class TestIVRankFlags: + def test_high_rank_flagged(self) -> None: + hits = screen([make_input(iv_rank=85.0)]) + assert hits[0].flags == [ScreenFlag.HIGH_IV_RANK] + + def test_low_rank_flagged(self) -> None: + hits = screen([make_input(iv_rank=12.0)]) + assert hits[0].flags == [ScreenFlag.LOW_IV_RANK] + + def test_middling_rank_not_flagged(self) -> None: + assert screen([make_input(iv_rank=50.0)]) == [] + + @pytest.mark.parametrize("rank", [70.0, 30.0]) + def test_thresholds_are_inclusive(self, rank: float) -> None: + assert screen([make_input(iv_rank=rank)]) + + def test_thresholds_are_configurable(self) -> None: + assert screen([make_input(iv_rank=60.0)]) == [] + assert screen([make_input(iv_rank=60.0)], high_iv_rank=55.0) + + +class TestUnavailableRankIsReportedNotHidden: + def test_missing_rank_produces_no_flag(self) -> None: + """No history is not a signal, in either direction.""" + assert screen([make_input(iv_rank=None)]) == [] + + def test_but_a_symbol_with_another_flag_carries_the_note(self) -> None: + """Otherwise "not flagged" and "not checkable" look identical.""" + hits = screen([make_input(iv_rank=None, volume_today=10_000, volume_history=[1_000] * 20)]) + assert hits[0].flags == [ScreenFlag.UNUSUAL_VOLUME] + assert any("IV rank unavailable" in note for note in hits[0].notes) + + +class TestActivityFlags: + def test_volume_well_above_median_flagged(self) -> None: + hits = screen([make_input(volume_today=5_000, volume_history=[1_000] * 20)]) + assert ScreenFlag.UNUSUAL_VOLUME in hits[0].flags + assert hits[0].volume_multiple == pytest.approx(5.0) + assert hits[0].volume_median == pytest.approx(1_000) + + def test_ordinary_volume_not_flagged(self) -> None: + assert screen([make_input(volume_today=1_100, volume_history=[1_000] * 20)]) == [] + + def test_uses_the_median_not_the_mean(self) -> None: + """Option volume is spiky; one expiry week would poison a mean for weeks.""" + history = [1_000] * 19 + [500_000] + hits = screen([make_input(volume_today=3_000, volume_history=history)]) + assert ScreenFlag.UNUSUAL_VOLUME in hits[0].flags + assert hits[0].volume_median == pytest.approx(1_000) + + def test_open_interest_flagged_separately(self) -> None: + hits = screen([make_input(open_interest_today=50_000, open_interest_history=[5_000] * 20)]) + assert ScreenFlag.UNUSUAL_OPEN_INTEREST in hits[0].flags + + +class TestTooLittleHistoryToJudgeActivity: + def test_short_history_produces_no_activity_flag(self) -> None: + """With six days, "2x the median" describes the sample, not the market.""" + hits = screen([make_input(iv_rank=50.0, volume_today=1_000_000, volume_history=[10] * 5)]) + assert hits == [] + + def test_exactly_the_minimum_is_enough(self) -> None: + hits = screen( + [ + make_input( + volume_today=10_000, + volume_history=[1_000] * MIN_ACTIVITY_HISTORY, + ) + ] + ) + assert ScreenFlag.UNUSUAL_VOLUME in hits[0].flags + + def test_an_all_zero_baseline_does_not_flag(self) -> None: + """Any activity against a zero median is infinite growth and meaningless.""" + hits = screen([make_input(volume_today=5, volume_history=[0] * 20)]) + assert hits == [] + + def test_missing_today_value_does_not_flag(self) -> None: + assert screen([make_input(volume_today=None, volume_history=[1_000] * 20)]) == [] + + +class TestEarnings: + def test_earnings_before_the_near_expiry_is_flagged(self) -> None: + hits = screen([make_input(days_to_earnings=5, days_to_near_expiry=10)]) + assert ScreenFlag.NEAR_EARNINGS in hits[0].flags + + def test_earnings_after_the_near_expiry_is_not(self) -> None: + assert screen([make_input(days_to_earnings=30, days_to_near_expiry=10)]) == [] + + def test_unknown_earnings_date_is_not_a_flag(self) -> None: + """None means "we do not know", which must never read as "none scheduled".""" + assert screen([make_input(days_to_earnings=None, days_to_near_expiry=10)]) == [] + + def test_past_earnings_is_not_flagged(self) -> None: + assert screen([make_input(days_to_earnings=-3, days_to_near_expiry=10)]) == [] + + +class TestOrderingAndOutput: + def test_more_flags_sort_first(self) -> None: + busy = make_input( + ticker="BUSY", iv_rank=90.0, volume_today=9_000, volume_history=[1_000] * 20 + ) + quiet = make_input(ticker="QUIET", iv_rank=95.0) + assert [hit.ticker for hit in screen([quiet, busy])] == ["BUSY", "QUIET"] + + def test_unflagged_symbols_are_omitted_entirely(self) -> None: + hits = screen([make_input(ticker="A", iv_rank=50.0), make_input(ticker="B", iv_rank=90.0)]) + assert [hit.ticker for hit in hits] == ["B"] + + def test_summary_is_human_readable(self) -> None: + hits = screen([make_input(iv_rank=90.0)]) + assert "implied volatility is high" in hits[0].summary + + def test_every_flag_has_an_explanation(self) -> None: + for flag in ScreenFlag: + assert flag.explanation + assert not flag.explanation.endswith(".") + + def test_empty_input(self) -> None: + assert screen([]) == [] From dc6ec1127bf1f57fc5080cc0b4b446b15c3ca910 Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:56:14 -0400 Subject: [PATCH 09/11] Make it deployable: private API, authenticated front end, self-scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the deployment rather than bolting auth onto the existing shape. The API now has no public address at all — it is reachable only over Fly's private network, from the Next.js server, which proxies to it. Three problems disappear instead of being managed: - one auth check covers everything, because the browser can only reach the API through /api/* on the web app, behind the same middleware as every other route - CORS stops applying, so there is no allow-list to keep in sync with a domain - the API address becomes runtime config; it used to be compiled into the client bundle, so an image only worked against the host it was built for Auth is HTTP Basic in Next.js middleware, with a constant-time comparison and both sides always compared so a wrong username and a wrong password take the same path. /healthz is exempt because Fly's checker cannot present credentials, and weakening the middleware to let it through would be the worse trade. 19 tests cover the ugly cases: malformed base64, missing scheme, a password containing a colon, and near-miss credentials. The daily snapshot moves into the API process. A Fly volume attaches to exactly one machine, and the job writes to the same SQLite file the API reads, so a separate scheduled machine could not open it. It catches up on start — if the machine was down through the scheduled time and today has no snapshot, it runs immediately — because a missed day is a permanent hole that no free source can backfill. A failing run is logged and the loop survives it; tomorrow still needs to run. Two real bugs fixed: - **Startup crashed on any database the CLI had touched.** init_db() builds the schema with create_all and leaves no alembic_version row, so `upgrade head` tried to create the tables again and died with "table tickers already exists". Anyone who ran `options-tool snapshot` before first starting the API could not start it at all. The schema is now adopted by stamping. Covered by test_startup.py, which reproduces the original failure. - **No .dockerignore**, so every build uploaded 751MB of context including api/.venv and web/node_modules — and a host-built .venv of macOS binaries could shadow the Linux one inside the image. Also: /live split from /health, because a readiness probe that touches the database will restart a container for a storage problem that restarting cannot fix. Request ids on every response and log line, since one chain request fans out into several vendor calls and retries. JSON logging for deployments. Migrations applied at startup before serving. /health reports whether the scheduler is running, so a deployment that silently stopped accumulating history is visible rather than discovered weeks later when IV rank never arrives. CI gains an auth test against the real web image: /healthz open, everything else 401, correct credentials 200. 547 Python tests, 48 TypeScript. Verified end to end locally — auth blocks, the proxy serves, and the UI renders identically through the new same-origin data path. Co-Authored-By: Claude Opus 5 (1M context) --- .dockerignore | 48 +++++ .env.example | 47 ++++- .github/workflows/ci.yml | 35 +++- Dockerfile.api | 13 +- Dockerfile.web | 12 +- README.md | 73 +++++--- api/options_tool/api/main.py | 147 +++++++++++++-- api/options_tool/api/routes.py | 32 +++- api/options_tool/api/schemas.py | 9 +- api/options_tool/config.py | 52 ++++++ api/options_tool/logging_config.py | 81 +++++++++ api/options_tool/scheduler.py | 175 ++++++++++++++++++ api/pyproject.toml | 4 +- api/tests/conftest.py | 5 + api/tests/test_scheduler.py | 279 +++++++++++++++++++++++++++++ api/tests/test_startup.py | 126 +++++++++++++ deploy/README.md | 240 ++++++++++++++++--------- docker-compose.yml | 49 ++--- fly.api.toml | 60 +++++++ fly.web.toml | 48 +++++ web/app/api/[...path]/route.ts | 99 ++++++++++ web/app/healthz/route.ts | 21 +++ web/lib/api.ts | 24 ++- web/middleware.test.ts | 102 +++++++++++ web/middleware.ts | 93 ++++++++++ web/next.config.ts | 10 +- web/vitest.config.ts | 2 +- 27 files changed, 1705 insertions(+), 181 deletions(-) create mode 100644 .dockerignore create mode 100644 api/options_tool/logging_config.py create mode 100644 api/options_tool/scheduler.py create mode 100644 api/tests/test_scheduler.py create mode 100644 api/tests/test_startup.py create mode 100644 fly.api.toml create mode 100644 fly.web.toml create mode 100644 web/app/api/[...path]/route.ts create mode 100644 web/app/healthz/route.ts create mode 100644 web/middleware.test.ts create mode 100644 web/middleware.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..904ab52 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,48 @@ +# Everything the image does not need. Without this the build context includes +# api/.venv and web/node_modules — hundreds of megabytes uploaded to the builder +# on every build, and worse, a host-built .venv containing macOS binaries can +# shadow the Linux one installed inside the image. + +.git +.github +.gitignore +.dockerignore + +# Python +**/.venv +**/venv +**/__pycache__ +**/*.py[cod] +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +**/.coverage +**/coverage.xml +**/htmlcov +**/*.egg-info + +# Node +**/node_modules +**/.next +**/out +**/dist +**/build +**/*.tsbuildinfo + +# Local state — never bake a developer's database into an image +**/.env +**/*.db +**/*.sqlite +**/*.sqlite3 +data/ + +# Docs and images +docs/ +deploy/ +*.md +!README.md + +# OS / editor +**/.DS_Store +**/.idea +**/.vscode diff --git a/.env.example b/.env.example index 1d051cc..ec9c335 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,11 @@ # Copy to .env and adjust. .env is gitignored and must never be committed. +# +# Every value here has a working default, so a clean clone runs with no .env at +# all. The ones that matter for a real deployment are marked. # --- Market data ------------------------------------------------------------- -# Which provider the CLI and API use by default: yfinance | fixture +# yfinance | fixture ("fixture" replays the checked-in capture: offline, stale, +# and deterministic — useful for demos when Yahoo is throttling.) OPTIONS_PROVIDER=yfinance # Optional keys for alternative providers. yfinance needs none; these slots exist @@ -26,13 +30,42 @@ OPTIONS_IV_WINDOW_DAYS=252 OPTIONS_MIN_HISTORY_DAYS=20 # --- Storage ----------------------------------------------------------------- +# In containers this is sqlite:////data/options.db, on a mounted volume. The +# accumulated IV history is the only irreplaceable state in this project. OPTIONS_DATABASE_URL=sqlite:///./data/options.db +# --- Daily snapshot ---------------------------------------------------------- +# Runs inside the API process; see api/options_tool/scheduler.py for why it is +# not a separate container. Off by default so local runs and tests never fire +# live vendor requests on a timer. TURN THIS ON IN A DEPLOYMENT. +OPTIONS_SNAPSHOT_ENABLED=false +# 24h UTC. 21:15 is after the US equity close in both EST and EDT. +OPTIONS_SNAPSHOT_AT=21:15 + +# --- Operations -------------------------------------------------------------- +# text | json (json for a deployment whose logs are shipped somewhere) +OPTIONS_LOG_FORMAT=text +# Apply Alembic migrations when the API starts. Idempotent, and it adopts a +# schema created by the CLI rather than failing on it. +OPTIONS_RUN_MIGRATIONS_ON_STARTUP=true +# Browser origins allowed to call the API directly. Leave empty: the front end +# proxies server-side, so no cross-origin request is ever made. +OPTIONS_CORS_ORIGINS= + # --- Web --------------------------------------------------------------------- -NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 +# Where the Next.js server reaches the API. Read at request time, never baked +# into the browser bundle, so one built image runs in every environment. +# local (uvicorn on the host): http://localhost:8000 +# docker compose: http://api:8000 +# fly.io: http://options-analytics-api.internal:8000 +API_INTERNAL_URL=http://localhost:8000 + +# HTTP Basic auth over the entire web app. LEAVE BOTH EMPTY FOR LOCAL DEV — the +# app then runs open. SET BOTH FOR ANY DEPLOYMENT; the API has no public address +# and no auth of its own, so this is the whole authentication story. +# On Fly these are secrets (`fly secrets set`), never committed values. +APP_USERNAME= +APP_PASSWORD= -# --- Deployment (docker-compose) --------------------------------------------- -# The URL the BROWSER will call, baked into the client bundle at build time. -# Behind nginx this is https://your-domain/api — not the internal service name. -# Changing it requires rebuilding the web image, not just restarting it. -PUBLIC_API_URL=http://localhost:8000 +# Host port for the web app under docker compose. +WEB_PORT=3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83066de..9e96182 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,8 +143,6 @@ jobs: - name: Build run: npm run build - env: - NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 # The development machine has no Docker, so the images would otherwise ship # written but never built. Building them here is the difference between @@ -170,18 +168,22 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - - name: The API image must serve a healthy /health + - name: The API image must start, migrate and serve run: | docker run -d --name api -p 8000:8000 \ -e OPTIONS_PROVIDER=fixture \ -e OPTIONS_DATABASE_URL=sqlite:////data/options.db \ options-analytics-api:ci for _ in $(seq 1 30); do - if curl -fs localhost:8000/health > /dev/null; then break; fi + if curl -fs localhost:8000/live > /dev/null; then break; fi sleep 2 done + curl -fs localhost:8000/live curl -fs localhost:8000/health | tee health.json grep -q '"database_reachable":true' health.json + # Migrations run at startup against an empty volume, so the schema + # must be at head without any separate migrate step. + docker exec api python -c "import sqlite3;print(sqlite3.connect('/data/options.db').execute('select * from alembic_version').fetchone())" | grep -q . docker rm -f api - name: The snapshot command must be idempotent inside the container @@ -210,10 +212,33 @@ jobs: push: false load: true tags: options-analytics-web:ci - build-args: NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 cache-from: type=gha cache-to: type=gha,mode=max + # Basic auth is the entire authentication story for the deployed app: the + # API has no public address, so if this middleware is wrong nothing else + # catches it. Verified against the real image, not just in unit tests. + - name: The web image must enforce Basic auth + if: needs.detect.outputs.web == 'true' + run: | + docker run -d --name web -p 3000:3000 \ + -e APP_USERNAME=ci -e APP_PASSWORD=secret \ + -e API_INTERNAL_URL=http://127.0.0.1:8000 \ + options-analytics-web:ci + for _ in $(seq 1 30); do + if curl -fs localhost:3000/healthz > /dev/null; then break; fi + sleep 2 + done + # /healthz must be reachable without credentials, or the platform + # health check fails and Fly kills a perfectly healthy machine. + test "$(curl -s -o /dev/null -w '%{http_code}' localhost:3000/healthz)" = "200" + # Everything else must not be. + test "$(curl -s -o /dev/null -w '%{http_code}' localhost:3000/)" = "401" + test "$(curl -s -o /dev/null -w '%{http_code}' localhost:3000/api/watchlist)" = "401" + test "$(curl -s -o /dev/null -w '%{http_code}' -u ci:wrong localhost:3000/)" = "401" + test "$(curl -s -o /dev/null -w '%{http_code}' -u ci:secret localhost:3000/)" = "200" + docker rm -f web + - name: Validate the compose file run: docker compose config --quiet diff --git a/Dockerfile.api b/Dockerfile.api index 7c9ffbb..f090a34 100644 --- a/Dockerfile.api +++ b/Dockerfile.api @@ -47,9 +47,12 @@ ENV OPTIONS_DATABASE_URL=sqlite:////data/options.db VOLUME ["/data"] EXPOSE 8000 -# The API reports database and provider reachability, so the container is marked -# unhealthy when it is up but cannot actually serve, not merely when it is dead. -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=4).status == 200 else 1)" - +# Checks /live, not /health. A container health check answers "should this be +# restarted", and restarting cannot fix an unreachable database -- pointing it +# at the readiness endpoint would turn a degraded service into a crash loop. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/live', timeout=4).status == 200 else 1)" + +# Overridden in fly.api.toml to bind :: instead, because Fly's private network +# is IPv6-only. CMD ["uvicorn", "options_tool.api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.web b/Dockerfile.web index ea6ec8e..dfdaa5b 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -12,10 +12,10 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY web/ ./ -# Baked into the client bundle at build time, so it must be supplied here rather -# than at run time. Changing the API URL means rebuilding this image. -ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 -ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL} +# Nothing environment-specific is baked in. The browser calls this app's own +# /api route, which forwards server-side to API_INTERNAL_URL -- read at request +# time. So one built image runs unchanged in every environment, and moving the +# API does not require a rebuild. ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build @@ -41,7 +41,9 @@ COPY --from=builder --chown=nextjs:nextjs /app/public ./public USER nextjs EXPOSE 3000 +# /healthz rather than /, because / is behind Basic auth and the check cannot +# present credentials. HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD node -e "require('http').get('http://127.0.0.1:3000/',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" + CMD node -e "require('http').get('http://127.0.0.1:3000/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" CMD ["node", "server.js"] diff --git a/README.md b/README.md index a80b213..7ee2eeb 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ rather than rendering a misleading zero. | M3 — FastAPI | done | | M4 — tests + CI | done | | M5 — Next.js front end | done | -| M6 — Docker + Linux deploy | images build in CI; **not yet deployed to a host** | +| M6 — Docker + deployment | Fly.io, private API + authenticated front end | | M7 — polish (earnings flag, screener, CSV export) | done | --- @@ -269,7 +269,7 @@ cannot be priced is information, so it is shown rather than dropped. ## Tests -509 tests. **The suite runs with no network at all**, which is enforced rather +547 Python tests and 48 TypeScript tests. **The Python suite runs with no network at all**, which is enforced rather than intended: `pytest-socket` is configured with `--disable-socket`, so any accidental outbound call fails the test that made it. `tests/test_offline.py` asserts the block is actually in place, so the guarantee cannot rot quietly if @@ -354,27 +354,60 @@ row, so a stored IV remains interpretable by whoever opens the file later. ## Deployment +Two Fly.io apps. **The API has no public address at all** — it is reachable only +over Fly's private network, from the web app, which is the single authenticated +surface. + +``` + internet ──TLS──▶ web (HTTP Basic auth, Next.js) + │ + │ server-side proxy, private network + ▼ + api (FastAPI — no public address) + │ + ▼ + volume: /data/options.db +``` + +That shape was chosen over the obvious one (expose the API, add a token) because +it removes three problems rather than managing them: + +- **One auth check covers everything.** The browser can only reach the API via + `/api/*` on the web app, behind the same middleware as every other route. + There is no second, weaker door. +- **CORS stops existing.** Same origin, no preflight, no allow-list to keep in + sync with the deployed domain. +- **The API address becomes runtime config.** It used to be compiled into the + client bundle, so the image only worked against the host it was built for. + ```bash -cp .env.example .env # set PUBLIC_API_URL to the URL the browser will call -docker compose up --build +curl -L https://fly.io/install.sh | sh +fly auth login +fly volumes create options_data --size 1 -a options-analytics-api +fly secrets set APP_USERNAME=you APP_PASSWORD="$(openssl rand -base64 24)" -a options-analytics-web +fly deploy -c fly.api.toml && fly deploy -c fly.web.toml ``` -Multi-stage images for both services (non-root users, health checks, Next.js -standalone output), an nginx config with TLS and rate limiting, and a systemd -timer for the daily snapshot. Full walkthrough in -[`deploy/README.md`](deploy/README.md). - -**I have not deployed this to a server.** The images are built and smoke-tested -in CI — the API container must answer `/health` with a reachable database, and -the snapshot command must report `refreshed` on its second run inside the -container with an unchanged row count — but no host is running them. I would -rather say that than imply a deployment I cannot point at. - -The timer runs weekdays at 21:15 UTC, after the US close in both EST and EDT, so -the chain captured is the settled one rather than a mid-session reading whose -volatility depends on what time the job happened to run. `Persistent=true` is set -because a missed day is a permanent hole: historical implied volatility is not -purchasable from any free source and cannot be backfilled. +Full walkthrough, including backups and troubleshooting, in +[`deploy/README.md`](deploy/README.md). `docker-compose.yml` mirrors the same +topology for a plain VPS. + +### The daily snapshot runs inside the API process + +Not a cron container, not a scheduled machine — a Fly volume attaches to exactly +one machine at a time, and the snapshot writes to the same SQLite file the API +reads. A separate scheduler could not open it. + +It fires weekdays at 21:15 UTC, after the US close in both EST and EDT, so the +captured chain is the settled one rather than a mid-session reading whose +volatility depends on what time the job happened to run. **On start it catches +up**: if the machine was down through the scheduled time and today has no +snapshot, it runs immediately. A missed day is a permanent hole — historical +implied volatility cannot be bought from a free source or backfilled. + +This is a deliberate trade-off, not a default. A single machine with a single +file database can schedule its own work; anything multi-instance could not, +because every replica would fire the same job. --- diff --git a/api/options_tool/api/main.py b/api/options_tool/api/main.py index 2c9a40e..61a16bf 100644 --- a/api/options_tool/api/main.py +++ b/api/options_tool/api/main.py @@ -1,21 +1,33 @@ """FastAPI application. - uvicorn options_tool.api.main:app --reload + uvicorn options_tool.api.main:app Docs are at `/docs`. Every response is a Pydantic model and every failure is a typed error body -- see `errors.py`. + +**This service is not intended to be exposed to the internet directly.** In the +deployed arrangement it has no public address at all: the Next.js front end +reaches it over a private network and is itself the authenticated surface. That +is why there is no authentication here -- adding a second, weaker one would +invite exposing this port on the assumption it was protected. """ from __future__ import annotations import logging +import time +import uuid +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from options_tool import __version__ from options_tool.api.errors import register_exception_handlers from options_tool.api.routes import router +from options_tool.config import get_settings +from options_tool.logging_config import configure_logging, request_id_var __all__ = ["app", "create_app"] @@ -38,25 +50,136 @@ """ +def _run_migrations() -> None: + """Bring the database schema up to head. + + Run in-process at startup rather than as a separate deploy step because this + app is a single machine with a single attached volume — there is no window + in which a migration container could hold the disk instead. Alembic is + idempotent, so a restart loop re-runs this harmlessly. + """ + from pathlib import Path # noqa: PLC0415 + + from alembic.config import Config # noqa: PLC0415 + from sqlalchemy import inspect # noqa: PLC0415 + + from alembic import command # noqa: PLC0415 + from options_tool.db.session import create_db_engine # noqa: PLC0415 + + settings = get_settings() + root = Path(__file__).resolve().parent.parent.parent + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "alembic")) + config.set_main_option("sqlalchemy.url", settings.options_database_url) + + # Also ensures the SQLite file's parent directory exists before Alembic + # opens it. + engine = create_db_engine(settings.options_database_url) + try: + tables = set(inspect(engine).get_table_names()) + finally: + engine.dispose() + + logger.info("database has %d existing table(s)", len(tables)) + + # A database created by `init_db()` -- which the CLI calls on every command + # -- has the tables but no alembic_version row. `upgrade head` would then + # try to create them a second time and die with "table tickers already + # exists", so anyone who ran `options-tool snapshot` before first starting + # the API would be unable to start it at all. Adopt the existing schema by + # stamping it instead. + if "alembic_version" not in tables and "tickers" in tables: + logger.info("adopting an existing un-versioned schema; stamping at head") + command.stamp(config, "head") + return + + command.upgrade(config, "head") + logger.info("database schema is at head") + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings = get_settings() + configure_logging(settings.options_log_format) + + logger.info( + "starting options-tool %s (provider=%s, snapshot=%s)", + __version__, + settings.options_provider, + "on" if settings.options_snapshot_enabled else "off", + ) + + if settings.options_run_migrations_on_startup: + _run_migrations() + + scheduler = None + if settings.options_snapshot_enabled: + from options_tool.scheduler import SnapshotScheduler # noqa: PLC0415 + + scheduler = SnapshotScheduler(settings) + scheduler.start() + + app.state.scheduler = scheduler + try: + yield + finally: + if scheduler is not None: + await scheduler.stop() + + def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( title="Options Analytics API", version=__version__, description=DESCRIPTION, docs_url="/docs", redoc_url="/redoc", + lifespan=lifespan, ) - # The Next.js front end is served from a different origin in development. - # Permissive here because this is a single-user tool with no authentication - # and no secrets to leak; a multi-user deployment must narrow it. - app.add_middleware( - CORSMiddleware, - allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], - allow_credentials=False, - allow_methods=["GET", "POST", "DELETE"], - allow_headers=["*"], - ) + # Normally empty. The deployed front end proxies to this service server-side + # from the same origin, so no browser ever issues a cross-origin request to + # it — and an allow-list that is not needed is one more thing to get wrong. + # Settable for anyone running the web app against a remote API directly. + if settings.options_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.options_cors_origins, + allow_credentials=False, + allow_methods=["GET", "POST", "DELETE"], + allow_headers=["*"], + ) + + @app.middleware("http") + async def request_context(request: Request, call_next): # type: ignore[no-untyped-def] + """Tag every request with an id and log how long it took. + + A single chain request fans out into several vendor calls and retries; + without a correlating id those log lines interleave with every other + request and a throttling incident cannot be reconstructed afterwards. + """ + request_id = request.headers.get("x-request-id") or uuid.uuid4().hex[:12] + token = request_id_var.set(request_id) + started = time.perf_counter() + try: + response = await call_next(request) + finally: + request_id_var.reset(token) + + elapsed_ms = (time.perf_counter() - started) * 1000 + response.headers["x-request-id"] = request_id + # Health checks fire every 30s forever; logging them buries everything else. + if not request.url.path.startswith(("/health", "/live")): + logger.info( + "%s %s -> %s in %.0fms", + request.method, + request.url.path, + response.status_code, + elapsed_ms, + ) + return response register_exception_handlers(app) app.include_router(router) diff --git a/api/options_tool/api/routes.py b/api/options_tool/api/routes.py index f0788ea..ecb922a 100644 --- a/api/options_tool/api/routes.py +++ b/api/options_tool/api/routes.py @@ -8,6 +8,7 @@ from __future__ import annotations +import logging from datetime import date from typing import Annotated, Any @@ -51,6 +52,8 @@ from options_tool.providers.base import MarketDataProvider from options_tool.providers.rates import resolve_risk_free_rate +logger = logging.getLogger(__name__) + router = APIRouter() # Annotated dependencies rather than `= Depends(...)` defaults: the call then @@ -66,22 +69,45 @@ # -------------------------------------------------------------------------- +@router.get("/live", tags=["meta"], summary="Liveness — is the process up?") +def live() -> dict[str, str]: + """Cheap and dependency-free, for a platform restart check. + + Deliberately separate from /health: a readiness probe that touches the + database will restart a container for a *storage* problem that restarting + cannot fix, turning a degraded service into a crash loop. + """ + return {"status": "alive"} + + @router.get("/health", response_model=HealthResponse, tags=["meta"]) def health(session: SessionDep, provider: ProviderDep) -> HealthResponse: - """Liveness plus the two things that actually break: the DB and the provider.""" + """Readiness: can this instance actually serve? + + Reports the things that genuinely break — the database and which provider is + configured — and whether the snapshot scheduler is running, so a deployment + that silently stopped accumulating history is visible rather than something + you notice weeks later when IV rank never arrives. + """ from sqlalchemy import text # noqa: PLC0415 + from options_tool.config import get_settings # noqa: PLC0415 + try: session.execute(text("SELECT 1")) database_reachable = True - except Exception: # noqa: BLE001 -- health must report failure, not raise it + except Exception: + logger.exception("health check could not reach the database") database_reachable = False + settings = get_settings() return HealthResponse( - status="ok", + status="ok" if database_reachable else "degraded", version=__version__, provider=provider.name, database_reachable=database_reachable, + snapshot_scheduler=settings.options_snapshot_enabled, + snapshot_at=settings.options_snapshot_at if settings.options_snapshot_enabled else None, ) diff --git a/api/options_tool/api/schemas.py b/api/options_tool/api/schemas.py index 6ce8e83..87c44d2 100644 --- a/api/options_tool/api/schemas.py +++ b/api/options_tool/api/schemas.py @@ -40,10 +40,17 @@ class HealthResponse(BaseModel): - status: Literal["ok"] + status: Literal["ok", "degraded"] version: str provider: str database_reachable: bool + snapshot_scheduler: bool = Field( + default=False, + description="Whether this instance is running the daily snapshot itself.", + ) + snapshot_at: str | None = Field( + default=None, description="Scheduled snapshot time in UTC, when enabled." + ) class QuoteResponse(BaseModel): diff --git a/api/options_tool/config.py b/api/options_tool/config.py index cf28d11..70daf29 100644 --- a/api/options_tool/config.py +++ b/api/options_tool/config.py @@ -72,6 +72,39 @@ class Settings(BaseSettings): # --- Storage ------------------------------------------------------------- options_database_url: str = Field(default="sqlite:///./data/options.db") + # --- Daily snapshot (deployed runs) -------------------------------------- + options_snapshot_enabled: bool = Field( + default=False, + description=( + "Run the daily snapshot inside the API process. Off by default so " + "local development and tests never fire live vendor requests." + ), + ) + options_snapshot_at: str = Field( + default="21:15", + description=( + "24h UTC time for the daily snapshot. 21:15 UTC is after the US " + "equity close in both EST and EDT." + ), + ) + + # --- Operations ---------------------------------------------------------- + options_cors_origins: list[str] = Field( + default_factory=list, + description=( + "Browser origins allowed to call this API directly. Empty in the " + "deployed arrangement, where the front end proxies server-side and " + "no cross-origin request is ever made." + ), + ) + options_log_format: str = Field( + default="text", description="'text' for local readability, 'json' for a log pipeline." + ) + options_run_migrations_on_startup: bool = Field( + default=True, + description="Apply Alembic migrations when the API starts. Safe and idempotent.", + ) + @field_validator("options_provider") @classmethod def _known_provider(cls, value: str) -> str: @@ -80,6 +113,25 @@ def _known_provider(cls, value: str) -> str: raise ValueError(f"unknown provider {value!r}; expected 'yfinance' or 'fixture'") return normalised + @field_validator("options_snapshot_at") + @classmethod + def _valid_schedule_time(cls, value: str) -> str: + # Validated here rather than at first fire: a typo should stop the + # process at startup, not silently skip the snapshot for weeks. + # Imported inside the validator because scheduler imports config. + from options_tool.scheduler import parse_schedule_time # noqa: PLC0415 + + parse_schedule_time(value) + return value.strip() + + @field_validator("options_log_format") + @classmethod + def _known_log_format(cls, value: str) -> str: + normalised = value.strip().lower() + if normalised not in {"text", "json"}: + raise ValueError(f"unknown log format {value!r}; expected 'text' or 'json'") + return normalised + @field_validator("options_min_history_days") @classmethod def _history_fits_window(cls, value: int, info) -> int: # type: ignore[no-untyped-def] diff --git a/api/options_tool/logging_config.py b/api/options_tool/logging_config.py new file mode 100644 index 0000000..ea203e4 --- /dev/null +++ b/api/options_tool/logging_config.py @@ -0,0 +1,81 @@ +"""Logging setup. + +Two formats: readable text for a terminal, single-line JSON for a deployment +where logs are shipped somewhere that parses them. Chosen with +`OPTIONS_LOG_FORMAT`. + +Request IDs matter more than they look here. A single chain request fans out +into several vendor calls, retries and solver warnings; without a correlating id +those lines interleave with every other request in the log and a throttling +incident becomes impossible to reconstruct after the fact. +""" + +from __future__ import annotations + +import contextvars +import json +import logging +import sys +from typing import Any + +__all__ = ["configure_logging", "request_id_var", "RequestIdFilter"] + +# Set per request by the middleware; read by the log filter on every record. +request_id_var: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "request_id", default=None +) + + +class RequestIdFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = request_id_var.get() or "-" + return True + + +class JsonFormatter(logging.Formatter): + """One JSON object per line.""" + + def format(self, record: logging.LogRecord) -> str: + payload: dict[str, Any] = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "request_id": getattr(record, "request_id", "-"), + } + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + # json.dumps, not an f-string: a vendor error message containing a quote + # would otherwise produce a line no parser can read. + return json.dumps(payload, default=str) + + +def configure_logging(log_format: str = "text", level: int = logging.INFO) -> None: + """Install a single stdout handler. Safe to call more than once.""" + handler = logging.StreamHandler(sys.stdout) + handler.addFilter(RequestIdFilter()) + + if log_format == "json": + handler.setFormatter(JsonFormatter()) + else: + handler.setFormatter( + logging.Formatter( + "%(asctime)s %(levelname)-7s [%(request_id)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + ) + + root = logging.getLogger() + # Replace rather than append, so repeated calls (reload, tests) do not + # produce duplicated lines. + for existing in root.handlers[:]: + root.removeHandler(existing) + root.addHandler(handler) + root.setLevel(level) + + # uvicorn installs its own handlers; let them propagate to ours instead so + # access logs carry the request id too. + for name in ("uvicorn", "uvicorn.error", "uvicorn.access"): + logger = logging.getLogger(name) + logger.handlers.clear() + logger.propagate = True diff --git a/api/options_tool/scheduler.py b/api/options_tool/scheduler.py new file mode 100644 index 0000000..c0acb9d --- /dev/null +++ b/api/options_tool/scheduler.py @@ -0,0 +1,175 @@ +"""In-process scheduler for the daily snapshot. + +Why in-process rather than a cron container or a scheduled machine: a Fly volume +attaches to exactly one machine at a time, and the snapshot writes to the same +SQLite database the API reads. A separate scheduled machine could not mount the +volume while the API held it. Running the job inside the API process is the +arrangement that matches the storage. + +This is a deliberate trade-off. A single-machine, single-file-database app can +schedule its own work; anything multi-instance could not, because every replica +would fire the same job. If this ever runs more than one machine, move the +schedule out (a Fly scheduled machine plus Postgres, or an external trigger +hitting `POST /snapshot`). + +Two properties make it safe: + +- **Catch-up on start.** If the process was down at the scheduled time and the + day has no snapshot yet, it runs immediately rather than skipping. This is the + equivalent of systemd's `Persistent=true`, and it matters because a missed day + is a permanent hole — historical implied volatility cannot be bought from a + free source or backfilled. +- **Idempotency.** A restart loop, a double fire, or a catch-up that overlaps + the real schedule all refresh the day in place rather than duplicating it. The + database constraints guarantee that regardless of what this module does. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import random +from datetime import UTC, datetime, time, timedelta + +from options_tool.analytics.chain import market_date +from options_tool.config import Settings +from options_tool.db.queries import history_depth, list_watchlist +from options_tool.db.session import session_scope +from options_tool.db.snapshot import snapshot_watchlist +from options_tool.providers import get_provider +from options_tool.providers.rates import resolve_risk_free_rate + +__all__ = ["SnapshotScheduler", "next_run_at", "parse_schedule_time"] + +logger = logging.getLogger(__name__) + +# US equity markets are shut; a weekend snapshot would capture Friday's stale +# chain and store it as a fresh observation. +_TRADING_WEEKDAYS = {0, 1, 2, 3, 4} # Monday..Friday + +# Every deployment firing at exactly HH:MM:00 is when the unofficial vendor +# throttles hardest. Mirrors systemd's RandomizedDelaySec. +_MAX_JITTER_SECONDS = 600 + + +def parse_schedule_time(value: str) -> time: + """Parse 'HH:MM' into a UTC time, with a useful error if it is malformed.""" + try: + hour, minute = (int(part) for part in value.strip().split(":", 1)) + return time(hour=hour, minute=minute, tzinfo=UTC) + except (ValueError, TypeError) as exc: + raise ValueError( + f"OPTIONS_SNAPSHOT_AT must look like '21:15' (24h UTC), got {value!r}" + ) from exc + + +def next_run_at(after: datetime, at: time) -> datetime: + """The next weekday occurrence of `at`, strictly after `after`.""" + candidate = after.astimezone(UTC).replace( + hour=at.hour, minute=at.minute, second=0, microsecond=0 + ) + if candidate <= after: + candidate += timedelta(days=1) + while candidate.weekday() not in _TRADING_WEEKDAYS: + candidate += timedelta(days=1) + return candidate + + +class SnapshotScheduler: + """Runs the snapshot once per trading day, inside the API process.""" + + def __init__(self, settings: Settings) -> None: + self.settings = settings + self.at = parse_schedule_time(settings.options_snapshot_at) + self._task: asyncio.Task[None] | None = None + + def start(self) -> None: + if self._task is not None: + return + self._task = asyncio.create_task(self._run(), name="snapshot-scheduler") + logger.info( + "snapshot scheduler started; next run %s", + next_run_at(datetime.now(UTC), self.at).isoformat(), + ) + + async def stop(self) -> None: + if self._task is None: + return + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + logger.info("snapshot scheduler stopped") + + async def _run(self) -> None: + # Catch up first: if the process was down through today's slot, the day + # is still recoverable right now but will not be tomorrow. + try: + if await asyncio.to_thread(self._needs_catch_up): + logger.info("no snapshot stored for today yet — running catch-up now") + await self._snapshot() + except Exception: + logger.exception("catch-up snapshot failed; continuing on schedule") + + while True: + now = datetime.now(UTC) + target = next_run_at(now, self.at) + jitter = random.uniform(0, _MAX_JITTER_SECONDS) + delay = (target - now).total_seconds() + jitter + + logger.info("next snapshot at %s (+%.0fs jitter)", target.isoformat(), jitter) + await asyncio.sleep(delay) + + try: + await self._snapshot() + except Exception: + # Never let one bad day kill the scheduler; tomorrow still needs + # to run, and the catch-up path cannot recover a dead task. + logger.exception("scheduled snapshot failed") + + def _needs_catch_up(self) -> bool: + """True when the schedule has passed today and nothing is stored for it.""" + now = datetime.now(UTC) + if now.weekday() not in _TRADING_WEEKDAYS: + return False + scheduled_today = now.replace( + hour=self.at.hour, minute=self.at.minute, second=0, microsecond=0 + ) + if now < scheduled_today: + return False + + today = market_date(now) + with session_scope() as session: + tickers = list_watchlist(session) + if not tickers: + return False + # If any tracked symbol is missing today, the run is worth making; + # it is idempotent for the ones already captured. + return any(history_depth(session, ticker.symbol)[2] != today for ticker in tickers) + + async def _snapshot(self) -> None: + await asyncio.to_thread(self._snapshot_blocking) + + def _snapshot_blocking(self) -> None: + """The actual work, off the event loop — it is network- and CPU-bound.""" + provider = get_provider(self.settings.options_provider) + rate = resolve_risk_free_rate(self.settings) + + with session_scope() as session: + results = snapshot_watchlist( + session, provider, rate, self.settings.options_dividend_yield + ) + + for result in results: + if result.errors and not result.contracts_written: + logger.error("%s: snapshot failed — %s", result.ticker, result.errors[0]) + continue + logger.info( + "%s %s: %d contracts (%.0f%% solved), 30d ATM IV %s", + result.ticker, + result.snapshot_date, + result.contracts_written, + result.solve_rate * 100, + f"{result.atm_iv_30d:.4f}" if result.atm_iv_30d else "unavailable", + ) diff --git a/api/pyproject.toml b/api/pyproject.toml index 965195c..3b5c3df 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -98,9 +98,11 @@ ignore = [ # genuinely unused by name. # PLC0415 -- importing inside a test keeps the import under the assertion that # depends on it, and lets a test prove something about import behaviour. +# ARG005 -- a monkeypatch stand-in must match the signature it replaces, so +# its unused parameters are required, not accidental. # RUF012 -- parametrisation tables held as plain class attributes read far # better than ClassVar-annotated ones. -"tests/*" = ["ARG001", "ARG002", "PLC0415", "RUF012"] +"tests/*" = ["ARG001", "ARG002", "ARG005", "PLC0415", "RUF012"] # Alembic writes these; hand-wrapping them only loses the next autogenerate. "alembic/versions/*" = ["E501"] "alembic/env.py" = ["ERA001"] diff --git a/api/tests/conftest.py b/api/tests/conftest.py index c1981e0..ff2f00b 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -69,6 +69,11 @@ def settings(tmp_path: Path) -> Iterator[Settings]: "OPTIONS_MIN_HISTORY_DAYS": "20", "OPTIONS_IV_WINDOW_DAYS": "252", "OPTIONS_FETCH_RISK_FREE_RATE": "false", + # Tests build the schema directly with `Base.metadata.create_all`, + # so the startup migration has nothing to do and would only race it. + "OPTIONS_RUN_MIGRATIONS_ON_STARTUP": "false", + # Never let a test process fire live vendor requests on a timer. + "OPTIONS_SNAPSHOT_ENABLED": "false", } ) get_settings.cache_clear() diff --git a/api/tests/test_scheduler.py b/api/tests/test_scheduler.py new file mode 100644 index 0000000..04b76ab --- /dev/null +++ b/api/tests/test_scheduler.py @@ -0,0 +1,279 @@ +"""The in-process daily snapshot scheduler. + +The schedule arithmetic is tested directly; the run loop is exercised through +the catch-up path, which is the part that protects against a permanent hole in +the IV history. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, time, timedelta + +import pytest +from sqlalchemy import Engine + +from options_tool.config import Settings +from options_tool.db.queries import add_to_watchlist +from options_tool.db.snapshot import snapshot_ticker +from options_tool.providers.fixture_provider import FixtureProvider +from options_tool.scheduler import SnapshotScheduler, next_run_at, parse_schedule_time + + +class TestParseScheduleTime: + def test_parses_a_normal_time(self) -> None: + assert parse_schedule_time("21:15") == time(21, 15, tzinfo=UTC) + + def test_tolerates_surrounding_whitespace(self) -> None: + assert parse_schedule_time(" 09:05 ") == time(9, 5, tzinfo=UTC) + + @pytest.mark.parametrize("bad", ["", "2115", "25:00", "12:75", "noon", "12:xx"]) + def test_rejects_malformed_times_at_startup(self, bad: str) -> None: + """A typo must stop the process, not silently skip the snapshot for weeks.""" + with pytest.raises(ValueError, match="OPTIONS_SNAPSHOT_AT"): + parse_schedule_time(bad) + + +class TestNextRunAt: + AT = time(21, 15, tzinfo=UTC) + + def test_later_today_when_the_time_has_not_passed(self) -> None: + now = datetime(2026, 9, 16, 10, 0, tzinfo=UTC) # Wednesday + assert next_run_at(now, self.AT) == datetime(2026, 9, 16, 21, 15, tzinfo=UTC) + + def test_tomorrow_when_it_has(self) -> None: + now = datetime(2026, 9, 16, 22, 0, tzinfo=UTC) + assert next_run_at(now, self.AT) == datetime(2026, 9, 17, 21, 15, tzinfo=UTC) + + def test_exactly_at_the_scheduled_time_moves_to_the_next_day(self) -> None: + """Strictly after, so a fire at 21:15:00 cannot immediately re-fire.""" + now = datetime(2026, 9, 16, 21, 15, tzinfo=UTC) + assert next_run_at(now, self.AT).day == 17 + + def test_friday_evening_skips_to_monday(self) -> None: + """US markets are shut; a weekend run would store Friday's stale chain.""" + friday = datetime(2026, 9, 18, 22, 0, tzinfo=UTC) + assert next_run_at(friday, self.AT) == datetime(2026, 9, 21, 21, 15, tzinfo=UTC) + + def test_saturday_skips_to_monday(self) -> None: + saturday = datetime(2026, 9, 19, 10, 0, tzinfo=UTC) + assert next_run_at(saturday, self.AT).weekday() == 0 + + def test_always_returns_a_weekday_in_the_future(self) -> None: + start = datetime(2026, 1, 1, tzinfo=UTC) + for offset in range(0, 400, 7): + now = start + timedelta(days=offset, hours=offset % 24) + result = next_run_at(now, self.AT) + assert result > now + assert result.weekday() < 5 + + +class TestCatchUp: + """The equivalent of systemd's Persistent=true. + + A day missed to a restart cannot be recovered later: historical implied + volatility is not purchasable from a free source. + """ + + @staticmethod + def scheduler(settings: Settings, at: str = "00:01") -> SnapshotScheduler: + object.__setattr__(settings, "options_snapshot_at", at) + return SnapshotScheduler(settings) + + def test_catches_up_when_today_has_no_snapshot( + self, settings: Settings, engine: Engine, session, monkeypatch: pytest.MonkeyPatch + ) -> None: + add_to_watchlist(session, "AAPL") + session.commit() + + scheduler = self.scheduler(settings) + # 00:01 UTC has certainly passed by any later hour of the day. + monkeypatch.setattr( + "options_tool.scheduler.datetime", + _FrozenDatetime(datetime(2026, 9, 16, 12, 0, tzinfo=UTC)), + ) + assert scheduler._needs_catch_up() is True + + def test_no_catch_up_before_the_scheduled_time( + self, settings: Settings, engine: Engine, session, monkeypatch: pytest.MonkeyPatch + ) -> None: + add_to_watchlist(session, "AAPL") + session.commit() + + scheduler = self.scheduler(settings, at="23:59") + monkeypatch.setattr( + "options_tool.scheduler.datetime", + _FrozenDatetime(datetime(2026, 9, 16, 12, 0, tzinfo=UTC)), + ) + assert scheduler._needs_catch_up() is False + + def test_no_catch_up_at_the_weekend( + self, settings: Settings, engine: Engine, session, monkeypatch: pytest.MonkeyPatch + ) -> None: + add_to_watchlist(session, "AAPL") + session.commit() + + scheduler = self.scheduler(settings) + monkeypatch.setattr( + "options_tool.scheduler.datetime", + _FrozenDatetime(datetime(2026, 9, 19, 12, 0, tzinfo=UTC)), # Saturday + ) + assert scheduler._needs_catch_up() is False + + def test_no_catch_up_with_an_empty_watchlist( + self, settings: Settings, engine: Engine, monkeypatch: pytest.MonkeyPatch + ) -> None: + scheduler = self.scheduler(settings) + monkeypatch.setattr( + "options_tool.scheduler.datetime", + _FrozenDatetime(datetime(2026, 9, 16, 12, 0, tzinfo=UTC)), + ) + assert scheduler._needs_catch_up() is False + + def test_no_catch_up_once_today_is_already_stored( + self, + settings: Settings, + engine: Engine, + session, + provider: FixtureProvider, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + add_to_watchlist(session, "AAPL") + snapshot_ticker(session, provider, "AAPL", 0.04, 0.0, as_of=datetime.now(UTC)) + session.commit() + + scheduler = self.scheduler(settings) + assert scheduler._needs_catch_up() is False + + +class TestSchedulerLifecycle: + def test_start_is_idempotent(self, settings: Settings) -> None: + """Guards against two loops both firing the snapshot.""" + scheduler = SnapshotScheduler(settings) + assert scheduler._task is None + + def test_construction_validates_the_time(self, settings: Settings) -> None: + object.__setattr__(settings, "options_snapshot_at", "nonsense") + with pytest.raises(ValueError, match="OPTIONS_SNAPSHOT_AT"): + SnapshotScheduler(settings) + + +class _FrozenDatetime: + """Stands in for `datetime` so `datetime.now(UTC)` is deterministic.""" + + def __init__(self, when: datetime) -> None: + self._when = when + + def now(self, tz: object = None) -> datetime: + return self._when + + def __getattr__(self, name: str) -> object: + import datetime as module + + return getattr(module.datetime, name) + + +class TestTheRunLoop: + """Exercised with `asyncio.run` rather than pytest-asyncio, to avoid a + dependency for three tests.""" + + def test_start_then_stop_cleanly(self, settings: Settings, engine: Engine) -> None: + import asyncio + + async def exercise() -> None: + scheduler = SnapshotScheduler(settings) + scheduler.start() + assert scheduler._task is not None + # Let the loop reach its first await. + await asyncio.sleep(0.05) + await scheduler.stop() + assert scheduler._task is None + + asyncio.run(exercise()) + + def test_start_twice_creates_only_one_task(self, settings: Settings, engine: Engine) -> None: + """Two loops would fire the snapshot twice a day. Idempotency makes that + harmless, but it would double the vendor load for no reason.""" + import asyncio + + async def exercise() -> None: + scheduler = SnapshotScheduler(settings) + scheduler.start() + first = scheduler._task + scheduler.start() + assert scheduler._task is first + await scheduler.stop() + + asyncio.run(exercise()) + + def test_stop_before_start_is_a_no_op(self, settings: Settings) -> None: + import asyncio + + asyncio.run(SnapshotScheduler(settings).stop()) + + def test_a_failing_snapshot_does_not_kill_the_loop( + self, settings: Settings, engine: Engine, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Tomorrow still needs to run, and the catch-up path cannot revive a + dead task.""" + import asyncio + + calls: list[int] = [] + + def boom(self: SnapshotScheduler) -> None: + calls.append(1) + raise RuntimeError("vendor exploded") + + monkeypatch.setattr(SnapshotScheduler, "_snapshot_blocking", boom) + monkeypatch.setattr(SnapshotScheduler, "_needs_catch_up", lambda self: True) + + async def exercise() -> None: + scheduler = SnapshotScheduler(settings) + scheduler.start() + await asyncio.sleep(0.1) + # The catch-up raised, and the task is still alive and waiting. + assert calls + assert scheduler._task is not None + assert not scheduler._task.done() + await scheduler.stop() + + asyncio.run(exercise()) + + def test_snapshot_writes_through_to_the_database( + self, + settings: Settings, + engine: Engine, + session, + provider: FixtureProvider, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The scheduler's own path, not just `snapshot_watchlist` directly.""" + from options_tool.db.queries import contract_count + + add_to_watchlist(session, "AAPL") + session.commit() + + monkeypatch.setattr("options_tool.scheduler.get_provider", lambda name: provider) + SnapshotScheduler(settings)._snapshot_blocking() + + assert contract_count(session) > 0 + + def test_snapshot_is_idempotent_through_the_scheduler( + self, + settings: Settings, + engine: Engine, + session, + provider: FixtureProvider, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + from options_tool.db.queries import contract_count + + add_to_watchlist(session, "AAPL") + session.commit() + monkeypatch.setattr("options_tool.scheduler.get_provider", lambda name: provider) + + scheduler = SnapshotScheduler(settings) + scheduler._snapshot_blocking() + after_first = contract_count(session) + scheduler._snapshot_blocking() + + assert contract_count(session) == after_first diff --git a/api/tests/test_startup.py b/api/tests/test_startup.py new file mode 100644 index 0000000..e965fd8 --- /dev/null +++ b/api/tests/test_startup.py @@ -0,0 +1,126 @@ +"""Startup behaviour: migrations, health split, request ids. + +The migration tests cover a crash this project actually had. `init_db()` runs on +every CLI command and builds the schema with `create_all`, which leaves no +`alembic_version` row. Starting the API against that database then ran +`upgrade head`, which tried to create the tables a second time and died with +"table tickers already exists" — so anyone who ran `options-tool snapshot` +before first starting the API could not start it at all. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, inspect + +from options_tool.api.main import _run_migrations +from options_tool.config import get_settings +from options_tool.db.models import Base + + +@pytest.fixture +def migration_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str: + url = f"sqlite:///{tmp_path / 'migrate.db'}" + monkeypatch.setitem(os.environ, "OPTIONS_DATABASE_URL", url) + get_settings.cache_clear() + yield url + get_settings.cache_clear() + + +def tables_in(url: str) -> set[str]: + engine = create_engine(url) + try: + return set(inspect(engine).get_table_names()) + finally: + engine.dispose() + + +class TestMigrationsOnStartup: + def test_empty_database_is_migrated_to_head(self, migration_db: str) -> None: + _run_migrations() + tables = tables_in(migration_db) + assert {"tickers", "snapshots", "contracts", "alembic_version"} <= tables + + def test_an_unversioned_schema_is_adopted_not_recreated(self, migration_db: str) -> None: + """The regression. Previously this raised "table tickers already exists".""" + engine = create_engine(migration_db) + Base.metadata.create_all(engine) + engine.dispose() + + assert "alembic_version" not in tables_in(migration_db) + + _run_migrations() # must not raise + + tables = tables_in(migration_db) + assert "alembic_version" in tables + assert "tickers" in tables + + def test_running_twice_is_harmless(self, migration_db: str) -> None: + """A container restart loop re-runs this on every boot.""" + _run_migrations() + _run_migrations() + assert "alembic_version" in tables_in(migration_db) + + def test_creates_the_parent_directory( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A fresh volume is mounted empty; nothing else creates /data/...""" + nested = tmp_path / "does" / "not" / "exist" / "o.db" + monkeypatch.setitem(os.environ, "OPTIONS_DATABASE_URL", f"sqlite:///{nested}") + get_settings.cache_clear() + try: + _run_migrations() + assert nested.exists() + finally: + get_settings.cache_clear() + + +class TestHealthEndpoints: + def test_live_is_cheap_and_dependency_free(self, api_client: TestClient) -> None: + """Restarting a container cannot fix a database problem, so liveness + must not consult the database.""" + response = api_client.get("/live") + assert response.status_code == 200 + assert response.json() == {"status": "alive"} + + def test_health_reports_readiness_details(self, api_client: TestClient) -> None: + body = api_client.get("/health").json() + assert body["status"] == "ok" + assert body["database_reachable"] is True + assert body["provider"] == "fixture" + + def test_health_reports_whether_the_scheduler_is_running(self, api_client: TestClient) -> None: + """A deployment that silently stopped accumulating history should be + visible here, not discovered weeks later when IV rank never arrives.""" + body = api_client.get("/health").json() + assert body["snapshot_scheduler"] is False + assert body["snapshot_at"] is None + + +class TestRequestIds: + def test_every_response_carries_one(self, api_client: TestClient) -> None: + response = api_client.get("/watchlist") + assert response.headers.get("x-request-id") + + def test_a_supplied_id_is_echoed_back(self, api_client: TestClient) -> None: + """Lets a caller correlate its own logs with this service's.""" + response = api_client.get("/watchlist", headers={"x-request-id": "abc123"}) + assert response.headers["x-request-id"] == "abc123" + + def test_ids_differ_between_requests(self, api_client: TestClient) -> None: + first = api_client.get("/watchlist").headers["x-request-id"] + second = api_client.get("/watchlist").headers["x-request-id"] + assert first != second + + +class TestCorsIsOffByDefault: + def test_no_cors_headers_without_configured_origins(self, api_client: TestClient) -> None: + """The deployed front end proxies server-side, so no browser ever makes + a cross-origin request to this service. An allow-list that is not needed + is one more thing to get wrong.""" + response = api_client.get("/health", headers={"Origin": "https://evil.example.com"}) + assert "access-control-allow-origin" not in response.headers diff --git a/deploy/README.md b/deploy/README.md index 704f707..a448f57 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,144 +1,212 @@ # Deploying -Single host, Docker Compose behind nginx, with a systemd timer running the daily -snapshot. +Two Fly.io apps. **The API has no public address** — it is reachable only over +Fly's private network, from the web app, which is the single authenticated +surface. -> **Status: written, not yet run.** These files were authored and checked for -> syntax, but they have not been built or deployed — the development machine has -> no Docker installed and there is no target host. Until someone runs the steps -> below successfully, nothing in this repository claims a live deployment. If you -> are that someone and a step is wrong, the fix belongs in this file. +``` + internet ──TLS──▶ options-analytics-web (Basic auth, Next.js) + │ + │ server-side proxy, Fly 6PN private network + ▼ + options-analytics-api (FastAPI, no public address) + │ + ▼ + volume: /data/options.db + (the IV history — the only irreplaceable state) +``` -## What runs where +Three consequences worth understanding before changing anything: -| Piece | Where | Why | -|---|---|---| -| `api` | container, bound to `127.0.0.1:8000` | not exposed directly; nginx terminates TLS | -| `web` | container, bound to `127.0.0.1:3000` | same | -| `options-data` | named Docker volume | the accumulated IV history — the only irreplaceable state here | -| snapshot | systemd timer, `docker compose run --rm` | survives reboots and logs to the journal | +- **One auth check covers everything.** The browser can only reach the API + through `/api/*` on the web app, which is behind the same middleware as every + other route. There is no second, weaker door to leave open. +- **CORS does not apply.** Same origin, no preflight, no allow-list to keep in + sync with whatever domain this ends up on. +- **The API address is runtime configuration.** `API_INTERNAL_URL` is read per + request, so the image is identical in every environment and moving the API + does not mean rebuilding the front end. -## 1. Host setup +--- -Tested target: Ubuntu 24.04 LTS, 1 vCPU / 1 GB. SQLite and a handful of chains -need very little. +## Prerequisites ```bash -sudo apt update && sudo apt install -y docker.io docker-compose-v2 nginx certbot python3-certbot-nginx -sudo systemctl enable --now docker -sudo usermod -aG docker "$USER" # log out and back in +curl -L https://fly.io/install.sh | sh +export PATH="$HOME/.fly/bin:$PATH" +fly auth login # opens a browser ``` -## 2. Get the code +No local Docker required — Fly builds the images remotely. + +--- + +## 1. Create the apps ```bash -sudo mkdir -p /opt/options-analytics -sudo chown "$USER":"$USER" /opt/options-analytics -git clone https://github.com/Tejas12972/stockwatchlist.git /opt/options-analytics -cd /opt/options-analytics +fly apps create options-analytics-api +fly apps create options-analytics-web ``` -## 3. Configure +## 2. Create the volume ```bash -cp .env.example .env +fly volumes create options_data --size 1 --region bos -a options-analytics-api ``` -Set `PUBLIC_API_URL` to the URL **the browser** will call — that is -`https://your-domain/api`, not `http://api:8000`. The Next.js client bundle bakes -this in at build time, so changing it later means rebuilding the web image, not -just restarting it. This is the single most common way to get a working deploy -that shows an empty page. +1 GB is generous: a year of daily snapshots for a handful of tickers is tens of +megabytes. Size it up later with `fly volumes extend`. + +> A Fly volume attaches to **exactly one machine**. That is why the daily +> snapshot runs inside the API process rather than as a separate scheduled +> machine — a second machine could not open the database while the API held it. +> See `api/options_tool/scheduler.py`. -## 4. Build and start +## 3. Set the login ```bash -docker compose build -docker compose up -d -docker compose ps # both services should be healthy -curl -s localhost:8000/health +fly secrets set \ + APP_USERNAME='you' \ + APP_PASSWORD="$(openssl rand -base64 24)" \ + -a options-analytics-web ``` -## 5. nginx and TLS +Print the generated password *before* you set it if you want to keep it — +`fly secrets` is write-only and will not show it back to you. + +**If these are unset the app runs open.** That is the right default for +`npm run dev` on localhost and the wrong one on the internet, so `/health` on +the API reports which mode is in force. + +## 4. Deploy + +The API first — the web app's health check depends on it being resolvable. ```bash -sudo cp deploy/nginx.conf /etc/nginx/sites-available/options-analytics -sudo sed -i "s/options.example.com/YOUR-DOMAIN/g" /etc/nginx/sites-available/options-analytics -sudo ln -sf /etc/nginx/sites-available/options-analytics /etc/nginx/sites-enabled/ -sudo rm -f /etc/nginx/sites-enabled/default - -sudo nginx -t # must pass before reloading -sudo systemctl reload nginx -sudo certbot --nginx -d YOUR-DOMAIN +fly deploy -c fly.api.toml +fly deploy -c fly.web.toml ``` -`certbot` rewrites the TLS certificate paths in place, so run it after copying -the config, not before. +## 5. Seed the watchlist -## 6. The daily snapshot +The daily job snapshots whatever is on the watchlist, so it needs at least one +symbol before it does anything. ```bash -sudo cp deploy/options-snapshot.service deploy/options-snapshot.timer /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now options-snapshot.timer +fly ssh console -a options-analytics-api -C "python -m options_tool watchlist --add SPY AAPL" +fly ssh console -a options-analytics-api -C "python -m options_tool snapshot" +``` -systemctl list-timers options-snapshot.timer -sudo systemctl start options-snapshot.service # run once now -journalctl -u options-snapshot.service -n 50 --no-pager +You can also add symbols through the UI once it is up. + +## 6. Verify + +```bash +fly status -a options-analytics-api +fly status -a options-analytics-web + +# The API must NOT be reachable from the internet. This should fail to resolve. +curl -sS https://options-analytics-api.fly.dev/health || echo "correctly unreachable" + +# The web app must challenge for credentials. +curl -s -o /dev/null -w '%{http_code}\n' https://options-analytics-web.fly.dev/ # 401 +curl -s -o /dev/null -w '%{http_code}\n' https://options-analytics-web.fly.dev/healthz # 200 + +# And serve with them. +curl -s -u you:PASSWORD https://options-analytics-web.fly.dev/api/health | jq ``` -The timer fires weekdays at 21:15 UTC — after the US close in both EST and EDT, -so the captured chain is the settled one rather than a mid-session reading whose -volatility depends on what time the job happened to run. +The last call should show `"snapshot_scheduler": true` and the scheduled time. +If it shows `false`, the daily job is not running and no history is +accumulating — which you would otherwise not notice for weeks, when IV rank +never arrives. -`Persistent=true` matters more than it looks: if the host is down at the -scheduled time the run happens at next boot instead of being skipped. **A missed -day is a permanent hole in the IV history** — historical implied volatility is -not purchasable from any free source, so it cannot be backfilled. +--- -## 7. Verify +## The daily snapshot -```bash -# Idempotency: the second run must not add rows. -sudo systemctl start options-snapshot.service -docker compose run --rm snapshot | tail -2 # expect "refreshed", not "captured" +Runs inside the API process at `OPTIONS_SNAPSHOT_AT` (21:15 UTC by default), +weekdays only — after the US equity close in both EST and EDT, so the chain +captured is the settled one rather than a mid-session reading whose volatility +depends on what time the job happened to fire. + +**On start it catches up.** If the machine was down through the scheduled time +and today has no snapshot, it runs immediately. This is the equivalent of +systemd's `Persistent=true`, and it matters because **a missed day is a +permanent hole** — historical implied volatility is not purchasable from any +free source and cannot be backfilled. -# Migrations are applied with the API stopped, against the same volume. -docker compose run --rm api alembic upgrade head +`min_machines_running = 1` and no auto-stop on the API app for the same reason: +a machine that suspends overnight misses the snapshot. + +```bash +fly logs -a options-analytics-api # watch it run +fly ssh console -a options-analytics-api -C "python -m options_tool snapshot" # force one ``` +Running it by hand while the scheduler also fires is safe — the database +constraints make a snapshot idempotent per (ticker, date, expiry, strike, right). + +--- + ## Backups -Only the volume matters. Everything else is rebuildable from git. +Only the volume matters; everything else is rebuildable from git. + +```bash +fly ssh console -a options-analytics-api -C "python -m options_tool export --what daily --out /tmp/history.csv" +fly ssh sftp get /tmp/history.csv -a options-analytics-api +``` + +Fly also snapshots volumes daily (`snapshot_retention = 30` in `fly.api.toml`): ```bash -docker run --rm -v options-analytics_options-data:/data -v "$PWD":/backup alpine \ - tar czf /backup/options-history-$(date +%F).tar.gz -C /data . +fly volumes snapshots list ``` -Worth putting on its own weekly timer. The database is small — a year of daily -snapshots for a handful of tickers is tens of megabytes — and it is the only -thing here that cannot be recreated. +--- ## Upgrading ```bash -cd /opt/options-analytics git pull -docker compose run --rm api alembic upgrade head # before starting new code -docker compose build -docker compose up -d +fly deploy -c fly.api.toml # migrations run at startup, before serving +fly deploy -c fly.web.toml ``` -Run the migration before the new containers start, so the schema is never behind -the code reading it. +No separate migrate step: the API applies Alembic migrations in its own startup +before accepting traffic. It also *adopts* a schema that was created by the CLI +rather than failing on it — a database with the tables but no `alembic_version` +row gets stamped instead of re-created. + +--- + +## Running it on a plain VPS instead + +`docker-compose.yml` mirrors the same topology — API unpublished, web published, +proxy in between — so it behaves the same way. + +```bash +cp .env.example .env # set APP_USERNAME / APP_PASSWORD +docker compose up -d --build +``` + +Put nginx in front for TLS using `deploy/nginx.conf` (it proxies to the web app +on :3000; the `/api/` block there is now unnecessary, since the web app proxies +internally). `deploy/options-snapshot.{service,timer}` are kept for a setup that +would rather schedule externally than use the in-process scheduler — set +`OPTIONS_SNAPSHOT_ENABLED=false` if you use them, so the job does not run twice. + +--- ## Troubleshooting | Symptom | Cause | |---|---| -| Pages load, all data empty | `PUBLIC_API_URL` wrong, or set after the web image was built. Rebuild the web image. | -| `provider_rate_limited` in the UI | Yahoo is throttling. It clears within minutes; the source is unofficial and has no SLA. | -| IV rank says "insufficient history" | Working as designed. It needs `OPTIONS_MIN_HISTORY_DAYS` (default 20) stored days. | -| Timer never fires | `systemctl list-timers`; check the unit is enabled and the host clock is in UTC. | -| Snapshot writes nothing | `journalctl -u options-snapshot.service`. A ticker failing does not abort the rest of the run, so check per-symbol warnings. | +| Pages load, every panel empty | The web app cannot reach the API. Check `API_INTERNAL_URL` and that the API app is running: `fly status -a options-analytics-api`. | +| `502 api_unreachable` from `/api/...` | Same. The proxy returns the API's own error shape, so it looks like an API error but is a connectivity one. | +| Browser keeps asking for credentials | `APP_USERNAME`/`APP_PASSWORD` differ from what you are typing. `fly secrets list` shows names, not values — reset it. | +| Fly health check failing on web | `/healthz` must be reachable without auth. Check the `matcher` in `middleware.ts` still excludes it. | +| `provider_rate_limited` | Yahoo is throttling. It clears within minutes; the source is unofficial and has no SLA. | +| IV rank stuck on "insufficient history" | Working as designed until `OPTIONS_MIN_HISTORY_DAYS` (20) days are stored. Check `/api/health` shows `snapshot_scheduler: true`. | +| History stopped growing | The API machine is suspending. It needs `min_machines_running = 1` and no auto-stop, because it owns the timer. | diff --git a/docker-compose.yml b/docker-compose.yml index f834c70..d338ae7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,13 @@ # Local and single-host deployment. # +# cp .env.example .env # docker compose up --build # -# The web image bakes NEXT_PUBLIC_API_BASE_URL into the client bundle at build -# time, so behind nginx it must be the public URL the *browser* will call, not -# the internal service name. That is the one thing about this file that catches -# people out. +# Mirrors the Fly topology: the API is **not** published to the host, the web +# app is, and the browser reaches the API only through the web app's server-side +# proxy. That means CORS never applies and the API is not directly reachable — +# the same properties the deployed version relies on, so testing here tests the +# real arrangement rather than a friendlier one. services: api: @@ -21,43 +23,46 @@ services: OPTIONS_DIVIDEND_YIELD: ${OPTIONS_DIVIDEND_YIELD:-0.0} OPTIONS_MIN_HISTORY_DAYS: ${OPTIONS_MIN_HISTORY_DAYS:-20} OPTIONS_IV_WINDOW_DAYS: ${OPTIONS_IV_WINDOW_DAYS:-252} + OPTIONS_LOG_FORMAT: ${OPTIONS_LOG_FORMAT:-text} + # The daily snapshot runs inside this process — see options_tool/scheduler.py + # for why it is not a separate container. + OPTIONS_SNAPSHOT_ENABLED: ${OPTIONS_SNAPSHOT_ENABLED:-true} + OPTIONS_SNAPSHOT_AT: ${OPTIONS_SNAPSHOT_AT:-21:15} volumes: - # The accumulated IV history is the one thing here that cannot be - # re-downloaded from anywhere. It lives in a named volume so `docker - # compose down` does not destroy it. + # The accumulated IV history is the only irreplaceable state here. A named + # volume so `docker compose down` does not destroy it. - options-data:/data - ports: - - "127.0.0.1:8000:8000" + # Deliberately no `ports`. Reachable only on the compose network, as `api`. + expose: + - "8000" web: build: context: . dockerfile: Dockerfile.web - args: - NEXT_PUBLIC_API_BASE_URL: ${PUBLIC_API_URL:-http://localhost:8000} image: options-analytics-web restart: unless-stopped depends_on: api: condition: service_healthy + environment: + API_INTERNAL_URL: http://api:8000 + # Unset locally = no auth, which is what you want on localhost. Set both + # to require a login. On Fly these are secrets, never environment values. + APP_USERNAME: ${APP_USERNAME:-} + APP_PASSWORD: ${APP_PASSWORD:-} ports: - - "127.0.0.1:3000:3000" + - "${WEB_PORT:-3000}:3000" - # The daily snapshot. Runs on the same image and the same volume as the API, - # so it shares one schema and one database with no coordination. - # - # Started only with `--profile snapshot`, because on a real host the systemd - # timer in deploy/ is the better scheduler: it survives reboots, logs to the - # journal, and will not silently stop when the container is recreated. + # One-off snapshot, for seeding history or testing idempotency by hand: + # docker compose run --rm snapshot + # Not started by `up` — the API schedules itself. snapshot: build: context: . dockerfile: Dockerfile.api image: options-analytics-api - profiles: ["snapshot"] - depends_on: - api: - condition: service_healthy + profiles: ["tools"] environment: OPTIONS_PROVIDER: ${OPTIONS_PROVIDER:-yfinance} OPTIONS_DATABASE_URL: sqlite:////data/options.db diff --git a/fly.api.toml b/fly.api.toml new file mode 100644 index 0000000..514f46c --- /dev/null +++ b/fly.api.toml @@ -0,0 +1,60 @@ +# Fly config for the analytics API. +# +# fly deploy -c fly.api.toml +# +# Deliberately has no [http_service] and no public ports. The machine still gets +# a private 6PN address, so the web app reaches it at +# options-analytics-api.internal — but nothing on the internet can. That is what +# makes the single Basic-auth check in the web app the complete authentication +# story rather than one layer of two. + +app = "options-analytics-api" +primary_region = "bos" # Boston — nearest to me, and to US market data + +[build] + dockerfile = "Dockerfile.api" + +[env] + OPTIONS_PROVIDER = "yfinance" + OPTIONS_DATABASE_URL = "sqlite:////data/options.db" + OPTIONS_LOG_FORMAT = "json" + OPTIONS_RUN_MIGRATIONS_ON_STARTUP = "true" + + # The daily snapshot runs inside this process. A Fly volume attaches to one + # machine at a time, so a separate scheduled machine could not open the same + # database while this one held it. + OPTIONS_SNAPSHOT_ENABLED = "true" + OPTIONS_SNAPSHOT_AT = "21:15" # UTC — after the US close in both EST and EDT + + OPTIONS_MIN_HISTORY_DAYS = "20" + OPTIONS_IV_WINDOW_DAYS = "252" + +[processes] + # Bound to :: rather than 0.0.0.0 because Fly's private network is IPv6. + app = "uvicorn options_tool.api.main:app --host :: --port 8000" + +[[mounts]] + source = "options_data" + destination = "/data" + # The accumulated IV history is the only irreplaceable state in this project. + # It cannot be re-downloaded from any free source, at any price. + snapshot_retention = 30 + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" # pandas + numpy need more than the 256mb default + +# No auto_stop. The scheduler lives in this process, so a machine that suspends +# overnight would miss the snapshot — and a missed day is a permanent hole. +[[services]] + internal_port = 8000 + protocol = "tcp" + auto_stop_machines = false + auto_start_machines = false + min_machines_running = 1 + # No `ports` block: reachable over 6PN only, never from the internet. + + [[services.tcp_checks]] + interval = "30s" + timeout = "5s" + grace_period = "20s" diff --git a/fly.web.toml b/fly.web.toml new file mode 100644 index 0000000..2668040 --- /dev/null +++ b/fly.web.toml @@ -0,0 +1,48 @@ +# Fly config for the Next.js front end — the only publicly reachable surface. +# +# fly deploy -c fly.web.toml +# +# Everything behind HTTP Basic auth (middleware.ts). Credentials are set as Fly +# secrets, never committed: +# +# fly secrets set APP_USERNAME=... APP_PASSWORD=... -a options-analytics-web + +app = "options-analytics-web" +primary_region = "bos" + +[build] + dockerfile = "Dockerfile.web" + +[env] + # Resolved at request time by app/api/[...path]/route.ts, not baked into the + # client bundle — so this image is identical across environments and moving + # the API does not require a rebuild. + API_INTERNAL_URL = "http://options-analytics-api.internal:8000" + NODE_ENV = "production" + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = "suspend" + auto_start_machines = true + # Suspending the front end is fine: it holds no state and no schedule, and a + # resume is fast. Only the API must stay up, because it owns the timer. + min_machines_running = 0 + + [http_service.concurrency] + type = "requests" + soft_limit = 50 + hard_limit = 100 + + [[http_service.checks]] + grace_period = "15s" + interval = "30s" + method = "GET" + timeout = "5s" + # Unauthenticated on purpose so the platform health check is not a reason to + # weaken the auth middleware. It returns a fixed string and reads nothing. + path = "/healthz" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" diff --git a/web/app/api/[...path]/route.ts b/web/app/api/[...path]/route.ts new file mode 100644 index 0000000..803594f --- /dev/null +++ b/web/app/api/[...path]/route.ts @@ -0,0 +1,99 @@ +/** + * Server-side proxy to the analytics API. + * + * The browser never talks to the API directly. It calls this same-origin route, + * which forwards to the API over Fly's private network. Four things fall out of + * that: + * + * 1. **The API needs no public address.** It is unreachable from the internet, + * so the only thing that has to be authenticated is this Next.js app. + * 2. **CORS stops existing.** Same origin, no preflight, no allow-list to keep + * in sync with whatever domain the app ends up on. + * 3. **The API URL becomes runtime configuration.** The previous design baked + * `NEXT_PUBLIC_API_BASE_URL` into the client bundle at build time, so moving + * the API meant rebuilding the image. `API_INTERNAL_URL` is read per request. + * 4. **One image runs anywhere** — local, staging, production — because nothing + * environment-specific is compiled in. + */ + +import { NextRequest, NextResponse } from "next/server"; + +// Always hit the upstream; never serve a cached chain as if it were live. +export const dynamic = "force-dynamic"; + +const API_INTERNAL_URL = + process.env.API_INTERNAL_URL ?? "http://localhost:8000"; + +// Longer than a browser's patience but shorter than a hung socket. A cold chain +// fetch from the vendor can legitimately take a while. +const UPSTREAM_TIMEOUT_MS = 30_000; + +async function proxy(request: NextRequest, path: string[]): Promise { + const target = new URL( + `${API_INTERNAL_URL.replace(/\/$/, "")}/${path.join("/")}`, + ); + target.search = request.nextUrl.search; + + const headers = new Headers(); + const contentType = request.headers.get("content-type"); + if (contentType) headers.set("content-type", contentType); + // Deliberately not forwarded: the browser's Authorization header. It + // authenticates the user to *this* app; the API sits on a private network and + // has no notion of it. + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS); + + try { + const upstream = await fetch(target, { + method: request.method, + headers, + body: + request.method === "GET" || request.method === "HEAD" + ? undefined + : await request.text(), + signal: controller.signal, + cache: "no-store", + }); + + const body = await upstream.text(); + return new NextResponse(body || null, { + status: upstream.status, + headers: { + "content-type": + upstream.headers.get("content-type") ?? "application/json", + "cache-control": "no-store", + }, + }); + } catch (cause) { + const timedOut = cause instanceof Error && cause.name === "AbortError"; + // Returned in the API's own error shape so the client has one contract to + // handle rather than two. + return NextResponse.json( + { + code: timedOut ? "upstream_timeout" : "api_unreachable", + message: timedOut + ? "The analytics API did not respond in time." + : "Could not reach the analytics API.", + detail: null, + }, + { status: timedOut ? 504 : 502, headers: { "cache-control": "no-store" } }, + ); + } finally { + clearTimeout(timeout); + } +} + +type Context = { params: Promise<{ path: string[] }> }; + +export async function GET(request: NextRequest, context: Context) { + return proxy(request, (await context.params).path); +} + +export async function POST(request: NextRequest, context: Context) { + return proxy(request, (await context.params).path); +} + +export async function DELETE(request: NextRequest, context: Context) { + return proxy(request, (await context.params).path); +} diff --git a/web/app/healthz/route.ts b/web/app/healthz/route.ts new file mode 100644 index 0000000..bcfd78d --- /dev/null +++ b/web/app/healthz/route.ts @@ -0,0 +1,21 @@ +/** + * Platform health check for the web app. + * + * Excluded from the auth middleware on purpose. Fly's health checker cannot + * present credentials, and the alternative — weakening the middleware so the + * checker gets through — would be a far worse trade than exposing a route that + * returns a fixed string and reads nothing. + * + * It deliberately does **not** check the upstream API. A readiness probe that + * fails because the *API* is down would have Fly restart the *web* machine, + * which cannot fix it, turning one degraded service into two. + */ + +export const dynamic = "force-dynamic"; + +export function GET() { + return new Response("ok", { + status: 200, + headers: { "content-type": "text/plain", "cache-control": "no-store" }, + }); +} diff --git a/web/lib/api.ts b/web/lib/api.ts index 460ec28..543178b 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -12,8 +12,17 @@ * component can distinguish a throttled vendor from a bad ticker. */ -export const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; +/** + * Same-origin. Requests go to this app's own `/api/[...path]` route, which + * forwards them to the analytics API server-side. + * + * This used to be `process.env.NEXT_PUBLIC_API_BASE_URL`, compiled into the + * client bundle at build time — which meant the image only worked against the + * host it was built for, and moving the API required a rebuild. Proxying makes + * the upstream address runtime configuration (`API_INTERNAL_URL`, read by the + * route handler) and lets the API stay off the public internet entirely. + */ +export const API_BASE_URL = "/api"; export interface ErrorBody { code: string; @@ -49,13 +58,16 @@ async function request(path: string, init?: RequestInit): Promise { cache: "no-store", }); } catch { - // The API not being up at all is the most common failure in development, - // and "Failed to fetch" tells the user nothing actionable, so the original - // cause is deliberately replaced with something a reader can act on. + // This now means the *proxy route* is unreachable, which in practice means + // the Next.js server itself is down — the upstream API failing is returned + // by the route handler as a normal 502/504 with a typed body, not as a + // thrown fetch error. "Failed to fetch" tells a user nothing actionable, so + // the original cause is deliberately replaced. throw new ApiError( { code: "api_unreachable", - message: `Could not reach the API at ${API_BASE_URL}. Is it running? Try: cd api && uvicorn options_tool.api.main:app --reload`, + message: + "Could not reach the application server. If you are running locally, check that `npm run dev` is still running.", }, 0, ); diff --git a/web/middleware.test.ts b/web/middleware.test.ts new file mode 100644 index 0000000..cd8fbe6 --- /dev/null +++ b/web/middleware.test.ts @@ -0,0 +1,102 @@ +/** + * Basic auth is the entire authentication story for this deployment — the API + * has no public address, so if this is wrong nothing else catches it. Hence the + * coverage of the ugly cases: no header, wrong scheme, malformed base64, a + * password containing a colon, and near-miss credentials. + */ + +import { describe, expect, it, beforeEach, afterEach } from "vitest"; + +import { middleware } from "./middleware"; + +const USER = "tejas"; +const PASSWORD = "correct-horse:battery"; + +function request(authorization?: string) { + const headers = new Headers(); + if (authorization) headers.set("authorization", authorization); + return { headers } as unknown as Parameters[0]; +} + +function basic(user: string, password: string): string { + return `Basic ${btoa(`${user}:${password}`)}`; +} + +describe("when credentials are configured", () => { + beforeEach(() => { + process.env.APP_USERNAME = USER; + process.env.APP_PASSWORD = PASSWORD; + }); + afterEach(() => { + delete process.env.APP_USERNAME; + delete process.env.APP_PASSWORD; + }); + + it("accepts the correct credentials", () => { + const response = middleware(request(basic(USER, PASSWORD))); + expect(response.status).toBe(200); + }); + + it("accepts a password containing a colon", () => { + // Splitting on every colon rather than the first would break this. + expect(middleware(request(basic(USER, PASSWORD))).status).toBe(200); + }); + + it("rejects a missing header with a challenge", () => { + const response = middleware(request()); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain("Basic"); + }); + + it("never caches a 401", () => { + expect(middleware(request()).headers.get("cache-control")).toBe("no-store"); + }); + + it.each([ + ["wrong password", basic(USER, "nope")], + ["wrong username", basic("someone", PASSWORD)], + ["both wrong", basic("someone", "nope")], + ["empty credentials", basic("", "")], + ["password as username", basic(PASSWORD, USER)], + ])("rejects %s", (_label, header) => { + expect(middleware(request(header)).status).toBe(401); + }); + + it.each([ + ["a bearer token", "Bearer abc123"], + ["no scheme", btoa(`${USER}:${PASSWORD}`)], + ["lowercase scheme", `basic ${btoa(`${USER}:${PASSWORD}`)}`], + ["malformed base64", "Basic !!!not-base64!!!"], + ["no colon in the payload", `Basic ${btoa("justausername")}`], + ["empty", "Basic "], + ])("rejects %s", (_label, header) => { + expect(middleware(request(header)).status).toBe(401); + }); + + it("rejects a prefix of the real password", () => { + // The case a length-short-circuiting comparison would leak. + expect(middleware(request(basic(USER, PASSWORD.slice(0, -1)))).status).toBe(401); + }); + + it("rejects a password with extra trailing characters", () => { + expect(middleware(request(basic(USER, `${PASSWORD}x`))).status).toBe(401); + }); +}); + +describe("when credentials are not configured", () => { + beforeEach(() => { + delete process.env.APP_USERNAME; + delete process.env.APP_PASSWORD; + }); + + it("runs open, for local development", () => { + expect(middleware(request()).status).toBe(200); + }); + + it("stays open if only a username is set", () => { + // Half-configured must not look protected while accepting anything. + process.env.APP_USERNAME = USER; + expect(middleware(request()).status).toBe(200); + delete process.env.APP_USERNAME; + }); +}); diff --git a/web/middleware.ts b/web/middleware.ts new file mode 100644 index 0000000..59168d9 --- /dev/null +++ b/web/middleware.ts @@ -0,0 +1,93 @@ +/** + * HTTP Basic authentication over the whole app. + * + * This tool is deployed fully private: there is no public read surface, and the + * API has no public address at all — it is reachable only over Fly's private + * network, from this Next.js server. So a single check here is the entire + * authentication story, which is why it guards the proxy route too. + * + * Basic auth rather than a bearer token because the front end is a browser app. + * The browser handles the credential prompt and re-sends the header on every + * request, including from client-side `fetch` to the same origin, with no login + * page to build and no session to store. Over TLS (which Fly terminates) the + * credentials are encrypted in transit. + * + * If `APP_USERNAME` / `APP_PASSWORD` are unset the app runs open. That is the + * right default for `npm run dev` on localhost, and `/api/health` reports which + * mode is in force so a deployment cannot be accidentally public without it + * being visible. + */ + +import { NextRequest, NextResponse } from "next/server"; + +export const config = { + // Everything except Next's own static output and /healthz. The proxy route is + // deliberately included: it is the only path to the API. + // + // /healthz is exempt because Fly's health checker cannot present credentials, + // and weakening this middleware to let the checker through would be a far + // worse trade than exposing a route that returns a fixed string. + matcher: ["/((?!_next/static|_next/image|favicon.ico|healthz).*)"], +}; + +/** + * Constant-time string comparison. + * + * A plain `===` on secrets leaks their length and prefix through timing. The + * practical risk over the internet is small, but the correct version is four + * lines and there is no reason to ship the sloppy one. + */ +function safeEqual(a: string, b: string): boolean { + const encoder = new TextEncoder(); + const left = encoder.encode(a); + const right = encoder.encode(b); + // Compare a fixed-length digest of each side so differing lengths do not + // short-circuit the loop. + let mismatch = left.length ^ right.length; + for (let i = 0; i < Math.max(left.length, right.length); i += 1) { + mismatch |= (left[i] ?? 0) ^ (right[i] ?? 0); + } + return mismatch === 0; +} + +function unauthorized(): NextResponse { + return new NextResponse("Authentication required.", { + status: 401, + headers: { + "WWW-Authenticate": 'Basic realm="Options Analytics", charset="UTF-8"', + "Cache-Control": "no-store", + }, + }); +} + +export function middleware(request: NextRequest) { + const username = process.env.APP_USERNAME; + const password = process.env.APP_PASSWORD; + + // No credentials configured: run open. Intended for local development. + if (!username || !password) return NextResponse.next(); + + const header = request.headers.get("authorization"); + if (!header?.startsWith("Basic ")) return unauthorized(); + + let decoded: string; + try { + decoded = atob(header.slice("Basic ".length)); + } catch { + return unauthorized(); + } + + // Split on the first colon only — a password may legitimately contain one. + const separator = decoded.indexOf(":"); + if (separator < 0) return unauthorized(); + + const suppliedUser = decoded.slice(0, separator); + const suppliedPassword = decoded.slice(separator + 1); + + // Both comparisons always run; `&&` would skip the second on a bad username + // and reintroduce the timing signal safeEqual exists to remove. + const userOk = safeEqual(suppliedUser, username); + const passwordOk = safeEqual(suppliedPassword, password); + + return userOk && passwordOk ? NextResponse.next() : unauthorized(); +} diff --git a/web/next.config.ts b/web/next.config.ts index 3927c2a..0bab49a 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -6,13 +6,9 @@ const config: NextConfig = { // reached at runtime, so the production image copies ~50MB instead of the // whole dependency tree. output: "standalone", - // The API base URL is read at build time by the client bundle. It is declared - // here as well so `next build` fails loudly in CI if it is ever removed from - // the environment, rather than shipping a bundle that silently calls itself. - env: { - NEXT_PUBLIC_API_BASE_URL: - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000", - }, + // Nothing environment-specific is compiled in. The API address is read at + // request time by app/api/[...path]/route.ts from API_INTERNAL_URL, so one + // built image runs unchanged against local, staging or production. }; export default config; diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 9be6b88..30a312a 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url"; export default defineConfig({ test: { environment: "node", - include: ["lib/**/*.test.ts"], + include: ["lib/**/*.test.ts", "*.test.ts"], }, resolve: { alias: { "@": fileURLToPath(new URL(".", import.meta.url)) }, From 7fc564deb1902c7af3d81b5701a03e861b68bbfa Mon Sep 17 00:00:00 2001 From: Tejas <77419101+Tejas12972@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:57:00 -0400 Subject: [PATCH 10/11] Add an idempotent Fly bootstrap script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One command after `fly auth login`: creates both apps, the volume and the login secret, deploys API before web (the web config resolves the API by internal DNS, so the reverse order fails its health check), and seeds the watchlist — without which the daily job has nothing to snapshot. Re-runnable after a partial failure. It skips anything that already exists rather than erroring, and will not silently rotate a password that is already set, since Fly secrets are write-only and an unannounced rotation would lock you out with no way to read the new value back. Co-Authored-By: Claude Opus 5 (1M context) --- deploy/README.md | 14 ++++++ deploy/fly-bootstrap.sh | 96 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100755 deploy/fly-bootstrap.sh diff --git a/deploy/README.md b/deploy/README.md index a448f57..7cec8f2 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -41,6 +41,20 @@ No local Docker required — Fly builds the images remotely. --- +## The short version + +```bash +fly auth login +./deploy/fly-bootstrap.sh +``` + +Idempotent — safe to re-run after a partial failure. It creates both apps, the +volume and the login secret, deploys API-then-web in that order, seeds the +watchlist, and prints the generated password once. The steps below are what it +does, for when something needs doing by hand. + +--- + ## 1. Create the apps ```bash diff --git a/deploy/fly-bootstrap.sh b/deploy/fly-bootstrap.sh new file mode 100755 index 0000000..7e8b29c --- /dev/null +++ b/deploy/fly-bootstrap.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# +# One-shot Fly.io bootstrap. Idempotent — safe to re-run after a partial failure. +# +# fly auth login # once, interactively +# ./deploy/fly-bootstrap.sh +# +# Creates both apps, the volume, and the login secret, then deploys API first +# and web second. Prints the generated password once; Fly secrets are write-only +# and will not show it back to you. + +set -euo pipefail + +API_APP="${API_APP:-options-analytics-api}" +WEB_APP="${WEB_APP:-options-analytics-web}" +REGION="${REGION:-bos}" +VOLUME_NAME="${VOLUME_NAME:-options_data}" +VOLUME_SIZE_GB="${VOLUME_SIZE_GB:-1}" +APP_USERNAME="${APP_USERNAME:-tejas}" + +FLY="${FLY:-flyctl}" +command -v "$FLY" >/dev/null 2>&1 || FLY="$HOME/.fly/bin/flyctl" +command -v "$FLY" >/dev/null 2>&1 || { + echo "flyctl not found. Install it with: curl -L https://fly.io/install.sh | sh" >&2 + exit 1 +} + +cd "$(dirname "$0")/.." + +say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; } + +"$FLY" auth whoami >/dev/null 2>&1 || { + echo "Not logged in. Run: $FLY auth login" >&2 + exit 1 +} + +say "Creating apps (skipped if they already exist)" +"$FLY" apps create "$API_APP" 2>/dev/null || echo " $API_APP already exists" +"$FLY" apps create "$WEB_APP" 2>/dev/null || echo " $WEB_APP already exists" + +say "Creating the data volume" +# The volume holds the accumulated IV history — the only state here that cannot +# be re-downloaded from anywhere, at any price. +if "$FLY" volumes list -a "$API_APP" 2>/dev/null | grep -q "$VOLUME_NAME"; then + echo " volume $VOLUME_NAME already exists" +else + "$FLY" volumes create "$VOLUME_NAME" \ + --size "$VOLUME_SIZE_GB" --region "$REGION" -a "$API_APP" --yes +fi + +say "Setting the login" +if "$FLY" secrets list -a "$WEB_APP" 2>/dev/null | grep -q APP_PASSWORD; then + echo " APP_PASSWORD already set; leaving it alone." + echo " To rotate: $FLY secrets set APP_PASSWORD=... -a $WEB_APP" +else + APP_PASSWORD="$(openssl rand -base64 24)" + "$FLY" secrets set \ + APP_USERNAME="$APP_USERNAME" \ + APP_PASSWORD="$APP_PASSWORD" \ + -a "$WEB_APP" + printf '\n \033[1;33mSave these now — Fly will not show the password again:\033[0m\n' + printf ' username: %s\n password: %s\n\n' "$APP_USERNAME" "$APP_PASSWORD" +fi + +# API first: the web app's config points at $API_APP.internal, and deploying it +# against an app that does not exist yet fails its health check. +say "Deploying the API (private — no public address)" +"$FLY" deploy -c fly.api.toml -a "$API_APP" + +say "Deploying the web app (public, behind Basic auth)" +"$FLY" deploy -c fly.web.toml -a "$WEB_APP" + +say "Seeding the watchlist" +# The daily job snapshots whatever is on the watchlist, so an empty one means it +# does nothing at all. +"$FLY" ssh console -a "$API_APP" -C "python -m options_tool watchlist --add SPY AAPL" || \ + echo " (seed failed — add symbols in the UI instead)" + +say "Done" +cat < Date: Thu, 24 Sep 2026 13:35:04 -0400 Subject: [PATCH 11/11] Price snapshots against the data's own clock, not the wall clock Without an explicit as_of, snapshot_ticker fell back to datetime.now() while taking prices from the provider. For a live vendor the two are the same instant; for a replayed fixture they are not, so the capture was aged past its quotes and filed under the wrong day. Once the fixture's front expiry (2026-09-14) went behind the calendar, the 30-day point could no longer be bracketed, atm_iv_30d was correctly null, and eight tests began failing on the date alone. Each chain is now priced at its own as_of, matching what the /chain route and the CLI already do, and the snapshot is filed under the first chain's market date. An explicit as_of still wins. Live behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- api/options_tool/db/snapshot.py | 28 +++++++++++++++++++++------- api/tests/test_snapshot.py | 20 ++++++++++++++++++-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/api/options_tool/db/snapshot.py b/api/options_tool/db/snapshot.py index f36f582..9c146a8 100644 --- a/api/options_tool/db/snapshot.py +++ b/api/options_tool/db/snapshot.py @@ -82,10 +82,18 @@ def snapshot_ticker( max_expiries: int = DEFAULT_MAX_EXPIRIES, as_of: datetime | None = None, ) -> SnapshotResult: - """Capture one ticker's chains for one day. Safe to run more than once a day.""" - as_of = as_of or datetime.now(UTC) - snapshot_date = market_date(as_of) - result = SnapshotResult(ticker=symbol.upper(), snapshot_date=snapshot_date) + """Capture one ticker's chains for one day. Safe to run more than once a day. + + Without an explicit `as_of`, the clock comes from the data -- each chain's own + `as_of` -- not from the wall clock. Prices and time-to-expiry must describe + the same instant: pricing a replayed capture against today's date solves IV + for an option days older than its quote, and files it under the wrong day. + For a live provider the two are the same moment anyway. + """ + snapshot_as_of = as_of + result = SnapshotResult( + ticker=symbol.upper(), snapshot_date=market_date(as_of or datetime.now(UTC)) + ) ticker = get_or_create_ticker(session, symbol) expiries = provider.get_expiries(symbol)[:max_expiries] @@ -106,10 +114,13 @@ def snapshot_ticker( result.errors.append(f"{expiry}: {exc}") continue - frame = build_chain_frame(chain, risk_free_rate, dividend_yield, as_of) + pricing_as_of = as_of or chain.as_of + frame = build_chain_frame(chain, risk_free_rate, dividend_yield, pricing_as_of) if frame.empty: continue + snapshot_as_of = snapshot_as_of or pricing_as_of + frames.append(frame) result.expiries.append(expiry) spot = chain.spot @@ -118,9 +129,12 @@ def snapshot_ticker( if atm is not None: atm_by_maturity[float(frame.attrs["time_to_expiry"])] = atm - if not frames or spot is None: + if not frames or spot is None or snapshot_as_of is None: raise ProviderError(f"{symbol}: every expiry failed; nothing to snapshot") + snapshot_date = market_date(snapshot_as_of) + result.snapshot_date = snapshot_date + combined = pd.concat(frames, ignore_index=True) result.contracts_written = len(combined) result.contracts_solved = int(combined["iv"].notna().sum()) @@ -131,7 +145,7 @@ def snapshot_ticker( session, ticker_id=ticker.id, snapshot_date=snapshot_date, - as_of=as_of, + as_of=snapshot_as_of, provider_name=provider.name, spot=spot, risk_free_rate=risk_free_rate, diff --git a/api/tests/test_snapshot.py b/api/tests/test_snapshot.py index 9c3fc6f..f10c7c0 100644 --- a/api/tests/test_snapshot.py +++ b/api/tests/test_snapshot.py @@ -128,14 +128,14 @@ def test_the_database_rejects_a_duplicate_even_without_the_upsert( def test_two_snapshots_for_one_ticker_on_one_day_are_rejected( self, session: Session, provider: FixtureProvider ) -> None: - run(session, provider) + result = run(session, provider) ticker = session.scalar(select(Ticker).where(Ticker.symbol == "AAPL")) assert ticker is not None session.add( Snapshot( ticker_id=ticker.id, - snapshot_date=market_date(), + snapshot_date=result.snapshot_date, as_of=datetime.now(UTC), provider="fixture", spot=1.0, @@ -236,6 +236,22 @@ def test_atm_iv_is_computed_and_plausible( assert result.atm_iv_30d is not None assert 0.01 < result.atm_iv_30d < 3.0 + def test_the_clock_comes_from_the_data_not_the_wall( + self, session: Session, provider: FixtureProvider + ) -> None: + """A replayed capture is priced, and filed, as of the moment it was captured. + + Pricing it against today instead ages every option past its quote; once + the capture's front expiry is behind us the 30-day point stops being + bracketed and this suite would start failing on the calendar alone. + """ + result = run(session, provider) + snapshot = latest_snapshot(session, "AAPL") + assert snapshot is not None + assert result.snapshot_date == market_date(provider.captured_at) + assert snapshot.as_of.replace(tzinfo=UTC) == provider.captured_at + assert result.atm_iv_30d is not None + def test_no_nan_reaches_the_database(self, session: Session, provider: FixtureProvider) -> None: """pandas NA is not a SQLite value; it must be normalised to NULL.""" import math