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 new file mode 100644 index 0000000..ec9c335 --- /dev/null +++ b/.env.example @@ -0,0 +1,71 @@ +# 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 ------------------------------------------------------------- +# 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 +# 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 ----------------------------------------------------------------- +# 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 --------------------------------------------------------------------- +# 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= + +# Host port for the web app under docker compose. +WEB_PORT=3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e96182 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,256 @@ +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 + + # 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 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/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 + 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 + 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 + + 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/.gitignore b/.gitignore new file mode 100644 index 0000000..ca98b60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# 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/ + +# Next.js +.next/ +out/ +next-env.d.ts 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/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..f090a34 --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,58 @@ +# 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 + +# 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 new file mode 100644 index 0000000..dfdaa5b --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,49 @@ +# 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/ ./ + +# 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 + +# ---- 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 + +# /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/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 311cbd6..7ee2eeb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,466 @@ -# stockwatchlist -stock watch list that displays chart and other data +# 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-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) + +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 + +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 | done | +| M3 — FastAPI | done | +| M4 — tests + CI | done | +| M5 — Next.js front end | done | +| M6 — Docker + deployment | Fly.io, private API + authenticated front end | +| M7 — polish (earnings flag, screener, CSV export) | done | + +--- + +## 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 +``` + +### 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% +┏━━━━━━━┳━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━┳━━━━━━━┳━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━┓ +┃ 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 +``` + +### 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. + +### 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 + +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, +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. + +--- + +## Tests + +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 +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, screener) | **97%** | +| whole package | 93% | + +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. + +--- + +## 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 + +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 +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 +``` + +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. + +--- + +## 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. + +--- + +## 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/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/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..fde964a --- /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 sqlalchemy import engine_from_config, pool + +from alembic import context +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..5919a9d --- /dev/null +++ b/api/alembic/versions/da5c89f6a74a_initial_schema_tickers_snapshots_.py @@ -0,0 +1,129 @@ +"""initial schema: tickers, snapshots, contracts + +Revision ID: da5c89f6a74a +Revises: +Create Date: 2026-09-12 20:37:18.906707 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +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"), + ) + 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/__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/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/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..0ddeca7 --- /dev/null +++ b/api/options_tool/analytics/chain.py @@ -0,0 +1,260 @@ +"""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 dataclasses import dataclass, field +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", + "MARKET_TZ", + "market_date", + "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) +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]", + "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 + + +@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 + 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. `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 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/implied_vol.py b/api/options_tool/analytics/implied_vol.py new file mode 100644 index 0000000..961f5a3 --- /dev/null +++ b/api/options_tool/analytics/implied_vol.py @@ -0,0 +1,253 @@ +"""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, 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): + """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, lower) + + 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 + + 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 + + +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/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/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/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/__init__.py b/api/options_tool/api/__init__.py new file mode 100644 index 0000000..e69de29 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..61a16bf --- /dev/null +++ b/api/options_tool/api/main.py @@ -0,0 +1,189 @@ +"""FastAPI application. + + 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, 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"] + +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 _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, + ) + + # 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) + 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..ecb922a --- /dev/null +++ b/api/options_tool/api/routes.py @@ -0,0 +1,485 @@ +"""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 + +import logging +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.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, + 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, + screen_inputs, +) +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 + +logger = logging.getLogger(__name__) + +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("/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: + """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: + logger.exception("health check could not reach the database") + database_reachable = False + + settings = get_settings() + return HealthResponse( + 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, + ) + + +# -------------------------------------------------------------------------- +# 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, + ) + + +@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 +# -------------------------------------------------------------------------- + + +@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..87c44d2 --- /dev/null +++ b/api/options_tool/api/schemas.py @@ -0,0 +1,290 @@ +"""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", + "ScreenHitOut", + "ScreenResponse", + "EarningsResponse", +] + +DISCLAIMER = "Not investment advice. Personal analytics only." + + +class HealthResponse(BaseModel): + 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): + 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] + + +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 new file mode 100644 index 0000000..e21c4a3 --- /dev/null +++ b/api/options_tool/cli.py @@ -0,0 +1,668 @@ +"""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 UTC, date, datetime +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_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_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" + ) + 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) + # 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, + "screen": _cmd_screen, + "export": _cmd_export, + "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_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_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 + + 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..70daf29 --- /dev/null +++ b/api/options_tool/config.py @@ -0,0 +1,150 @@ +"""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") + + # --- 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: + 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_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] + 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/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..4da5628 --- /dev/null +++ b/api/options_tool/db/queries.py @@ -0,0 +1,200 @@ +"""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.analytics.screener import ScreenInput +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", + "activity_history", + "screen_inputs", +] + + +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) + + +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/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..9c146a8 --- /dev/null +++ b/api/options_tool/db/snapshot.py @@ -0,0 +1,312 @@ +"""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, market_date +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. + + 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] + 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 + + 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 + + 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 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()) + 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=snapshot_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=market_date(as_of)) + failed.errors.append(str(exc)) + results.append(failed) + return results 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/providers/__init__.py b/api/options_tool/providers/__init__.py new file mode 100644 index 0000000..06f55a3 --- /dev/null +++ b/api/options_tool/providers/__init__.py @@ -0,0 +1,52 @@ +"""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 ( + ExpiryNotFoundError, + MarketDataProvider, + OptionChain, + OptionQuote, + ProviderError, + Quote, + RateLimitedError, + UnknownTickerError, +) + +__all__ = [ + "ExpiryNotFoundError", + "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..1554d89 --- /dev/null +++ b/api/options_tool/providers/base.py @@ -0,0 +1,160 @@ +"""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", + "ExpiryNotFoundError", + "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.""" + + +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.""" + + 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 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. + + 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: + 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/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..1c4fd36 --- /dev/null +++ b/api/options_tool/providers/fixture_provider.py @@ -0,0 +1,118 @@ +"""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 ( + ExpiryNotFoundError, + 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: + 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( + 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..6408dbb --- /dev/null +++ b/api/options_tool/providers/yfinance_provider.py @@ -0,0 +1,253 @@ +"""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_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( + 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/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 new file mode 100644 index 0000000..3b5c3df --- /dev/null +++ b/api/pyproject.toml @@ -0,0 +1,133 @@ +[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", + # 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", +] +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] +# 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. +# 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", "ARG005", "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. +"options_tool/analytics/implied_vol.py" = ["PLR0911"] + +[tool.mypy] +# 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 +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/conftest.py b/api/tests/conftest.py new file mode 100644 index 0000000..ff2f00b --- /dev/null +++ b/api/tests/conftest.py @@ -0,0 +1,140 @@ +"""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", + # 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() + 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/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 + } + } +} 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..0f12b8b --- /dev/null +++ b/api/tests/test_api.py @@ -0,0 +1,372 @@ +"""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"]) + + +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_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_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_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_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_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([]) == [] diff --git a/api/tests/test_snapshot.py b/api/tests/test_snapshot.py new file mode 100644 index 0000000..f10c7c0 --- /dev/null +++ b/api/tests/test_snapshot.py @@ -0,0 +1,381 @@ +"""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: + 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=result.snapshot_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_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 + + 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 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 new file mode 100644 index 0000000..7cec8f2 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,226 @@ +# Deploying + +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. + +``` + 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) +``` + +Three consequences worth understanding before changing anything: + +- **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. + +--- + +## Prerequisites + +```bash +curl -L https://fly.io/install.sh | sh +export PATH="$HOME/.fly/bin:$PATH" +fly auth login # opens a browser +``` + +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 +fly apps create options-analytics-api +fly apps create options-analytics-web +``` + +## 2. Create the volume + +```bash +fly volumes create options_data --size 1 --region bos -a options-analytics-api +``` + +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`. + +## 3. Set the login + +```bash +fly secrets set \ + APP_USERNAME='you' \ + APP_PASSWORD="$(openssl rand -base64 24)" \ + -a options-analytics-web +``` + +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 +fly deploy -c fly.api.toml +fly deploy -c fly.web.toml +``` + +## 5. Seed the watchlist + +The daily job snapshots whatever is on the watchlist, so it needs at least one +symbol before it does anything. + +```bash +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" +``` + +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 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. + +--- + +## The daily snapshot + +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. + +`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. + +```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 +fly volumes snapshots list +``` + +--- + +## Upgrading + +```bash +git pull +fly deploy -c fly.api.toml # migrations run at startup, before serving +fly deploy -c fly.web.toml +``` + +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, 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/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 < { + 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/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/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/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..543178b --- /dev/null +++ b/web/lib/api.ts @@ -0,0 +1,254 @@ +/** + * 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. + */ + +/** + * 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; + 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 { + // 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 application server. If you are running locally, check that `npm run dev` is still running.", + }, + 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/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 new file mode 100644 index 0000000..0bab49a --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,14 @@ +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", + // 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/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/public/.gitkeep b/web/public/.gitkeep new file mode 100644 index 0000000..e69de29 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..30a312a --- /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", "*.test.ts"], + }, + resolve: { + alias: { "@": fileURLToPath(new URL(".", import.meta.url)) }, + }, +});