Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
256 changes: 256 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading