From 00a68bc76723354196b5de154d7c529ce660c5d3 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Sat, 21 Mar 2026 19:21:40 +0100 Subject: [PATCH] Initial commit: Vault Dashboard for options hedging - FastAPI + NiceGUI web application - QuantLib-based Black-Scholes pricing with Greeks - Protective put, laddered, and LEAPS strategies - Real-time WebSocket updates - TradingView-style charts via Lightweight-Charts - Docker containerization - GitLab CI/CD pipeline for VPS deployment - VPN-only access configuration --- .dockerignore | 26 ++ .env.example | 4 + .gitattributes | 1 + .gitignore | 8 + .gitlab-ci.yml | 101 +++++ .python-version | 1 + DEPLOYMENT.md | 620 +++++++++++++++++++++++++++ Dockerfile | 52 +++ LICENSE | 21 + Makefile | 17 + README.md | 94 ++++ app/__init__.py | 1 + app/api/routes.py | 28 ++ app/components/__init__.py | 13 + app/components/charts.py | 182 ++++++++ app/components/greeks_table.py | 104 +++++ app/components/portfolio_view.py | 68 +++ app/components/strategy_panel.py | 158 +++++++ app/core/__init__.py | 21 + app/core/calculations.py | 98 +++++ app/core/pricing/__init__.py | 58 +++ app/core/pricing/american_pricing.py | 194 +++++++++ app/core/pricing/black_scholes.py | 210 +++++++++ app/core/pricing/volatility.py | 127 ++++++ app/main.py | 172 ++++++++ app/models/__init__.py | 15 + app/models/option.py | 109 +++++ app/models/portfolio.py | 71 +++ app/models/strategy.py | 101 +++++ app/pages/__init__.py | 3 + app/pages/common.py | 203 +++++++++ app/pages/hedge.py | 126 ++++++ app/pages/options.py | 126 ++++++ app/pages/overview.py | 66 +++ app/pages/settings.py | 58 +++ app/services/cache.py | 72 ++++ app/services/data_service.py | 145 +++++++ app/strategies/__init__.py | 17 + app/strategies/base.py | 40 ++ app/strategies/engine.py | 159 +++++++ app/strategies/laddered_put.py | 129 ++++++ app/strategies/lease.py | 95 ++++ app/strategies/protective_put.py | 139 ++++++ config/.gitignore | 1 + config/settings.example.yaml | 26 ++ config/settings.yaml | 26 ++ docker-compose.deploy.yml | 25 ++ docker-compose.prod.yml | 63 +++ docker-compose.yml | 48 +++ docs/API.md | 593 +++++++++++++++++++++++++ docs/ARCHITECTURE.md | 437 +++++++++++++++++++ docs/STRATEGIES.md | 424 ++++++++++++++++++ pyproject.toml | 32 ++ requirements-dev.txt | 6 + requirements.txt | 10 + scripts/deploy.sh | 91 ++++ scripts/entrypoint.sh | 70 +++ scripts/healthcheck.py | 53 +++ tests/conftest.py | 85 ++++ tests/test_health.py | 13 + tests/test_portfolio.py | 22 + tests/test_pricing.py | 67 +++ tests/test_strategies.py | 94 ++++ 63 files changed, 6239 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .python-version create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/routes.py create mode 100644 app/components/__init__.py create mode 100644 app/components/charts.py create mode 100644 app/components/greeks_table.py create mode 100644 app/components/portfolio_view.py create mode 100644 app/components/strategy_panel.py create mode 100644 app/core/__init__.py create mode 100644 app/core/calculations.py create mode 100644 app/core/pricing/__init__.py create mode 100644 app/core/pricing/american_pricing.py create mode 100644 app/core/pricing/black_scholes.py create mode 100644 app/core/pricing/volatility.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/option.py create mode 100644 app/models/portfolio.py create mode 100644 app/models/strategy.py create mode 100644 app/pages/__init__.py create mode 100644 app/pages/common.py create mode 100644 app/pages/hedge.py create mode 100644 app/pages/options.py create mode 100644 app/pages/overview.py create mode 100644 app/pages/settings.py create mode 100644 app/services/cache.py create mode 100644 app/services/data_service.py create mode 100644 app/strategies/__init__.py create mode 100644 app/strategies/base.py create mode 100644 app/strategies/engine.py create mode 100644 app/strategies/laddered_put.py create mode 100644 app/strategies/lease.py create mode 100644 app/strategies/protective_put.py create mode 100644 config/.gitignore create mode 100644 config/settings.example.yaml create mode 100644 config/settings.yaml create mode 100644 docker-compose.deploy.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/STRATEGIES.md create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100755 scripts/deploy.sh create mode 100755 scripts/entrypoint.sh create mode 100755 scripts/healthcheck.py create mode 100644 tests/conftest.py create mode 100644 tests/test_health.py create mode 100644 tests/test_portfolio.py create mode 100644 tests/test_pricing.py create mode 100644 tests/test_strategies.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b42b3ff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +.Python +.venv/ +venv/ +.env +.env.* +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.cache/ +.git/ +.gitignore +.vscode/ +.idea/ +.DS_Store +*.log +data/cache/ +data/exports/ +tests/ +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7952fe1 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +APP_HOST=0.0.0.0 +APP_PORT=8000 +REDIS_URL=redis://localhost:6379 +CONFIG_PATH=/app/config/settings.yaml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..551f251 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +.venv/ +*.pyc +.env +config/secrets.yaml +data/cache/ +.idea/ +.vscode/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c3e7d0e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,101 @@ +stages: + - test + - build + - deploy + +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + PYTHONUNBUFFERED: "1" + DOCKER_TLS_CERTDIR: "/certs" + IMAGE_TAG: "$CI_COMMIT_SHORT_SHA" + APP_IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" + +cache: + paths: + - .cache/pip + +.python_setup: &python_setup + image: python:3.12-slim + before_script: + - python -V + - python -m pip install --upgrade pip + - pip install -r requirements-dev.txt + +lint: + <<: *python_setup + stage: test + script: + - ruff check app tests scripts + - black --check app tests scripts + +unit_tests: + <<: *python_setup + stage: test + script: + - pytest -q tests + +type_check: + <<: *python_setup + stage: test + script: + - mypy app scripts --ignore-missing-imports + +build_image: + stage: build + image: docker:27 + services: + - docker:27-dind + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + script: + - docker build --pull -t "$APP_IMAGE" -t "$CI_REGISTRY_IMAGE:latest" . + - docker push "$APP_IMAGE" + - | + if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then + docker push "$CI_REGISTRY_IMAGE:latest" + fi + - printf 'APP_IMAGE=%s\nIMAGE_TAG=%s\n' "$APP_IMAGE" "$IMAGE_TAG" > build.env + artifacts: + reports: + dotenv: build.env + +security_scan: + stage: build + image: + name: aquasec/trivy:0.61.1 + entrypoint: [""] + needs: ["build_image"] + script: + - trivy image --exit-code 1 --severity HIGH,CRITICAL --username "$CI_REGISTRY_USER" --password "$CI_REGISTRY_PASSWORD" "$APP_IMAGE" + +.deploy_setup: &deploy_setup + image: python:3.12-alpine + before_script: + - apk add --no-cache bash openssh-client curl docker-cli docker-cli-compose + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - printf '%s' "$DEPLOY_SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - python -V + +.deploy_rules: &deploy_rules + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + + +deploy_production: + <<: *deploy_setup + stage: deploy + needs: ["build_image", "security_scan"] + environment: + name: production + variables: + GIT_STRATEGY: fetch + script: + - test -n "$DEPLOY_HOST" || (echo "DEPLOY_HOST must be set to a VPN-reachable private address" && exit 1) + - bash scripts/deploy.sh + - | + if [ -n "${EXTERNAL_HEALTHCHECK_URL:-}" ]; then + python scripts/healthcheck.py "$EXTERNAL_HEALTHCHECK_URL" --timeout 120 --expect-status ok --expect-environment "${APP_ENV:-production}" + fi + <<: *deploy_rules diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..c784910 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,620 @@ +# Deployment Guide + +This project ships with a GitLab CI/CD pipeline that builds a Docker image, pushes it to the GitLab Container Registry, and deploys it to a VPN-reachable VPS over SSH. + +## Overview + +Deployment is driven by: + +- `.gitlab-ci.yml` for CI/CD stages +- `scripts/deploy.sh` for remote deployment and rollback +- `docker-compose.deploy.yml` for the production app container +- `scripts/healthcheck.py` for post-deploy validation + +The current production flow is: + +1. Run lint, tests, and type checks +2. Build and push a Docker image to GitLab Container Registry +3. Scan the image with Trivy +4. SSH into the VPS +5. Upload `docker-compose.deploy.yml` +6. Write a remote `.env` +7. Pull the new image and restart the service +8. Poll `/health` +9. Roll back to the last successful image if health checks fail + +--- + +## 1. Prerequisites + +### VPS requirements + +Minimum recommended VPS baseline: + +- 2 vCPU +- 2 GB RAM +- 20 GB SSD +- Linux host with systemd +- Stable outbound internet access to: + - GitLab Container Registry + - Python package mirrors if you build locally on the server later + - Market data providers if production uses live data +- Docker Engine installed +- Docker Compose plugin installed (`docker compose`) +- `curl` installed +- SSH access enabled + +Recommended hardening: + +- Dedicated non-root deploy user +- Host firewall enabled (`ufw` or equivalent) +- Automatic security updates +- Disk monitoring and log rotation +- VPN-only access to SSH and application traffic + +### Software to install on the VPS + +Example for Debian/Ubuntu: + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg + +# Install Docker +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +sudo usermod -aG docker deploy +``` + +Log out and back in after adding the deploy user to the `docker` group. + +--- + +## 2. GitLab runner setup + +The repository uses three CI stages: + +- `test` +- `build` +- `deploy` + +### What the pipeline expects + +From `.gitlab-ci.yml`: + +- Test jobs run in `python:3.12-slim` +- Image builds run with `docker:27` plus `docker:27-dind` +- Deploy runs in `python:3.12-alpine` and installs: + - `bash` + - `openssh-client` + - `curl` + - `docker-cli` + - `docker-cli-compose` + +### Runner options + +You can use either: + +1. GitLab shared runners, if they support Docker-in-Docker for your project +2. A dedicated self-hosted Docker runner + +### Recommended self-hosted runner configuration + +Use a Docker executor runner with privileged mode enabled for the `build_image` job. + +Example `config.toml` excerpt: + +```toml +[[runners]] + name = "vault-dash-docker-runner" + url = "https://gitlab.com/" + token = "REDACTED" + executor = "docker" + [runners.docker] + tls_verify = false + image = "python:3.12-slim" + privileged = true + disable_cache = false + volumes = ["/cache"] + shm_size = 0 +``` + +### Registering a runner + +```bash +sudo gitlab-runner register +``` + +Recommended answers: + +- URL: your GitLab instance URL +- Executor: `docker` +- Default image: `python:3.12-slim` +- Tags: optional, but useful if you want to target dedicated runners later + +### Runner permissions and networking + +The runner must be able to: + +- Authenticate to the GitLab Container Registry +- Reach the target VPS over SSH +- Reach the target VPS VPN address during deploy validation +- Pull base images from Docker Hub or your mirror + +--- + +## 3. SSH key configuration + +Deployment authenticates with `DEPLOY_SSH_PRIVATE_KEY`, which the deploy job writes to `~/.ssh/id_ed25519` before running `scripts/deploy.sh`. + +### Generate a deployment keypair + +On a secure admin machine: + +```bash +ssh-keygen -t ed25519 -C "gitlab-deploy-vault-dash" -f ./vault_dash_deploy_key +``` + +This creates: + +- `vault_dash_deploy_key` — private key +- `vault_dash_deploy_key.pub` — public key + +### Install the public key on the VPS + +```bash +ssh-copy-id -i ./vault_dash_deploy_key.pub deploy@YOUR_VPN_HOST +``` + +Or manually append it to: + +```text +/home/deploy/.ssh/authorized_keys +``` + +### Add the private key to GitLab CI/CD variables + +In **Settings → CI/CD → Variables** add: + +- `DEPLOY_SSH_PRIVATE_KEY` — contents of the private key + +Recommended flags: + +- Masked: yes +- Protected: yes +- Environment scope: `production` if you use environment-specific variables + +### Known-host handling + +The current deploy script uses: + +```bash +-o StrictHostKeyChecking=no +``` + +That makes first connection easier, but it weakens SSH trust validation. For a stricter setup, update the pipeline to preload `known_hosts` and remove that option. + +--- + +## 4. VPN setup for access + +The deployment is designed for private-network access. + +### Why VPN is recommended + +- The application container binds to loopback by default +- `DEPLOY_HOST` is expected to be a VPN-reachable private IP or internal DNS name +- SSH and HTTP traffic should not be exposed publicly unless a hardened reverse proxy is placed in front + +### Recommended topology + +```text +Admin / GitLab Runner + | + | VPN + v + VPS private address + | + +--> SSH (22) + +--> reverse proxy or direct internal app access +``` + +### Tailscale example + +1. Install Tailscale on the VPS +2. Join the host to your tailnet +3. Use the Tailscale IP or MagicDNS name as `DEPLOY_HOST` +4. Restrict firewall rules to the Tailscale interface + +Example UFW rules: + +```bash +sudo ufw allow in on tailscale0 to any port 22 proto tcp +sudo ufw allow in on tailscale0 to any port 8000 proto tcp +sudo ufw deny 22/tcp +sudo ufw deny 8000/tcp +sudo ufw enable +``` + +### WireGuard alternative + +If you use WireGuard instead of Tailscale: + +- assign the VPS a stable private VPN IP +- allow SSH and proxy traffic only on the WireGuard interface +- set `DEPLOY_HOST` to that private IP + +### Access patterns + +Preferred options: + +1. VPN access only, app bound to `127.0.0.1`, reverse proxy on same host +2. VPN access only, app published to private/VPN interface +3. Public HTTPS only through reverse proxy, app still bound internally + +Least preferred: + +- public direct access to port `8000` + +--- + +## 5. Environment variables + +The deploy script supports two patterns: + +1. Provide a full `APP_ENV_FILE` variable containing the remote `.env` +2. Provide individual CI variables and let `scripts/deploy.sh` assemble the `.env` + +### Required GitLab variables + +#### SSH and deployment + +- `DEPLOY_SSH_PRIVATE_KEY` +- `DEPLOY_USER` +- `DEPLOY_HOST` +- `DEPLOY_PORT` (optional, default `22`) +- `DEPLOY_PATH` (optional, default `/opt/vault-dash`) + +#### Container registry + +These are generally provided by GitLab automatically in CI: + +- `CI_REGISTRY` +- `CI_REGISTRY_IMAGE` +- `CI_REGISTRY_USER` +- `CI_REGISTRY_PASSWORD` +- `CI_COMMIT_SHA` + +#### App runtime + +- `APP_ENV` +- `APP_NAME` +- `APP_PORT` +- `APP_BIND_ADDRESS` +- `REDIS_URL` +- `DEFAULT_SYMBOL` +- `CACHE_TTL` +- `WEBSOCKET_INTERVAL_SECONDS` +- `NICEGUI_MOUNT_PATH` +- `NICEGUI_STORAGE_SECRET` +- `CORS_ORIGINS` + +#### Optional deployment controls + +- `APP_ENV_FILE` +- `COMPOSE_FILE` +- `COMPOSE_SERVICE` +- `DEPLOY_TIMEOUT` +- `HEALTHCHECK_URL` +- `REMOTE_ENV_FILE` +- `EXTERNAL_HEALTHCHECK_URL` +- `IMAGE_TAG` +- `APP_IMAGE` + +### Example `.env` + +```env +APP_IMAGE=registry.gitlab.com/your-group/vault-dash:main-123456 +APP_ENV=production +APP_NAME=Vault Dashboard +APP_PORT=8000 +APP_BIND_ADDRESS=127.0.0.1 +REDIS_URL= +DEFAULT_SYMBOL=GLD +CACHE_TTL=300 +WEBSOCKET_INTERVAL_SECONDS=5 +NICEGUI_MOUNT_PATH=/ +NICEGUI_STORAGE_SECRET=replace-with-long-random-secret +CORS_ORIGINS=https://vault.example.com +``` + +### Variable behavior in the app + +`app/main.py` loads runtime settings from environment variables and uses them for: + +- CORS configuration +- Redis connection +- cache TTL +- default symbol +- WebSocket publish interval +- NiceGUI mount path +- NiceGUI storage secret + +### Secret management guidance + +Treat these as secrets or sensitive config: + +- `DEPLOY_SSH_PRIVATE_KEY` +- `NICEGUI_STORAGE_SECRET` +- `REDIS_URL` if it contains credentials +- any future broker API credentials +- any future OAuth client secrets + +--- + +## 6. SSL/TLS configuration + +SSL/TLS is strongly recommended, especially because future OAuth integrations require stable HTTPS callback URLs and secure cookie handling. + +### Current app behavior + +The app itself listens on plain HTTP inside the container on port `8000`. + +Recommended production pattern: + +```text +Client -> HTTPS reverse proxy -> vault-dash container (HTTP on localhost/private network) +``` + +### Recommended reverse proxy choices + +- Caddy +- Nginx +- Traefik + +### Minimum TLS recommendations + +- TLS termination at the reverse proxy +- Automatic certificate management with Let's Encrypt or internal PKI +- Redirect HTTP to HTTPS +- HSTS once the domain is stable +- Forward standard proxy headers + +### Nginx example + +```nginx +server { + listen 80; + server_name vault.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name vault.example.com; + + ssl_certificate /etc/letsencrypt/live/vault.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/vault.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} +``` + +### OAuth readiness checklist + +Before adding OAuth: + +- serve the app only over HTTPS +- use a stable public or internal FQDN +- keep `CORS_ORIGINS` limited to trusted origins +- ensure WebSocket upgrade headers pass through the reverse proxy +- store OAuth client secrets in GitLab CI/CD variables or a secret manager +- verify callback/redirect URLs exactly match the provider configuration + +--- + +## 7. Deployment procedure + +### One-time server preparation + +1. Provision the VPS +2. Install Docker and Compose +3. Create a deploy user +4. Install the SSH public key for that user +5. Join the VPS to your VPN +6. Configure firewall rules +7. Create the deployment directory: + +```bash +sudo mkdir -p /opt/vault-dash +sudo chown deploy:deploy /opt/vault-dash +``` + +### GitLab CI/CD configuration + +1. Add all required variables in GitLab +2. Protect production variables +3. Ensure the deploy runner can reach the VPN host +4. Push to the default branch + +### What happens during deploy + +`scripts/deploy.sh` will: + +- connect to `DEPLOY_USER@DEPLOY_HOST` +- create `DEPLOY_PATH` if it does not exist +- write `.env` to `REMOTE_ENV_FILE` +- upload `docker-compose.deploy.yml` +- log into the GitLab registry on the VPS +- pull `APP_IMAGE` +- start the service with `docker compose` +- check `http://127.0.0.1:${APP_PORT}/health` by default +- restore the previous image if the health check never passes + +### Manual deploy from a workstation + +You can also export the same variables locally and run: + +```bash +bash scripts/deploy.sh +``` + +This is useful for smoke tests before enabling automated production deploys. + +--- + +## 8. Troubleshooting + +### Pipeline fails during `build_image` + +Possible causes: + +- runner is not privileged for Docker-in-Docker +- registry auth failed +- Docker Hub rate limits or base image pull failures + +Checks: + +```bash +docker info +``` + +Verify on the runner that privileged mode is enabled for Docker executor jobs. + +### Deploy job cannot SSH to the VPS + +Possible causes: + +- wrong `DEPLOY_HOST` +- VPN not connected +- wrong private key +- missing public key in `authorized_keys` +- firewall blocking port 22 + +Checks: + +```bash +ssh -i vault_dash_deploy_key deploy@YOUR_VPN_HOST +``` + +### Deploy job connects but `docker compose` fails + +Possible causes: + +- Docker not installed on VPS +- deploy user not in `docker` group +- remote filesystem permissions wrong +- invalid `.env` content + +Checks on VPS: + +```bash +docker version +docker compose version +id +ls -la /opt/vault-dash +``` + +### Health check never turns green + +Possible causes: + +- app failed to start +- container crashed +- missing `NICEGUI_STORAGE_SECRET` +- invalid env vars +- reverse proxy misrouting traffic + +Checks on VPS: + +```bash +cd /opt/vault-dash +docker compose -f docker-compose.deploy.yml --env-file .env ps +docker compose -f docker-compose.deploy.yml --env-file .env logs --tail=200 +curl -fsS http://127.0.0.1:8000/health +``` + +### Redis warnings at startup + +This app tolerates missing Redis and falls back to no-cache mode. If caching is expected, verify: + +- `REDIS_URL` is set +- Redis is reachable from the container +- the `redis` Python package is installed in the image + +### WebSocket issues behind a proxy + +Possible causes: + +- missing `Upgrade` / `Connection` headers +- idle timeout too low on the proxy +- incorrect HTTPS termination config + +Verify that `/ws/updates` supports WebSocket upgrades end-to-end. + +### Rollback failed + +The deploy script stores the last successful image at: + +```text +/opt/vault-dash/.last_successful_image +``` + +Manual rollback: + +```bash +cd /opt/vault-dash +export PREVIOUS_IMAGE="$(cat .last_successful_image)" +sed -i.bak "/^APP_IMAGE=/d" .env +printf 'APP_IMAGE=%s\n' "$PREVIOUS_IMAGE" | cat - .env.bak > .env +rm -f .env.bak +docker pull "$PREVIOUS_IMAGE" +docker compose -f docker-compose.deploy.yml --env-file .env up -d --remove-orphans +``` + +--- + +## 9. Post-deploy validation + +Minimum checks: + +```bash +curl -fsS http://127.0.0.1:8000/health +python scripts/healthcheck.py https://vault.example.com/health --timeout 120 --expect-status ok +``` + +Recommended smoke checks: + +- load the NiceGUI dashboard in a browser +- call `/api/portfolio?symbol=GLD` +- call `/api/options?symbol=GLD` +- call `/api/strategies?symbol=GLD` +- verify `/ws/updates` emits `connected` then `portfolio_update` + +## 10. Future deployment improvements + +Suggested follow-ups: + +- pin SSH host keys instead of disabling strict checking +- add a production reverse proxy service to Compose +- add Redis to the deploy Compose stack if caching is required in production +- add metrics and centralized logging +- split staging and production environments +- move secrets to a dedicated secret manager diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5833ea5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# syntax=docker/dockerfile:1.7 + +FROM python:3.11-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + VIRTUAL_ENV=/opt/venv + +WORKDIR /app + +RUN apt-get update \ + && apt-get install --no-install-recommends -y build-essential \ + && rm -rf /var/lib/apt/lists/* \ + && python -m venv "$VIRTUAL_ENV" + +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +COPY requirements.txt ./ +RUN pip install --upgrade pip setuptools wheel \ + && pip install -r requirements.txt + +FROM python:3.11-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:$PATH" + +WORKDIR /app + +RUN groupadd --system appuser \ + && useradd --system --gid appuser --create-home --home-dir /home/appuser appuser \ + && mkdir -p /app/data/cache /app/data/exports \ + && chown -R appuser:appuser /app /home/appuser + +COPY --from=builder /opt/venv /opt/venv +COPY --chown=appuser:appuser app ./app +COPY --chown=appuser:appuser config ./config +COPY --chown=appuser:appuser scripts/entrypoint.sh ./scripts/entrypoint.sh + +RUN chmod +x /app/scripts/entrypoint.sh + +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3); sys.exit(0)" + +ENTRYPOINT ["/app/scripts/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2728dd0 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: install dev test build deploy + +install: + python3 -m venv .venv + . .venv/bin/activate && pip install --upgrade pip && pip install -r requirements-dev.txt + +dev: + . .venv/bin/activate && python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +test: + . .venv/bin/activate && pytest + +build: + docker build -t vault-dash . + +deploy: + ./scripts/deploy.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8b2787 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Vault Dashboard + +A real-time options hedging dashboard for Lombard loan protection strategies. + +## Features + +- **Live Options Data**: Integration with Interactive Brokers and free data sources +- **Hedge Calculations**: Black-Scholes pricing, Greeks, strategy comparisons +- **Interactive Charts**: TradingView-quality visualizations with Lightweight Charts +- **Strategy Analysis**: Protective puts, collars, laddered positions +- **Real-time Updates**: WebSocket-based live data streaming + +## Quick Start + +### Local Development + +```bash +# Create virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Run development server +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### Docker + +```bash +# Build +docker build -t vault-dash . + +# Run +docker run -p 8000:8000 vault-dash +``` + +### Docker Compose + +```bash +docker-compose up -d +``` + +## Architecture + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ IBKR │────▶│ FastAPI │────▶│ Redis │ +│ Gateway │ │ Backend │ │ (cache) │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ NiceGUI Dashboard │ + │ (WebSocket client) │ + └─────────────────────────────┘ +``` + +## Configuration + +Copy `config/settings.example.yaml` to `config/settings.yaml` and configure: + +```yaml +# Broker settings +broker: + type: ibkr # or alpaca, yfinance + +# IBKR settings +ibkr: + host: 127.0.0.1 + port: 7497 # TWS: 7497, Gateway: 4001 + client_id: 1 + +# Portfolio defaults +portfolio: + gold_value: 1000000 + loan_amount: 600000 + ltv_ratio: 0.60 + margin_call_threshold: 0.75 + +# Data sources +data: + primary: yfinance # or ibkr, alpaca + cache_ttl: 300 +``` + +## Deployment + +See [DEPLOYMENT.md](DEPLOYMENT.md) for GitLab CI/CD and VPS deployment instructions. + +## License + +MIT \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..a3ebe50 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Vault dashboard application package.""" diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..1294aef --- /dev/null +++ b/app/api/routes.py @@ -0,0 +1,28 @@ +"""API routes for dashboard and strategy data.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request + +from app.services.data_service import DataService + +router = APIRouter(prefix="/api", tags=["api"]) + + +def get_data_service(request: Request) -> DataService: + return request.app.state.data_service + + +@router.get("/portfolio") +async def portfolio(symbol: str = "GLD", data_service: DataService = Depends(get_data_service)) -> dict: + return await data_service.get_portfolio(symbol) + + +@router.get("/options") +async def options(symbol: str = "GLD", data_service: DataService = Depends(get_data_service)) -> dict: + return await data_service.get_options_chain(symbol) + + +@router.get("/strategies") +async def strategies(symbol: str = "GLD", data_service: DataService = Depends(get_data_service)) -> dict: + return await data_service.get_strategies(symbol) diff --git a/app/components/__init__.py b/app/components/__init__.py new file mode 100644 index 0000000..86043fc --- /dev/null +++ b/app/components/__init__.py @@ -0,0 +1,13 @@ +"""Reusable NiceGUI dashboard components for the Vault Dashboard.""" + +from .charts import CandlestickChart +from .greeks_table import GreeksTable +from .portfolio_view import PortfolioOverview +from .strategy_panel import StrategyComparisonPanel + +__all__ = [ + "CandlestickChart", + "GreeksTable", + "PortfolioOverview", + "StrategyComparisonPanel", +] diff --git a/app/components/charts.py b/app/components/charts.py new file mode 100644 index 0000000..6bd8d42 --- /dev/null +++ b/app/components/charts.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import json +from typing import Any +from uuid import uuid4 + +from nicegui import ui + +_CHARTS_SCRIPT_ADDED = False + + +def _ensure_lightweight_charts_assets() -> None: + global _CHARTS_SCRIPT_ADDED + if _CHARTS_SCRIPT_ADDED: + return + ui.add_head_html( + """ + + """ + ) + _CHARTS_SCRIPT_ADDED = True + + +class CandlestickChart: + """Minimal Lightweight-Charts wrapper for NiceGUI candlestick dashboards. + + Features: + - real-time candlestick price updates + - volume histogram overlay + - moving-average / indicator line support + """ + + def __init__(self, title: str = "Gold Price", *, height: int = 420) -> None: + _ensure_lightweight_charts_assets() + self.chart_id = f"chart_{uuid4().hex}" + self.height = height + + with ui.card().classes("w-full rounded-2xl border border-slate-800 bg-slate-950/90 shadow-xl"): + with ui.row().classes("w-full items-center justify-between"): + ui.label(title).classes("text-lg font-semibold text-white") + ui.label("Live").classes( + "rounded-full bg-emerald-500/15 px-3 py-1 text-xs font-medium uppercase tracking-wide text-emerald-300" + ) + self.container = ui.html(f'
').style( + f"height: {height}px;" + ) + + self._initialize_chart() + + def _initialize_chart(self) -> None: + ui.run_javascript( + f""" + (function() {{ + const root = document.getElementById({json.dumps(self.chart_id)}); + if (!root || typeof LightweightCharts === 'undefined') return; + + root.innerHTML = ''; + window.vaultDashCharts = window.vaultDashCharts || {{}}; + + const chart = LightweightCharts.createChart(root, {{ + autoSize: true, + layout: {{ + background: {{ color: '#020617' }}, + textColor: '#cbd5e1', + }}, + grid: {{ + vertLines: {{ color: 'rgba(148, 163, 184, 0.12)' }}, + horzLines: {{ color: 'rgba(148, 163, 184, 0.12)' }}, + }}, + rightPriceScale: {{ borderColor: 'rgba(148, 163, 184, 0.25)' }}, + timeScale: {{ borderColor: 'rgba(148, 163, 184, 0.25)' }}, + crosshair: {{ mode: LightweightCharts.CrosshairMode.Normal }}, + }}); + + const candleSeries = chart.addSeries(LightweightCharts.CandlestickSeries, {{ + upColor: '#22c55e', + downColor: '#ef4444', + borderVisible: false, + wickUpColor: '#22c55e', + wickDownColor: '#ef4444', + }}); + + const volumeSeries = chart.addSeries(LightweightCharts.HistogramSeries, {{ + priceFormat: {{ type: 'volume' }}, + priceScaleId: '', + scaleMargins: {{ top: 0.78, bottom: 0 }}, + color: 'rgba(56, 189, 248, 0.45)', + }}); + + window.vaultDashCharts[{json.dumps(self.chart_id)}] = {{ + chart, + candleSeries, + volumeSeries, + indicators: {{}}, + }}; + }})(); + """ + ) + + def set_candles(self, candles: list[dict[str, Any]]) -> None: + payload = json.dumps(candles) + ui.run_javascript( + f""" + (function() {{ + const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; + if (!ref) return; + ref.candleSeries.setData({payload}); + ref.chart.timeScale().fitContent(); + }})(); + """ + ) + + def update_price(self, candle: dict[str, Any]) -> None: + payload = json.dumps(candle) + ui.run_javascript( + f""" + (function() {{ + const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; + if (!ref) return; + ref.candleSeries.update({payload}); + }})(); + """ + ) + + def set_volume(self, volume_points: list[dict[str, Any]]) -> None: + payload = json.dumps(volume_points) + ui.run_javascript( + f""" + (function() {{ + const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; + if (!ref) return; + ref.volumeSeries.setData({payload}); + }})(); + """ + ) + + def update_volume(self, volume_point: dict[str, Any]) -> None: + payload = json.dumps(volume_point) + ui.run_javascript( + f""" + (function() {{ + const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; + if (!ref) return; + ref.volumeSeries.update({payload}); + }})(); + """ + ) + + def set_indicator(self, name: str, points: list[dict[str, Any]], *, color: str = '#f59e0b', line_width: int = 2) -> None: + key = json.dumps(name) + payload = json.dumps(points) + ui.run_javascript( + f""" + (function() {{ + const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; + if (!ref) return; + if (!ref.indicators[{key}]) {{ + ref.indicators[{key}] = ref.chart.addSeries(LightweightCharts.LineSeries, {{ + color: {json.dumps(color)}, + lineWidth: {line_width}, + priceLineVisible: false, + lastValueVisible: true, + }}); + }} + ref.indicators[{key}].setData({payload}); + }})(); + """ + ) + + def update_indicator(self, name: str, point: dict[str, Any]) -> None: + key = json.dumps(name) + payload = json.dumps(point) + ui.run_javascript( + f""" + (function() {{ + const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}]; + const series = ref?.indicators?.[{key}]; + if (!series) return; + series.update({payload}); + }})(); + """ + ) diff --git a/app/components/greeks_table.py b/app/components/greeks_table.py new file mode 100644 index 0000000..fd67a94 --- /dev/null +++ b/app/components/greeks_table.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from typing import Any + +from nicegui import ui + +from app.models.option import OptionContract + + +class GreeksTable: + """Live Greeks table with simple risk-level color coding.""" + + def __init__(self, options: list[OptionContract | dict[str, Any]] | None = None) -> None: + self.options = options or [] + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.row().classes("w-full items-center justify-between"): + ui.label("Option Greeks").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label("Live Risk Snapshot").classes( + "rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-violet-700 dark:bg-violet-500/15 dark:text-violet-300" + ) + self.table_html = ui.html("").classes("w-full") + self.set_options(self.options) + + def set_options(self, options: list[OptionContract | dict[str, Any]]) -> None: + self.options = options + self.table_html.content = self._render_table() + self.table_html.update() + + def _render_table(self) -> str: + rows = [self._row_html(option) for option in self.options] + return f""" +
+ + + + + + + + + + + + {''.join(rows) if rows else self._empty_row()} +
OptionDeltaGammaThetaVegaRho
+
+ """ + + def _row_html(self, option: OptionContract | dict[str, Any]) -> str: + if isinstance(option, OptionContract): + label = f"{option.option_type.upper()} {option.strike:.2f}" + greeks = { + "delta": option.greeks.delta, + "gamma": option.greeks.gamma, + "theta": option.greeks.theta, + "vega": option.greeks.vega, + "rho": option.greeks.rho, + } + else: + label = str(option.get("label") or option.get("symbol") or option.get("name") or "Option") + greeks = { + greek: float(option.get(greek, option.get("greeks", {}).get(greek, 0.0))) + for greek in ("delta", "gamma", "theta", "vega", "rho") + } + + cells = "".join( + f'{value:+.4f}' + for name, value in greeks.items() + ) + return ( + '' + f'{label}' + f'{cells}' + '' + ) + + @staticmethod + def _risk_class(name: str, value: float) -> str: + magnitude = abs(value) + if name == "gamma": + if magnitude >= 0.08: + return "text-rose-600 dark:text-rose-400" + if magnitude >= 0.04: + return "text-amber-600 dark:text-amber-400" + return "text-emerald-600 dark:text-emerald-400" + if name == "theta": + if value <= -0.08: + return "text-rose-600 dark:text-rose-400" + if value <= -0.03: + return "text-amber-600 dark:text-amber-400" + return "text-emerald-600 dark:text-emerald-400" + if magnitude >= 0.6: + return "text-rose-600 dark:text-rose-400" + if magnitude >= 0.3: + return "text-amber-600 dark:text-amber-400" + return "text-emerald-600 dark:text-emerald-400" + + @staticmethod + def _empty_row() -> str: + return ( + '' + 'No options selected' + '' + ) diff --git a/app/components/portfolio_view.py b/app/components/portfolio_view.py new file mode 100644 index 0000000..71e7473 --- /dev/null +++ b/app/components/portfolio_view.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Any + +from nicegui import ui + + +class PortfolioOverview: + """Portfolio summary card with LTV risk coloring and margin warning.""" + + def __init__(self, *, margin_call_ltv: float = 0.75) -> None: + self.margin_call_ltv = margin_call_ltv + + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.row().classes("w-full items-center justify-between"): + ui.label("Portfolio Overview").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + self.warning_badge = ui.label("Monitoring").classes( + "rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-500/15 dark:text-amber-300" + ) + + with ui.grid(columns=2).classes("w-full gap-4 max-sm:grid-cols-1"): + self.gold_value = self._metric_card("Current Gold Value") + self.loan_amount = self._metric_card("Loan Amount") + self.ltv = self._metric_card("Current LTV") + self.net_equity = self._metric_card("Net Equity") + + def _metric_card(self, label: str) -> ui.label: + with ui.card().classes("rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"): + ui.label(label).classes("text-sm font-medium text-slate-500 dark:text-slate-400") + return ui.label("--").classes("text-2xl font-bold text-slate-900 dark:text-slate-50") + + def update(self, portfolio: dict[str, Any], *, margin_call_ltv: float | None = None) -> None: + threshold = margin_call_ltv if margin_call_ltv is not None else portfolio.get("margin_call_ltv", self.margin_call_ltv) + gold_value = float(portfolio.get("gold_value", portfolio.get("portfolio_value", 0.0))) + loan_amount = float(portfolio.get("loan_amount", 0.0)) + current_ltv = float(portfolio.get("ltv_ratio", portfolio.get("current_ltv", 0.0))) + net_equity = float(portfolio.get("net_equity", gold_value - loan_amount)) + + self.gold_value.set_text(self._money(gold_value)) + self.loan_amount.set_text(self._money(loan_amount)) + self.net_equity.set_text(self._money(net_equity)) + self.ltv.set_text(f"{current_ltv * 100:.1f}%") + self.ltv.style(f"color: {self._ltv_color(current_ltv, threshold)}") + + badge_text, badge_style = self._warning_state(current_ltv, threshold) + self.warning_badge.set_text(badge_text) + self.warning_badge.style(badge_style) + + @staticmethod + def _money(value: float) -> str: + return f"${value:,.2f}" + + @staticmethod + def _ltv_color(ltv: float, threshold: float) -> str: + if ltv >= threshold: + return "#f43f5e" + if ltv >= threshold * 0.9: + return "#f59e0b" + return "#22c55e" + + @staticmethod + def _warning_state(ltv: float, threshold: float) -> tuple[str, str]: + base = "border-radius: 9999px; padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 600;" + if ltv >= threshold: + return ("Margin call risk", base + " background: rgba(244, 63, 94, 0.14); color: #f43f5e;") + if ltv >= threshold * 0.9: + return ("Approaching threshold", base + " background: rgba(245, 158, 11, 0.14); color: #f59e0b;") + return ("Healthy collateral", base + " background: rgba(34, 197, 94, 0.14); color: #22c55e;") diff --git a/app/components/strategy_panel.py b/app/components/strategy_panel.py new file mode 100644 index 0000000..c3d535f --- /dev/null +++ b/app/components/strategy_panel.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from typing import Any + +from nicegui import ui + + +class StrategyComparisonPanel: + """Interactive strategy comparison with scenario slider and cost-benefit table.""" + + def __init__(self, strategies: list[dict[str, Any]] | None = None, *, current_spot: float = 100.0) -> None: + self.strategies = strategies or [] + self.current_spot = current_spot + self.price_change_pct = 0 + self.strategy_cards: list[ui.html] = [] + + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label("Strategy Comparison").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + + with ui.row().classes("w-full items-center justify-between gap-4 max-sm:flex-col max-sm:items-start"): + self.slider_label = ui.label(self._slider_text()).classes("text-sm text-slate-500 dark:text-slate-400") + self.scenario_spot = ui.label(self._scenario_spot_text()).classes( + "rounded-full bg-sky-100 px-3 py-1 text-sm font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-300" + ) + + ui.slider(min=-50, max=50, value=0, step=5, on_change=self._on_slider_change).classes("w-full") + + with ui.row().classes("w-full gap-4 max-lg:flex-col"): + self.cards_container = ui.row().classes("w-full gap-4 max-lg:flex-col") + + ui.separator().classes("my-2") + ui.label("Cost / Benefit Summary").classes("text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400") + self.table_html = ui.html("").classes("w-full") + + self.set_strategies(self.strategies, current_spot=current_spot) + + def _on_slider_change(self, event: Any) -> None: + self.price_change_pct = int(event.value) + self.slider_label.set_text(self._slider_text()) + self.scenario_spot.set_text(self._scenario_spot_text()) + self._render() + + def set_strategies(self, strategies: list[dict[str, Any]], *, current_spot: float | None = None) -> None: + self.strategies = strategies + if current_spot is not None: + self.current_spot = current_spot + self._render() + + def _render(self) -> None: + self.cards_container.clear() + self.strategy_cards.clear() + with self.cards_container: + for strategy in self.strategies: + self.strategy_cards.append(ui.html(self._strategy_card_html(strategy)).classes("w-full")) + self.table_html.content = self._table_html() + self.table_html.update() + + def _scenario_spot(self) -> float: + return self.current_spot * (1 + self.price_change_pct / 100) + + def _slider_text(self) -> str: + sign = "+" if self.price_change_pct > 0 else "" + return f"Scenario slider: {sign}{self.price_change_pct}% gold price change" + + def _scenario_spot_text(self) -> str: + return f"Scenario spot: ${self._scenario_spot():,.2f}" + + def _strategy_card_html(self, strategy: dict[str, Any]) -> str: + name = str(strategy.get("name", "strategy")).replace("_", " ").title() + description = strategy.get("description", "") + cost = float(strategy.get("estimated_cost", 0.0)) + payoff = self._scenario_benefit(strategy) + payoff_class = "text-emerald-600 dark:text-emerald-400" if payoff >= 0 else "text-rose-600 dark:text-rose-400" + return f""" +
+
+
+
{name}
+
{description}
+
+
Live Scenario
+
+
+
+
Est. Cost
+
${cost:,.2f}
+
+
+
Scenario Benefit
+
${payoff:,.2f}
+
+
+
+ """ + + def _table_html(self) -> str: + rows = [] + for strategy in self.strategies: + name = str(strategy.get("name", "strategy")).replace("_", " ").title() + cost = float(strategy.get("estimated_cost", 0.0)) + floor = strategy.get("max_drawdown_floor", "—") + cap = strategy.get("upside_cap", "—") + scenario = self._scenario_benefit(strategy) + scenario_class = "text-emerald-600 dark:text-emerald-400" if scenario >= 0 else "text-rose-600 dark:text-rose-400" + rows.append( + f""" + + {name} + ${cost:,.2f} + {self._fmt_optional_money(floor)} + {self._fmt_optional_money(cap)} + ${scenario:,.2f} + + """ + ) + return f""" +
+ + + + + + + + + + + {''.join(rows) if rows else self._empty_row()} +
StrategyEstimated CostProtection FloorUpside CapScenario Benefit
+
+ """ + + def _scenario_benefit(self, strategy: dict[str, Any]) -> float: + scenario_spot = self._scenario_spot() + cost = float(strategy.get("estimated_cost", 0.0)) + floor = strategy.get("max_drawdown_floor") + cap = strategy.get("upside_cap") + benefit = -cost + + if isinstance(floor, (int, float)) and scenario_spot < float(floor): + benefit += float(floor) - scenario_spot + if isinstance(cap, (int, float)) and scenario_spot > float(cap): + benefit -= scenario_spot - float(cap) + return benefit + + @staticmethod + def _fmt_optional_money(value: Any) -> str: + if isinstance(value, (int, float)): + return f"${float(value):,.2f}" + return "—" + + @staticmethod + def _empty_row() -> str: + return ( + '' + 'No strategies loaded' + '' + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..efc832f --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,21 @@ +"""Core domain and pricing utilities.""" + +from .calculations import ( + loan_to_value, + ltv_scenarios, + margin_call_price, + net_equity, + option_payoff, + portfolio_net_equity, + strategy_payoff, +) + +__all__ = [ + "loan_to_value", + "ltv_scenarios", + "margin_call_price", + "net_equity", + "option_payoff", + "portfolio_net_equity", + "strategy_payoff", +] diff --git a/app/core/calculations.py b/app/core/calculations.py new file mode 100644 index 0000000..ed6ac0e --- /dev/null +++ b/app/core/calculations.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Iterable + +from app.models.option import OptionContract +from app.models.portfolio import LombardPortfolio +from app.models.strategy import HedgingStrategy + + +def margin_call_price(gold_ounces: float, loan_amount: float, margin_call_ltv: float) -> float: + """Calculate the gold price per ounce that triggers a margin call.""" + if gold_ounces <= 0: + raise ValueError("gold_ounces must be positive") + if loan_amount < 0: + raise ValueError("loan_amount must be non-negative") + if not 0 < margin_call_ltv < 1: + raise ValueError("margin_call_ltv must be between 0 and 1") + return loan_amount / (margin_call_ltv * gold_ounces) + + +def loan_to_value(loan_amount: float, collateral_value: float) -> float: + """Calculate the loan-to-value ratio.""" + if loan_amount < 0: + raise ValueError("loan_amount must be non-negative") + if collateral_value <= 0: + raise ValueError("collateral_value must be positive") + return loan_amount / collateral_value + + +def ltv_scenarios(portfolio: LombardPortfolio, gold_prices: Iterable[float]) -> dict[float, float]: + """Return LTV values for a collection of gold-price scenarios.""" + scenarios: dict[float, float] = {} + for price in gold_prices: + if price <= 0: + raise ValueError("scenario gold prices must be positive") + scenarios[price] = portfolio.ltv_at_price(price) + if not scenarios: + raise ValueError("gold_prices must contain at least one scenario") + return scenarios + + +def option_payoff(contracts: Iterable[OptionContract], underlying_price: float, *, short: bool = False) -> float: + """Aggregate expiry payoff across option contracts.""" + if underlying_price <= 0: + raise ValueError("underlying_price must be positive") + payoff = sum(contract.payoff(underlying_price) for contract in contracts) + return -payoff if short else payoff + + +def strategy_payoff(strategy: HedgingStrategy, underlying_price: float) -> float: + """Net option payoff before premium cost for a hedging strategy.""" + return strategy.gross_payoff(underlying_price) + + +def net_equity( + gold_ounces: float, + gold_price_per_ounce: float, + loan_amount: float, + hedge_cost: float = 0.0, + option_payoff_value: float = 0.0, +) -> float: + """Calculate net equity after debt and hedging effects. + + Formula: + ``gold_value - loan_amount - hedge_cost + option_payoff`` + """ + if gold_ounces <= 0: + raise ValueError("gold_ounces must be positive") + if gold_price_per_ounce <= 0: + raise ValueError("gold_price_per_ounce must be positive") + if loan_amount < 0: + raise ValueError("loan_amount must be non-negative") + if hedge_cost < 0: + raise ValueError("hedge_cost must be non-negative") + + gold_value = gold_ounces * gold_price_per_ounce + return gold_value - loan_amount - hedge_cost + option_payoff_value + + +def portfolio_net_equity( + portfolio: LombardPortfolio, + gold_price_per_ounce: float | None = None, + strategy: HedgingStrategy | None = None, +) -> float: + """Calculate scenario net equity for a portfolio with an optional hedge.""" + scenario_price = portfolio.gold_price_per_ounce if gold_price_per_ounce is None else gold_price_per_ounce + if scenario_price <= 0: + raise ValueError("gold_price_per_ounce must be positive") + + payoff_value = strategy.gross_payoff(scenario_price) if strategy is not None else 0.0 + hedge_cost = strategy.hedge_cost if strategy is not None else 0.0 + return net_equity( + gold_ounces=portfolio.gold_ounces, + gold_price_per_ounce=scenario_price, + loan_amount=portfolio.loan_amount, + hedge_cost=hedge_cost, + option_payoff_value=payoff_value, + ) diff --git a/app/core/pricing/__init__.py b/app/core/pricing/__init__.py new file mode 100644 index 0000000..f4e81a8 --- /dev/null +++ b/app/core/pricing/__init__.py @@ -0,0 +1,58 @@ +"""Core options pricing utilities for the Vault dashboard. + +This package provides pricing helpers for: +- European Black-Scholes valuation +- American option pricing via binomial trees when QuantLib is installed +- Implied volatility inversion when QuantLib is installed + +Research defaults are based on the Vault hedging paper: +- Gold price: 4,600 USD/oz +- GLD price: 460 USD/share +- Risk-free rate: 4.5% +- Volatility: 16% annualized +- GLD dividend yield: 0% +""" + +from .black_scholes import ( + DEFAULT_GLD_PRICE, + DEFAULT_GOLD_PRICE_PER_OUNCE, + DEFAULT_RISK_FREE_RATE, + DEFAULT_VOLATILITY, + BlackScholesInputs, + HedgingCost, + PricingResult, + annual_hedging_cost, + black_scholes_price_and_greeks, + margin_call_threshold_price, +) + +__all__ = [ + "DEFAULT_GLD_PRICE", + "DEFAULT_GOLD_PRICE_PER_OUNCE", + "DEFAULT_RISK_FREE_RATE", + "DEFAULT_VOLATILITY", + "BlackScholesInputs", + "HedgingCost", + "PricingResult", + "annual_hedging_cost", + "black_scholes_price_and_greeks", + "margin_call_threshold_price", +] + +try: # pragma: no cover - optional QuantLib modules + from .american_pricing import AmericanOptionInputs, AmericanPricingResult, american_option_price_and_greeks + from .volatility import implied_volatility +except ImportError: # pragma: no cover - optional dependency + AmericanOptionInputs = None + AmericanPricingResult = None + american_option_price_and_greeks = None + implied_volatility = None +else: + __all__.extend( + [ + "AmericanOptionInputs", + "AmericanPricingResult", + "american_option_price_and_greeks", + "implied_volatility", + ] + ) diff --git a/app/core/pricing/american_pricing.py b/app/core/pricing/american_pricing.py new file mode 100644 index 0000000..0fb1222 --- /dev/null +++ b/app/core/pricing/american_pricing.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Literal + +import QuantLib as ql + +OptionType = Literal["call", "put"] + +DEFAULT_RISK_FREE_RATE: float = 0.045 +DEFAULT_VOLATILITY: float = 0.16 +DEFAULT_DIVIDEND_YIELD: float = 0.0 +DEFAULT_GLD_PRICE: float = 460.0 + + +@dataclass(frozen=True) +class AmericanOptionInputs: + """Inputs for American option pricing via a binomial tree. + + This module is intended primarily for GLD protective puts, where early + exercise can matter in stressed scenarios. + + Example: + >>> params = AmericanOptionInputs( + ... spot=460.0, + ... strike=420.0, + ... time_to_expiry=0.5, + ... option_type="put", + ... ) + >>> params.steps + 500 + """ + + spot: float = DEFAULT_GLD_PRICE + strike: float = DEFAULT_GLD_PRICE + time_to_expiry: float = 0.5 + risk_free_rate: float = DEFAULT_RISK_FREE_RATE + volatility: float = DEFAULT_VOLATILITY + option_type: OptionType = "put" + dividend_yield: float = DEFAULT_DIVIDEND_YIELD + steps: int = 500 + valuation_date: date | None = None + tree: str = "crr" + + +@dataclass(frozen=True) +class AmericanPricingResult: + """American option price and finite-difference Greeks.""" + + price: float + delta: float + gamma: float + theta: float + vega: float + rho: float + + +def _validate_option_type(option_type: str) -> OptionType: + option = option_type.lower() + if option not in {"call", "put"}: + raise ValueError("option_type must be either 'call' or 'put'") + return option # type: ignore[return-value] + + +def _to_quantlib_option_type(option_type: OptionType) -> ql.Option.Type: + return ql.Option.Call if option_type == "call" else ql.Option.Put + + +def _build_dates(time_to_expiry: float, valuation_date: date | None) -> tuple[ql.Date, ql.Date]: + if time_to_expiry <= 0.0: + raise ValueError("time_to_expiry must be positive") + + valuation = valuation_date or date.today() + maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365))) + return ( + ql.Date(valuation.day, valuation.month, valuation.year), + ql.Date(maturity.day, maturity.month, maturity.year), + ) + + +def _american_price( + params: AmericanOptionInputs, + *, + spot: float | None = None, + risk_free_rate: float | None = None, + volatility: float | None = None, + time_to_expiry: float | None = None, +) -> float: + option_type = _validate_option_type(params.option_type) + used_spot = params.spot if spot is None else spot + used_rate = params.risk_free_rate if risk_free_rate is None else risk_free_rate + used_vol = params.volatility if volatility is None else volatility + used_time = params.time_to_expiry if time_to_expiry is None else time_to_expiry + + if used_spot <= 0 or used_vol <= 0 or used_time <= 0: + raise ValueError("spot, volatility, and time_to_expiry must be positive") + if params.steps < 10: + raise ValueError("steps must be at least 10 for binomial pricing") + + valuation_ql, maturity_ql = _build_dates(used_time, params.valuation_date) + ql.Settings.instance().evaluationDate = valuation_ql + + day_count = ql.Actual365Fixed() + calendar = ql.NullCalendar() + + spot_handle = ql.QuoteHandle(ql.SimpleQuote(used_spot)) + dividend_curve = ql.YieldTermStructureHandle( + ql.FlatForward(valuation_ql, params.dividend_yield, day_count) + ) + risk_free_curve = ql.YieldTermStructureHandle( + ql.FlatForward(valuation_ql, used_rate, day_count) + ) + volatility_curve = ql.BlackVolTermStructureHandle( + ql.BlackConstantVol(valuation_ql, calendar, used_vol, day_count) + ) + + process = ql.BlackScholesMertonProcess( + spot_handle, + dividend_curve, + risk_free_curve, + volatility_curve, + ) + + payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), params.strike) + exercise = ql.AmericanExercise(valuation_ql, maturity_ql) + option = ql.VanillaOption(payoff, exercise) + option.setPricingEngine(ql.BinomialVanillaEngine(process, params.tree, params.steps)) + return float(option.NPV()) + + +def american_option_price_and_greeks(params: AmericanOptionInputs) -> AmericanPricingResult: + """Price an American option and estimate Greeks with finite differences. + + Notes: + - The price uses a QuantLib binomial tree engine. + - Greeks are finite-difference approximations because closed-form + American Greeks are not available in general. + - Theta is annualized and approximated by rolling one calendar day forward. + + Args: + params: American option inputs. + + Returns: + A price and finite-difference Greeks. + + Example: + >>> params = AmericanOptionInputs( + ... spot=460.0, + ... strike=400.0, + ... time_to_expiry=0.5, + ... risk_free_rate=0.045, + ... volatility=0.16, + ... option_type="put", + ... ) + >>> result = american_option_price_and_greeks(params) + >>> result.price > 0 + True + """ + + base_price = _american_price(params) + + spot_bump = max(0.01, params.spot * 0.01) + vol_bump = 0.01 + rate_bump = 0.0001 + dt = 1.0 / 365.0 + + price_up = _american_price(params, spot=params.spot + spot_bump) + price_down = _american_price(params, spot=max(1e-8, params.spot - spot_bump)) + delta = (price_up - price_down) / (2.0 * spot_bump) + gamma = (price_up - 2.0 * base_price + price_down) / (spot_bump**2) + + vega_up = _american_price(params, volatility=params.volatility + vol_bump) + vega_down = _american_price(params, volatility=max(1e-6, params.volatility - vol_bump)) + vega = (vega_up - vega_down) / (2.0 * vol_bump) + + rho_up = _american_price(params, risk_free_rate=params.risk_free_rate + rate_bump) + rho_down = _american_price(params, risk_free_rate=params.risk_free_rate - rate_bump) + rho = (rho_up - rho_down) / (2.0 * rate_bump) + + if params.time_to_expiry <= dt: + theta = 0.0 + else: + shorter_price = _american_price(params, time_to_expiry=params.time_to_expiry - dt) + theta = (shorter_price - base_price) / dt + + return AmericanPricingResult( + price=base_price, + delta=delta, + gamma=gamma, + theta=theta, + vega=vega, + rho=rho, + ) diff --git a/app/core/pricing/black_scholes.py b/app/core/pricing/black_scholes.py new file mode 100644 index 0000000..f78be61 --- /dev/null +++ b/app/core/pricing/black_scholes.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +import math +from typing import Any, Literal + +try: # pragma: no cover - optional dependency + import QuantLib as ql +except ImportError: # pragma: no cover - optional dependency + ql = None + +OptionType = Literal["call", "put"] + +DEFAULT_GOLD_PRICE_PER_OUNCE: float = 4600.0 +DEFAULT_GLD_PRICE: float = 460.0 +DEFAULT_RISK_FREE_RATE: float = 0.045 +DEFAULT_VOLATILITY: float = 0.16 +DEFAULT_DIVIDEND_YIELD: float = 0.0 + + +@dataclass(frozen=True) +class BlackScholesInputs: + """Inputs for European Black-Scholes pricing.""" + + spot: float = DEFAULT_GLD_PRICE + strike: float = DEFAULT_GLD_PRICE + time_to_expiry: float = 0.25 + risk_free_rate: float = DEFAULT_RISK_FREE_RATE + volatility: float = DEFAULT_VOLATILITY + option_type: OptionType = "put" + dividend_yield: float = DEFAULT_DIVIDEND_YIELD + valuation_date: date | None = None + + +@dataclass(frozen=True) +class PricingResult: + """European option price and Greeks.""" + + price: float + delta: float + gamma: float + theta: float + vega: float + rho: float + + +@dataclass(frozen=True) +class HedgingCost: + """Annualized hedging cost summary.""" + + premium_paid: float + annual_cost_dollars: float + annual_cost_pct: float + + +def _validate_option_type(option_type: str) -> OptionType: + option = option_type.lower() + if option not in {"call", "put"}: + raise ValueError("option_type must be either 'call' or 'put'") + return option # type: ignore[return-value] + + +def _to_quantlib_option_type(option_type: OptionType) -> Any: + if ql is None: + raise RuntimeError("QuantLib is not installed") + return ql.Option.Call if option_type == "call" else ql.Option.Put + + +def _build_dates(time_to_expiry: float, valuation_date: date | None) -> tuple[Any, Any]: + if time_to_expiry <= 0.0: + raise ValueError("time_to_expiry must be positive") + if ql is None: + return (None, None) + + valuation = valuation_date or date.today() + maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365))) + return ( + ql.Date(valuation.day, valuation.month, valuation.year), + ql.Date(maturity.day, maturity.month, maturity.year), + ) + + +def _norm_pdf(value: float) -> float: + return math.exp(-(value**2) / 2.0) / math.sqrt(2.0 * math.pi) + + +def _norm_cdf(value: float) -> float: + return 0.5 * (1.0 + math.erf(value / math.sqrt(2.0))) + + +def _analytic_black_scholes(params: BlackScholesInputs, option_type: OptionType) -> PricingResult: + if params.spot <= 0 or params.strike <= 0 or params.time_to_expiry <= 0 or params.volatility <= 0: + raise ValueError("spot, strike, time_to_expiry, and volatility must be positive") + + t = params.time_to_expiry + sigma = params.volatility + sqrt_t = math.sqrt(t) + d1 = ( + math.log(params.spot / params.strike) + + (params.risk_free_rate - params.dividend_yield + 0.5 * sigma**2) * t + ) / (sigma * sqrt_t) + d2 = d1 - sigma * sqrt_t + disc_r = math.exp(-params.risk_free_rate * t) + disc_q = math.exp(-params.dividend_yield * t) + pdf_d1 = _norm_pdf(d1) + + if option_type == "call": + price = params.spot * disc_q * _norm_cdf(d1) - params.strike * disc_r * _norm_cdf(d2) + delta = disc_q * _norm_cdf(d1) + theta = ( + -(params.spot * disc_q * pdf_d1 * sigma) / (2 * sqrt_t) + - params.risk_free_rate * params.strike * disc_r * _norm_cdf(d2) + + params.dividend_yield * params.spot * disc_q * _norm_cdf(d1) + ) + rho = params.strike * t * disc_r * _norm_cdf(d2) + else: + price = params.strike * disc_r * _norm_cdf(-d2) - params.spot * disc_q * _norm_cdf(-d1) + delta = disc_q * (_norm_cdf(d1) - 1.0) + theta = ( + -(params.spot * disc_q * pdf_d1 * sigma) / (2 * sqrt_t) + + params.risk_free_rate * params.strike * disc_r * _norm_cdf(-d2) + - params.dividend_yield * params.spot * disc_q * _norm_cdf(-d1) + ) + rho = -params.strike * t * disc_r * _norm_cdf(-d2) + + gamma = (disc_q * pdf_d1) / (params.spot * sigma * sqrt_t) + vega = params.spot * disc_q * pdf_d1 * sqrt_t + return PricingResult(price=float(price), delta=float(delta), gamma=float(gamma), theta=float(theta), vega=float(vega), rho=float(rho)) + + +def black_scholes_price_and_greeks(params: BlackScholesInputs) -> PricingResult: + """Price a European option with QuantLib when available, otherwise analytic BSM.""" + + option_type = _validate_option_type(params.option_type) + if ql is None: + return _analytic_black_scholes(params, option_type) + + valuation_ql, maturity_ql = _build_dates(params.time_to_expiry, params.valuation_date) + ql.Settings.instance().evaluationDate = valuation_ql + + day_count = ql.Actual365Fixed() + calendar = ql.NullCalendar() + + spot_handle = ql.QuoteHandle(ql.SimpleQuote(params.spot)) + dividend_curve = ql.YieldTermStructureHandle( + ql.FlatForward(valuation_ql, params.dividend_yield, day_count) + ) + risk_free_curve = ql.YieldTermStructureHandle( + ql.FlatForward(valuation_ql, params.risk_free_rate, day_count) + ) + volatility = ql.BlackVolTermStructureHandle( + ql.BlackConstantVol(valuation_ql, calendar, params.volatility, day_count) + ) + + process = ql.BlackScholesMertonProcess(spot_handle, dividend_curve, risk_free_curve, volatility) + payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), params.strike) + exercise = ql.EuropeanExercise(maturity_ql) + option = ql.VanillaOption(payoff, exercise) + option.setPricingEngine(ql.AnalyticEuropeanEngine(process)) + + return PricingResult( + price=float(option.NPV()), + delta=float(option.delta()), + gamma=float(option.gamma()), + theta=float(option.theta()), + vega=float(option.vega()), + rho=float(option.rho()), + ) + + +def margin_call_threshold_price( + portfolio_value: float, + loan_amount: float, + current_price: float = DEFAULT_GLD_PRICE, + margin_call_ltv: float = 0.75, +) -> float: + """Calculate the underlying price where a margin call is triggered.""" + + if portfolio_value <= 0 or loan_amount <= 0 or current_price <= 0: + raise ValueError("portfolio_value, loan_amount, and current_price must be positive") + if not 0 < margin_call_ltv < 1: + raise ValueError("margin_call_ltv must be between 0 and 1") + + units = portfolio_value / current_price + return loan_amount / (margin_call_ltv * units) + + +def annual_hedging_cost( + premium_per_share: float, + shares_hedged: float, + portfolio_value: float, + hedge_term_years: float, +) -> HedgingCost: + """Annualize the premium cost of a hedging program.""" + + if premium_per_share < 0 or shares_hedged <= 0 or portfolio_value <= 0 or hedge_term_years <= 0: + raise ValueError( + "premium_per_share must be non-negative and shares_hedged, portfolio_value, " + "and hedge_term_years must be positive" + ) + + premium_paid = premium_per_share * shares_hedged + annual_cost_dollars = premium_paid / hedge_term_years + annual_cost_pct = annual_cost_dollars / portfolio_value + return HedgingCost( + premium_paid=premium_paid, + annual_cost_dollars=annual_cost_dollars, + annual_cost_pct=annual_cost_pct, + ) diff --git a/app/core/pricing/volatility.py b/app/core/pricing/volatility.py new file mode 100644 index 0000000..67b9525 --- /dev/null +++ b/app/core/pricing/volatility.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from datetime import date, timedelta +from typing import Literal + +import QuantLib as ql + +OptionType = Literal["call", "put"] + +DEFAULT_RISK_FREE_RATE: float = 0.045 +DEFAULT_VOLATILITY_GUESS: float = 0.16 +DEFAULT_DIVIDEND_YIELD: float = 0.0 + + +def _validate_option_type(option_type: str) -> OptionType: + option = option_type.lower() + if option not in {"call", "put"}: + raise ValueError("option_type must be either 'call' or 'put'") + return option # type: ignore[return-value] + + +def _to_quantlib_option_type(option_type: OptionType) -> ql.Option.Type: + return ql.Option.Call if option_type == "call" else ql.Option.Put + + +def implied_volatility( + option_price: float, + spot: float, + strike: float, + time_to_expiry: float, + risk_free_rate: float = DEFAULT_RISK_FREE_RATE, + option_type: OptionType = "put", + dividend_yield: float = DEFAULT_DIVIDEND_YIELD, + valuation_date: date | None = None, + initial_guess: float = DEFAULT_VOLATILITY_GUESS, + min_vol: float = 1e-4, + max_vol: float = 4.0, + accuracy: float = 1e-8, + max_evaluations: int = 500, +) -> float: + """Invert the Black-Scholes-Merton model to solve for implied volatility. + + Assumptions: + - European option exercise + - Flat rate, dividend, and volatility term structures + - GLD dividend yield defaults to zero + + Args: + option_price: Observed market premium. + spot: Current underlying price. + strike: Option strike price. + time_to_expiry: Time to maturity in years. + risk_free_rate: Annual risk-free rate. + option_type: ``"call"`` or ``"put"``. + dividend_yield: Continuous dividend yield. + valuation_date: Pricing date, defaults to today. + initial_guess: Starting volatility guess used in the pricing process. + min_vol: Lower volatility search bound. + max_vol: Upper volatility search bound. + accuracy: Root-finding tolerance. + max_evaluations: Maximum solver iterations. + + Returns: + The annualized implied volatility as a decimal. + + Example: + >>> vol = implied_volatility( + ... option_price=12.0, + ... spot=460.0, + ... strike=430.0, + ... time_to_expiry=0.5, + ... risk_free_rate=0.045, + ... option_type="put", + ... ) + >>> vol > 0 + True + """ + + if option_price <= 0 or spot <= 0 or strike <= 0 or time_to_expiry <= 0: + raise ValueError("option_price, spot, strike, and time_to_expiry must be positive") + if initial_guess <= 0 or min_vol <= 0 or max_vol <= min_vol: + raise ValueError("invalid volatility bounds or initial_guess") + + option_type = _validate_option_type(option_type) + valuation = valuation_date or date.today() + maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365))) + + valuation_ql = ql.Date(valuation.day, valuation.month, valuation.year) + maturity_ql = ql.Date(maturity.day, maturity.month, maturity.year) + ql.Settings.instance().evaluationDate = valuation_ql + + day_count = ql.Actual365Fixed() + calendar = ql.NullCalendar() + + spot_handle = ql.QuoteHandle(ql.SimpleQuote(spot)) + dividend_curve = ql.YieldTermStructureHandle( + ql.FlatForward(valuation_ql, dividend_yield, day_count) + ) + risk_free_curve = ql.YieldTermStructureHandle( + ql.FlatForward(valuation_ql, risk_free_rate, day_count) + ) + volatility_curve = ql.BlackVolTermStructureHandle( + ql.BlackConstantVol(valuation_ql, calendar, initial_guess, day_count) + ) + + process = ql.BlackScholesMertonProcess( + spot_handle, + dividend_curve, + risk_free_curve, + volatility_curve, + ) + + payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), strike) + exercise = ql.EuropeanExercise(maturity_ql) + option = ql.VanillaOption(payoff, exercise) + option.setPricingEngine(ql.AnalyticEuropeanEngine(process)) + + return float( + option.impliedVolatility( + option_price, + process, + accuracy, + max_evaluations, + min_vol, + max_vol, + ) + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..51862ca --- /dev/null +++ b/app/main.py @@ -0,0 +1,172 @@ +"""FastAPI application entry point with NiceGUI integration.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from nicegui import ui + +from app.api.routes import router as api_router +import app.pages # noqa: F401 +from app.services.cache import CacheService +from app.services.data_service import DataService + +logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class Settings: + app_name: str = "Vault Dashboard" + environment: str = "development" + cors_origins: list[str] | None = None + redis_url: str | None = None + cache_ttl: int = 300 + default_symbol: str = "GLD" + websocket_interval_seconds: int = 5 + nicegui_mount_path: str = "/" + nicegui_storage_secret: str = "vault-dash-dev-secret" + + @classmethod + def load(cls) -> "Settings": + cls._load_dotenv() + origins = os.getenv("CORS_ORIGINS", "*") + return cls( + app_name=os.getenv("APP_NAME", cls.app_name), + environment=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", cls.environment)), + cors_origins=[origin.strip() for origin in origins.split(",") if origin.strip()], + redis_url=os.getenv("REDIS_URL"), + cache_ttl=int(os.getenv("CACHE_TTL", cls.cache_ttl)), + default_symbol=os.getenv("DEFAULT_SYMBOL", cls.default_symbol), + websocket_interval_seconds=int(os.getenv("WEBSOCKET_INTERVAL_SECONDS", cls.websocket_interval_seconds)), + nicegui_mount_path=os.getenv("NICEGUI_MOUNT_PATH", cls.nicegui_mount_path), + nicegui_storage_secret=os.getenv("NICEGUI_STORAGE_SECRET", cls.nicegui_storage_secret), + ) + + @staticmethod + def _load_dotenv() -> None: + try: + from dotenv import load_dotenv + except ImportError: + return + load_dotenv() + + +settings = Settings.load() + + +class ConnectionManager: + def __init__(self) -> None: + self._connections: set[WebSocket] = set() + + async def connect(self, websocket: WebSocket) -> None: + await websocket.accept() + self._connections.add(websocket) + + def disconnect(self, websocket: WebSocket) -> None: + self._connections.discard(websocket) + + async def broadcast_json(self, payload: dict[str, Any]) -> None: + stale: list[WebSocket] = [] + for websocket in self._connections: + try: + await websocket.send_json(payload) + except Exception: + stale.append(websocket) + for websocket in stale: + self.disconnect(websocket) + + @property + def count(self) -> int: + return len(self._connections) + + +async def publish_updates(app: FastAPI) -> None: + try: + while True: + payload = { + "type": "portfolio_update", + "connections": app.state.ws_manager.count, + "portfolio": await app.state.data_service.get_portfolio(app.state.settings.default_symbol), + } + await app.state.ws_manager.broadcast_json(payload) + await asyncio.sleep(app.state.settings.websocket_interval_seconds) + except asyncio.CancelledError: + logger.info("WebSocket publisher stopped") + raise + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.settings = settings + app.state.cache = CacheService(settings.redis_url, default_ttl=settings.cache_ttl) + await app.state.cache.connect() + app.state.data_service = DataService(app.state.cache, default_symbol=settings.default_symbol) + app.state.ws_manager = ConnectionManager() + app.state.publisher_task = asyncio.create_task(publish_updates(app)) + logger.info("Application startup complete") + + try: + yield + finally: + app.state.publisher_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await app.state.publisher_task + await app.state.cache.close() + logger.info("Application shutdown complete") + + +app = FastAPI(title=settings.app_name, lifespan=lifespan) +app.include_router(api_router) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins or ["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/health", tags=["health"]) +async def health(request: Request) -> dict[str, Any]: + return { + "status": "ok", + "environment": request.app.state.settings.environment, + "redis_enabled": request.app.state.cache.enabled, + } + + +@app.websocket("/ws/updates") +async def websocket_updates(websocket: WebSocket) -> None: + manager: ConnectionManager = websocket.app.state.ws_manager + await manager.connect(websocket) + try: + await websocket.send_json({"type": "connected", "message": "Real-time updates enabled"}) + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + finally: + manager.disconnect(websocket) + + +ui.run_with( + app, + mount_path=settings.nicegui_mount_path, + storage_secret=settings.nicegui_storage_secret, + show=False, +) + + +if __name__ in {"__main__", "__mp_main__"}: + import uvicorn + + uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=settings.environment == "development") diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..ddd913c --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,15 @@ +"""Application domain models.""" + +from .option import Greeks, OptionContract, OptionMoneyness +from .portfolio import LombardPortfolio +from .strategy import HedgingStrategy, ScenarioResult, StrategyType + +__all__ = [ + "Greeks", + "HedgingStrategy", + "LombardPortfolio", + "OptionContract", + "OptionMoneyness", + "ScenarioResult", + "StrategyType", +] diff --git a/app/models/option.py b/app/models/option.py new file mode 100644 index 0000000..3ef90d4 --- /dev/null +++ b/app/models/option.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from typing import Literal + +OptionType = Literal["call", "put"] +OptionMoneyness = Literal["ITM", "ATM", "OTM"] + + +@dataclass(frozen=True) +class Greeks: + """Option Greeks container.""" + + delta: float = 0.0 + gamma: float = 0.0 + theta: float = 0.0 + vega: float = 0.0 + rho: float = 0.0 + + +@dataclass(frozen=True) +class OptionContract: + """Vanilla option contract used in hedging strategies. + + Attributes: + option_type: Contract type, either ``"put"`` or ``"call"``. + strike: Strike price. + expiry: Expiration date. + premium: Premium paid or received per unit of underlying. + quantity: Number of contracts or units. + contract_size: Underlying units per contract. + underlying_price: Current underlying spot price for classification. + greeks: Stored option Greeks. + """ + + option_type: OptionType + strike: float + expiry: date + premium: float + quantity: float = 1.0 + contract_size: float = 1.0 + underlying_price: float | None = None + greeks: Greeks = Greeks() + + def __post_init__(self) -> None: + option = self.option_type.lower() + if option not in {"call", "put"}: + raise ValueError("option_type must be either 'call' or 'put'") + object.__setattr__(self, "option_type", option) + + if self.strike <= 0: + raise ValueError("strike must be positive") + if self.premium < 0: + raise ValueError("premium must be non-negative") + if self.quantity <= 0: + raise ValueError("quantity must be positive") + if self.contract_size <= 0: + raise ValueError("contract_size must be positive") + if self.expiry <= date.today(): + raise ValueError("expiry must be in the future") + if self.underlying_price is not None and self.underlying_price <= 0: + raise ValueError("underlying_price must be positive when provided") + + @property + def notional_units(self) -> float: + """Underlying units covered by the contract position.""" + return self.quantity * self.contract_size + + @property + def total_premium(self) -> float: + """Total premium paid or received for the position.""" + return self.premium * self.notional_units + + def classify_moneyness(self, underlying_price: float | None = None, *, atm_tolerance: float = 0.01) -> OptionMoneyness: + """Classify the contract as ITM, ATM, or OTM. + + Args: + underlying_price: Spot price used for classification. Falls back to + ``self.underlying_price``. + atm_tolerance: Relative tolerance around strike treated as at-the-money. + """ + spot = self.underlying_price if underlying_price is None else underlying_price + if spot is None: + raise ValueError("underlying_price must be provided for strategy classification") + if spot <= 0: + raise ValueError("underlying_price must be positive") + if atm_tolerance < 0: + raise ValueError("atm_tolerance must be non-negative") + + relative_gap = abs(spot - self.strike) / self.strike + if relative_gap <= atm_tolerance: + return "ATM" + + if self.option_type == "put": + return "ITM" if self.strike > spot else "OTM" + return "ITM" if self.strike < spot else "OTM" + + def intrinsic_value(self, underlying_price: float) -> float: + """Intrinsic value per underlying unit at a given spot price.""" + if underlying_price <= 0: + raise ValueError("underlying_price must be positive") + if self.option_type == "put": + return max(self.strike - underlying_price, 0.0) + return max(underlying_price - self.strike, 0.0) + + def payoff(self, underlying_price: float) -> float: + """Gross payoff of the option position at expiry.""" + return self.intrinsic_value(underlying_price) * self.notional_units diff --git a/app/models/portfolio.py b/app/models/portfolio.py new file mode 100644 index 0000000..c9f1fe3 --- /dev/null +++ b/app/models/portfolio.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LombardPortfolio: + """Lombard loan portfolio backed by physical gold. + + Attributes: + gold_ounces: Quantity of pledged gold in troy ounces. + gold_price_per_ounce: Current gold spot price per ounce. + loan_amount: Outstanding Lombard loan balance. + initial_ltv: Origination or current reference loan-to-value ratio. + margin_call_ltv: LTV threshold at which a margin call is triggered. + """ + + gold_ounces: float + gold_price_per_ounce: float + loan_amount: float + initial_ltv: float + margin_call_ltv: float + + def __post_init__(self) -> None: + if self.gold_ounces <= 0: + raise ValueError("gold_ounces must be positive") + if self.gold_price_per_ounce <= 0: + raise ValueError("gold_price_per_ounce must be positive") + if self.loan_amount < 0: + raise ValueError("loan_amount must be non-negative") + if not 0 < self.initial_ltv < 1: + raise ValueError("initial_ltv must be between 0 and 1") + if not 0 < self.margin_call_ltv < 1: + raise ValueError("margin_call_ltv must be between 0 and 1") + if self.initial_ltv > self.margin_call_ltv: + raise ValueError("initial_ltv cannot exceed margin_call_ltv") + if self.loan_amount > self.gold_value: + raise ValueError("loan_amount cannot exceed current gold value") + + @property + def gold_value(self) -> float: + """Current market value of pledged gold.""" + return self.gold_ounces * self.gold_price_per_ounce + + @property + def current_ltv(self) -> float: + """Current loan-to-value ratio.""" + return self.loan_amount / self.gold_value + + @property + def net_equity(self) -> float: + """Equity remaining after subtracting the loan from gold value.""" + return self.gold_value - self.loan_amount + + def gold_value_at_price(self, gold_price_per_ounce: float) -> float: + """Gold value under an alternative spot-price scenario.""" + if gold_price_per_ounce <= 0: + raise ValueError("gold_price_per_ounce must be positive") + return self.gold_ounces * gold_price_per_ounce + + def ltv_at_price(self, gold_price_per_ounce: float) -> float: + """Portfolio LTV under an alternative gold-price scenario.""" + return self.loan_amount / self.gold_value_at_price(gold_price_per_ounce) + + def net_equity_at_price(self, gold_price_per_ounce: float) -> float: + """Net equity under an alternative gold-price scenario.""" + return self.gold_value_at_price(gold_price_per_ounce) - self.loan_amount + + def margin_call_price(self) -> float: + """Gold price per ounce at which the portfolio breaches the margin LTV.""" + return self.loan_amount / (self.margin_call_ltv * self.gold_ounces) diff --git a/app/models/strategy.py b/app/models/strategy.py new file mode 100644 index 0000000..e12f2a4 --- /dev/null +++ b/app/models/strategy.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +from .option import OptionContract + +StrategyType = Literal["single_put", "laddered_put", "collar"] + + +@dataclass(frozen=True) +class ScenarioResult: + """Scenario output for a hedging strategy.""" + + underlying_price: float + gross_option_payoff: float + hedge_cost: float + net_option_benefit: float + + +@dataclass(frozen=True) +class HedgingStrategy: + """Collection of option positions representing a hedge. + + Notes: + Premiums on long positions are positive cash outflows. Premiums on + short positions are handled through ``short_contracts`` and reduce the + total hedge cost. + """ + + strategy_type: StrategyType + long_contracts: tuple[OptionContract, ...] = field(default_factory=tuple) + short_contracts: tuple[OptionContract, ...] = field(default_factory=tuple) + description: str = "" + + def __post_init__(self) -> None: + if self.strategy_type not in {"single_put", "laddered_put", "collar"}: + raise ValueError("unsupported strategy_type") + if not self.long_contracts and not self.short_contracts: + raise ValueError("at least one option contract is required") + + if self.strategy_type == "single_put": + if len(self.long_contracts) != 1 or self.long_contracts[0].option_type != "put": + raise ValueError("single_put requires exactly one long put contract") + if self.short_contracts: + raise ValueError("single_put cannot include short contracts") + + if self.strategy_type == "laddered_put": + if len(self.long_contracts) < 2: + raise ValueError("laddered_put requires at least two long put contracts") + if any(contract.option_type != "put" for contract in self.long_contracts): + raise ValueError("laddered_put supports only long put contracts") + if self.short_contracts: + raise ValueError("laddered_put cannot include short contracts") + + if self.strategy_type == "collar": + if not self.long_contracts or not self.short_contracts: + raise ValueError("collar requires both long and short contracts") + if any(contract.option_type != "put" for contract in self.long_contracts): + raise ValueError("collar long leg must be put options") + if any(contract.option_type != "call" for contract in self.short_contracts): + raise ValueError("collar short leg must be call options") + + @property + def hedge_cost(self) -> float: + """Net upfront hedge cost.""" + long_cost = sum(contract.total_premium for contract in self.long_contracts) + short_credit = sum(contract.total_premium for contract in self.short_contracts) + return long_cost - short_credit + + def gross_payoff(self, underlying_price: float) -> float: + """Gross expiry payoff from all option legs.""" + if underlying_price <= 0: + raise ValueError("underlying_price must be positive") + long_payoff = sum(contract.payoff(underlying_price) for contract in self.long_contracts) + short_payoff = sum(contract.payoff(underlying_price) for contract in self.short_contracts) + return long_payoff - short_payoff + + def net_benefit(self, underlying_price: float) -> float: + """Net value added by the hedge after premium cost.""" + return self.gross_payoff(underlying_price) - self.hedge_cost + + def scenario_analysis(self, underlying_prices: list[float] | tuple[float, ...]) -> list[ScenarioResult]: + """Evaluate the hedge across alternative underlying-price scenarios.""" + if not underlying_prices: + raise ValueError("underlying_prices must not be empty") + + results: list[ScenarioResult] = [] + for price in underlying_prices: + if price <= 0: + raise ValueError("scenario prices must be positive") + gross_payoff = self.gross_payoff(price) + results.append( + ScenarioResult( + underlying_price=price, + gross_option_payoff=gross_payoff, + hedge_cost=self.hedge_cost, + net_option_benefit=gross_payoff - self.hedge_cost, + ) + ) + return results diff --git a/app/pages/__init__.py b/app/pages/__init__.py new file mode 100644 index 0000000..df89483 --- /dev/null +++ b/app/pages/__init__.py @@ -0,0 +1,3 @@ +from . import hedge, options, overview, settings + +__all__ = ["overview", "hedge", "options", "settings"] diff --git a/app/pages/common.py b/app/pages/common.py new file mode 100644 index 0000000..cd379e0 --- /dev/null +++ b/app/pages/common.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any, Iterator + +from nicegui import ui + +NAV_ITEMS: list[tuple[str, str, str]] = [ + ("overview", "/", "Overview"), + ("hedge", "/hedge", "Hedge Analysis"), + ("options", "/options", "Options Chain"), + ("settings", "/settings", "Settings"), +] + + +def demo_spot_price() -> float: + return 215.0 + + +def portfolio_snapshot() -> dict[str, float]: + gold_units = 1_000.0 + spot = demo_spot_price() + gold_value = gold_units * spot + loan_amount = 145_000.0 + margin_call_ltv = 0.75 + return { + "gold_value": gold_value, + "loan_amount": loan_amount, + "ltv_ratio": loan_amount / gold_value, + "net_equity": gold_value - loan_amount, + "spot_price": spot, + "margin_call_ltv": margin_call_ltv, + "margin_call_price": loan_amount / (margin_call_ltv * gold_units), + "cash_buffer": 18_500.0, + "hedge_budget": 8_000.0, + } + + +def strategy_catalog() -> list[dict[str, Any]]: + return [ + { + "name": "protective_put", + "label": "Protective Put", + "description": "Full downside protection below the hedge strike with uncapped upside.", + "estimated_cost": 6.25, + "max_drawdown_floor": 210.0, + "coverage": "High", + }, + { + "name": "collar", + "label": "Collar", + "description": "Lower premium by financing puts with covered call upside caps.", + "estimated_cost": 2.10, + "max_drawdown_floor": 208.0, + "upside_cap": 228.0, + "coverage": "Balanced", + }, + { + "name": "laddered_puts", + "label": "Laddered Puts", + "description": "Multiple maturities and strikes reduce roll concentration and smooth protection.", + "estimated_cost": 4.45, + "max_drawdown_floor": 205.0, + "coverage": "Layered", + }, + ] + + +def quick_recommendations() -> list[dict[str, str]]: + portfolio = portfolio_snapshot() + ltv_gap = (portfolio["margin_call_ltv"] - portfolio["ltv_ratio"]) * 100 + return [ + { + "title": "Balanced hedge favored", + "summary": "A collar keeps the current LTV comfortably below the margin threshold while limiting upfront spend.", + "tone": "positive", + }, + { + "title": f"{ltv_gap:.1f} pts LTV headroom", + "summary": "You still have room before a margin trigger, so prefer cost-efficient protection over maximum convexity.", + "tone": "info", + }, + { + "title": "Roll window approaching", + "summary": "Stage long-dated puts now and keep a near-dated layer for event risk over the next quarter.", + "tone": "warning", + }, + ] + + +def option_chain() -> list[dict[str, Any]]: + spot = demo_spot_price() + expiries = ["2026-04-17", "2026-06-19", "2026-09-18"] + strikes = [190.0, 200.0, 210.0, 215.0, 220.0, 230.0] + rows: list[dict[str, Any]] = [] + for expiry in expiries: + for strike in strikes: + distance = (strike - spot) / spot + for option_type in ("put", "call"): + premium_base = 8.2 if option_type == "put" else 7.1 + premium = round(max(1.1, premium_base - abs(distance) * 18 + (0.8 if expiry == "2026-09-18" else 0.0)), 2) + delta = round((0.5 - distance * 1.8) * (-1 if option_type == "put" else 1), 3) + rows.append( + { + "symbol": f"GLD {expiry} {option_type.upper()} {strike:.0f}", + "expiry": expiry, + "type": option_type, + "strike": strike, + "premium": premium, + "bid": round(max(premium - 0.18, 0.5), 2), + "ask": round(premium + 0.18, 2), + "open_interest": int(200 + abs(spot - strike) * 14), + "volume": int(75 + abs(spot - strike) * 8), + "delta": max(-0.95, min(0.95, delta)), + "gamma": round(max(0.012, 0.065 - abs(distance) * 0.12), 3), + "theta": round(-0.014 - abs(distance) * 0.025, 3), + "vega": round(0.09 + max(0.0, 0.24 - abs(distance) * 0.6), 3), + "rho": round((0.04 + abs(distance) * 0.09) * (-1 if option_type == "put" else 1), 3), + } + ) + return rows + + +def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]: + strategy = next((item for item in strategy_catalog() if item["name"] == strategy_name), strategy_catalog()[0]) + spot = demo_spot_price() + floor = float(strategy.get("max_drawdown_floor", spot * 0.95)) + cap = strategy.get("upside_cap") + cost = float(strategy["estimated_cost"]) + + scenario_prices = [round(spot * (1 + pct / 100), 2) for pct in range(-25, 30, 5)] + benefits: list[float] = [] + for price in scenario_prices: + payoff = max(floor - price, 0.0) + if isinstance(cap, (int, float)) and price > float(cap): + payoff -= price - float(cap) + benefits.append(round(payoff - cost, 2)) + + scenario_price = round(spot * (1 + scenario_pct / 100), 2) + unhedged_equity = scenario_price * 1_000 - 145_000.0 + scenario_payoff = max(floor - scenario_price, 0.0) + capped_upside = 0.0 + if isinstance(cap, (int, float)) and scenario_price > float(cap): + capped_upside = -(scenario_price - float(cap)) + hedged_equity = unhedged_equity + scenario_payoff + capped_upside - cost * 1_000 + + waterfall_steps = [ + ("Base equity", round(70_000.0, 2)), + ("Spot move", round((scenario_price - spot) * 1_000, 2)), + ("Option payoff", round(scenario_payoff * 1_000, 2)), + ("Call cap", round(capped_upside * 1_000, 2)), + ("Hedge cost", round(-cost * 1_000, 2)), + ("Net equity", round(hedged_equity, 2)), + ] + + return { + "strategy": strategy, + "scenario_pct": scenario_pct, + "scenario_price": scenario_price, + "scenario_series": [{"price": price, "benefit": benefit} for price, benefit in zip(scenario_prices, benefits, strict=True)], + "waterfall_steps": waterfall_steps, + "unhedged_equity": round(unhedged_equity, 2), + "hedged_equity": round(hedged_equity, 2), + } + + +@contextmanager +def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]: + ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9") + + with ui.column().classes("mx-auto w-full max-w-7xl gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container: + with ui.header(elevated=False).classes("items-center justify-between border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90"): + with ui.row().classes("items-center gap-3"): + ui.icon("shield").classes("text-2xl text-sky-500") + with ui.column().classes("gap-0"): + ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50") + ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400") + with ui.row().classes("items-center gap-2 max-sm:flex-wrap"): + for key, href, label in NAV_ITEMS: + active = key == current + link_classes = ( + "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + + ( + "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" + if active + else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" + ) + ) + ui.link(label, href).classes(link_classes) + + with ui.row().classes("w-full items-end justify-between gap-4 max-md:flex-col max-md:items-start"): + with ui.column().classes("gap-1"): + ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") + ui.label(subtitle).classes("text-slate-500 dark:text-slate-400") + yield container + + +def recommendation_style(tone: str) -> str: + return { + "positive": "border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/30", + "warning": "border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30", + "info": "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/30", + }.get(tone, "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900") diff --git a/app/pages/hedge.py b/app/pages/hedge.py new file mode 100644 index 0000000..b6ec30d --- /dev/null +++ b/app/pages/hedge.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from nicegui import ui + +from app.pages.common import dashboard_page, demo_spot_price, strategy_catalog, strategy_metrics + + +def _cost_benefit_options(metrics: dict) -> dict: + return { + "tooltip": {"trigger": "axis"}, + "xAxis": { + "type": "category", + "data": [f"${point['price']:.0f}" for point in metrics["scenario_series"]], + "name": "GLD spot", + }, + "yAxis": {"type": "value", "name": "Net benefit / oz"}, + "series": [ + { + "type": "bar", + "data": [point["benefit"] for point in metrics["scenario_series"]], + "itemStyle": { + "color": "#0ea5e9", + }, + } + ], + } + + +def _waterfall_options(metrics: dict) -> dict: + steps = metrics["waterfall_steps"] + running = 0.0 + base = [] + values = [] + for index, (_, amount) in enumerate(steps): + if index == 0: + base.append(0) + values.append(amount) + running = amount + elif index == len(steps) - 1: + base.append(0) + values.append(amount) + else: + base.append(running) + values.append(amount) + running += amount + return { + "tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}}, + "xAxis": {"type": "category", "data": [label for label, _ in steps]}, + "yAxis": {"type": "value", "name": "USD"}, + "series": [ + {"type": "bar", "stack": "total", "data": base, "itemStyle": {"color": "rgba(0,0,0,0)"}}, + { + "type": "bar", + "stack": "total", + "data": values, + "itemStyle": { + "color": "#22c55e", + }, + }, + ], + } + + +@ui.page("/hedge") +def hedge_page() -> None: + strategies = strategy_catalog() + strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies} + selected = {"strategy": strategies[0]["name"], "scenario_pct": 0} + + with dashboard_page( + "Hedge Analysis", + "Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.", + "hedge", + ): + with ui.row().classes("w-full gap-6 max-lg:flex-col"): + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label("Strategy Controls").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + selector = ui.select(strategy_map, value=selected["strategy"], label="Strategy selector").classes("w-full") + slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400") + slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full") + ui.label(f"Current spot reference: ${demo_spot_price():,.2f}").classes("text-sm text-slate-500 dark:text-slate-400") + + summary = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900") + + charts_row = ui.row().classes("w-full gap-6 max-lg:flex-col") + with charts_row: + cost_chart = ui.echart(_cost_benefit_options(strategy_metrics(selected["strategy"], selected["scenario_pct"]))).classes("h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900") + waterfall_chart = ui.echart(_waterfall_options(strategy_metrics(selected["strategy"], selected["scenario_pct"]))).classes("h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900") + + def render_summary() -> None: + metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"]) + strategy = metrics["strategy"] + summary.clear() + with summary: + ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + with ui.grid(columns=2).classes("w-full gap-4 max-sm:grid-cols-1"): + cards = [ + ("Scenario spot", f"${metrics['scenario_price']:,.2f}"), + ("Hedge cost", f"${strategy['estimated_cost']:,.2f}/oz"), + ("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"), + ("Hedged equity", f"${metrics['hedged_equity']:,.0f}"), + ] + for label, value in cards: + with ui.card().classes("rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"): + ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100") + ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300") + + cost_chart.options = _cost_benefit_options(metrics) + cost_chart.update() + waterfall_chart.options = _waterfall_options(metrics) + waterfall_chart.update() + + def refresh_from_selector(event) -> None: + selected["strategy"] = event.value + render_summary() + + def refresh_from_slider(event) -> None: + selected["scenario_pct"] = int(event.value) + sign = "+" if selected["scenario_pct"] >= 0 else "" + slider_value.set_text(f"Scenario move: {sign}{selected['scenario_pct']}%") + render_summary() + + selector.on_value_change(refresh_from_selector) + slider.on_value_change(refresh_from_slider) + render_summary() diff --git a/app/pages/options.py b/app/pages/options.py new file mode 100644 index 0000000..d10caa7 --- /dev/null +++ b/app/pages/options.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from nicegui import ui + +from app.components import GreeksTable +from app.pages.common import dashboard_page, option_chain, strategy_catalog + + +@ui.page("/options") +def options_page() -> None: + chain = option_chain() + expiries = sorted({row["expiry"] for row in chain}) + strike_values = sorted({row["strike"] for row in chain}) + selected_expiry = {"value": expiries[0]} + strike_range = {"min": strike_values[0], "max": strike_values[-1]} + selected_strategy = {"value": strategy_catalog()[0]["label"]} + chosen_contracts: list[dict] = [] + + with dashboard_page( + "Options Chain", + "Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.", + "options", + ): + with ui.row().classes("w-full gap-6 max-lg:flex-col"): + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label("Filters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + expiry_select = ui.select(expiries, value=selected_expiry["value"], label="Expiry").classes("w-full") + min_strike = ui.number("Min strike", value=strike_range["min"], min=strike_values[0], max=strike_values[-1], step=5).classes("w-full") + max_strike = ui.number("Max strike", value=strike_range["max"], min=strike_values[0], max=strike_values[-1], step=5).classes("w-full") + strategy_select = ui.select([item["label"] for item in strategy_catalog()], value=selected_strategy["value"], label="Add to hedge strategy").classes("w-full") + + selection_card = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900") + + chain_table = ui.html("").classes("w-full") + greeks = GreeksTable([]) + + def filtered_rows() -> list[dict]: + return [ + row + for row in chain + if row["expiry"] == selected_expiry["value"] and strike_range["min"] <= row["strike"] <= strike_range["max"] + ] + + def render_selection() -> None: + selection_card.clear() + with selection_card: + ui.label("Strategy Integration").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label(f"Target strategy: {selected_strategy['value']}").classes("text-sm text-slate-500 dark:text-slate-400") + if not chosen_contracts: + ui.label("No contracts added yet.").classes("text-sm text-slate-500 dark:text-slate-400") + return + for contract in chosen_contracts[-3:]: + ui.label( + f"{contract['symbol']} · premium ${contract['premium']:.2f} · Δ {contract['delta']:+.3f}" + ).classes("text-sm text-slate-600 dark:text-slate-300") + + def add_to_strategy(contract: dict) -> None: + chosen_contracts.append(contract) + render_selection() + greeks.set_options(chosen_contracts[-6:]) + ui.notify(f"Added {contract['symbol']} to {selected_strategy['value']}", color="positive") + + def render_chain() -> None: + rows = filtered_rows() + chain_table.content = """ +
+ + + + + + + + + + + + + """ + "".join( + f""" + + + + + + + + + """ + for row in rows + ) + ("" if rows else "") + """ + +
ContractTypeStrikeBid / AskGreeksAction
{row['symbol']}{row['type'].upper()}${row['strike']:.2f}${row['bid']:.2f} / ${row['ask']:.2f}Δ {row['delta']:+.3f} · Γ {row['gamma']:.3f} · Θ {row['theta']:+.3f}Use quick-add buttons below
No contracts match the current filter.
+
+ """ + chain_table.update() + quick_add.clear() + with quick_add: + ui.label("Quick add to hedge").classes("text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400") + with ui.row().classes("w-full gap-2 max-sm:flex-col"): + for row in rows[:6]: + ui.button( + f"Add {row['type'].upper()} {row['strike']:.0f}", + on_click=lambda _, contract=row: add_to_strategy(contract), + ).props("outline color=primary") + greeks.set_options(rows[:6]) + + quick_add = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900") + + def update_filters() -> None: + selected_expiry["value"] = expiry_select.value + strike_range["min"] = float(min_strike.value) + strike_range["max"] = float(max_strike.value) + if strike_range["min"] > strike_range["max"]: + strike_range["min"], strike_range["max"] = strike_range["max"], strike_range["min"] + min_strike.value = strike_range["min"] + max_strike.value = strike_range["max"] + render_chain() + + expiry_select.on_value_change(lambda _: update_filters()) + min_strike.on_value_change(lambda _: update_filters()) + max_strike.on_value_change(lambda _: update_filters()) + strategy_select.on_value_change(lambda event: (selected_strategy.__setitem__("value", event.value), render_selection())) + + render_selection() + render_chain() diff --git a/app/pages/overview.py b/app/pages/overview.py new file mode 100644 index 0000000..191b8ca --- /dev/null +++ b/app/pages/overview.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from nicegui import ui + +from app.components import PortfolioOverview +from app.pages.common import dashboard_page, portfolio_snapshot, quick_recommendations, recommendation_style, strategy_catalog + + +@ui.page("/") +@ui.page("/overview") +def overview_page() -> None: + portfolio = portfolio_snapshot() + + with dashboard_page( + "Overview", + "Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", + "overview", + ): + with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): + summary_cards = [ + ("Spot Price", f"${portfolio['spot_price']:,.2f}", "GLD reference price"), + ("Margin Call Price", f"${portfolio['margin_call_price']:,.2f}", "Implied trigger level"), + ("Cash Buffer", f"${portfolio['cash_buffer']:,.0f}", "Available liquidity"), + ("Hedge Budget", f"${portfolio['hedge_budget']:,.0f}", "Approved premium budget"), + ] + for title, value, caption in summary_cards: + with ui.card().classes("rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label(title).classes("text-sm font-medium text-slate-500 dark:text-slate-400") + ui.label(value).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") + ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400") + + portfolio_view = PortfolioOverview(margin_call_ltv=portfolio["margin_call_ltv"]) + portfolio_view.update(portfolio) + + with ui.row().classes("w-full gap-6 max-lg:flex-col"): + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + with ui.row().classes("w-full items-center justify-between"): + ui.label("Current LTV Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label(f"Threshold {portfolio['margin_call_ltv'] * 100:.0f}%").classes( + "rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300" + ) + ui.linear_progress(value=portfolio["ltv_ratio"] / portfolio["margin_call_ltv"], show_value=False).props("color=warning track-color=grey-3 rounded") + ui.label( + f"Current LTV is {portfolio['ltv_ratio'] * 100:.1f}% with a margin buffer of {(portfolio['margin_call_ltv'] - portfolio['ltv_ratio']) * 100:.1f} percentage points." + ).classes("text-sm text-slate-600 dark:text-slate-300") + ui.label( + "Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required." + ).classes("text-sm font-medium text-amber-700 dark:text-amber-300") + + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + for strategy in strategy_catalog(): + with ui.row().classes("w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800"): + with ui.column().classes("gap-1"): + ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100") + ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(f"${strategy['estimated_cost']:.2f}/oz").classes( + "rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700 dark:bg-sky-500/15 dark:text-sky-300" + ) + + ui.label("Quick Strategy Recommendations").classes("text-xl font-semibold text-slate-900 dark:text-slate-100") + with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-1"): + for rec in quick_recommendations(): + with ui.card().classes(f"rounded-2xl border shadow-sm {recommendation_style(rec['tone'])}"): + ui.label(rec["title"]).classes("text-base font-semibold text-slate-900 dark:text-slate-100") + ui.label(rec["summary"]).classes("text-sm text-slate-600 dark:text-slate-300") diff --git a/app/pages/settings.py b/app/pages/settings.py new file mode 100644 index 0000000..c4ef395 --- /dev/null +++ b/app/pages/settings.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from nicegui import ui + +from app.pages.common import dashboard_page + + +@ui.page("/settings") +def settings_page() -> None: + with dashboard_page( + "Settings", + "Configure portfolio assumptions, preferred market data inputs, alert thresholds, and import/export behavior.", + "settings", + ): + with ui.row().classes("w-full gap-6 max-lg:flex-col"): + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label("Portfolio Parameters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + gold_value = ui.number("Gold collateral value", value=215000, min=0, step=1000).classes("w-full") + loan_amount = ui.number("Loan amount", value=145000, min=0, step=1000).classes("w-full") + margin_threshold = ui.number("Margin call LTV", value=0.75, min=0.1, max=0.95, step=0.01).classes("w-full") + hedge_budget = ui.number("Monthly hedge budget", value=8000, min=0, step=500).classes("w-full") + + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + primary_source = ui.select(["yfinance", "ibkr", "alpaca"], value="yfinance", label="Primary source").classes("w-full") + fallback_source = ui.select(["fallback", "yfinance", "manual"], value="fallback", label="Fallback source").classes("w-full") + refresh_interval = ui.number("Refresh interval (seconds)", value=5, min=1, step=1).classes("w-full") + ui.switch("Enable Redis cache", value=True) + + with ui.row().classes("w-full gap-6 max-lg:flex-col"): + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label("Alert Thresholds").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ltv_warning = ui.number("LTV warning level", value=0.70, min=0.1, max=0.95, step=0.01).classes("w-full") + vol_alert = ui.number("Volatility spike alert", value=0.25, min=0.01, max=2.0, step=0.01).classes("w-full") + price_alert = ui.number("Spot drawdown alert (%)", value=7.5, min=0.1, max=50.0, step=0.5).classes("w-full") + email_alerts = ui.switch("Email alerts", value=False) + + with ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"): + ui.label("Export / Import").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + export_format = ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full") + ui.switch("Include scenario history", value=True) + ui.switch("Include option selections", value=True) + ui.button("Import settings", icon="upload").props("outline color=primary") + ui.button("Export settings", icon="download").props("outline color=primary") + + def save_settings() -> None: + status.set_text( + "Saved configuration: " + f"gold=${gold_value.value:,.0f}, loan=${loan_amount.value:,.0f}, margin={margin_threshold.value:.2f}, " + f"primary={primary_source.value}, fallback={fallback_source.value}, refresh={refresh_interval.value}s, " + f"ltv warning={ltv_warning.value:.2f}, vol={vol_alert.value:.2f}, drawdown={price_alert.value:.1f}%, " + f"email alerts={'on' if email_alerts.value else 'off'}, export={export_format.value}." + ) + ui.notify("Settings saved", color="positive") + + with ui.row().classes("w-full items-center justify-between gap-4"): + status = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") + ui.button("Save settings", on_click=save_settings).props("color=primary") diff --git a/app/services/cache.py b/app/services/cache.py new file mode 100644 index 0000000..ce405ad --- /dev/null +++ b/app/services/cache.py @@ -0,0 +1,72 @@ +"""Redis-backed caching utilities with graceful fallback support.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime +from typing import Any + +logger = logging.getLogger(__name__) + +try: + from redis.asyncio import Redis +except ImportError: # pragma: no cover - optional dependency + Redis = None + + +class CacheService: + """Small async cache wrapper around Redis.""" + + def __init__(self, url: str | None, default_ttl: int = 300) -> None: + self.url = url + self.default_ttl = default_ttl + self._client: Redis | None = None + self._enabled = bool(url and Redis) + + @property + def enabled(self) -> bool: + return self._enabled and self._client is not None + + async def connect(self) -> None: + if not self._enabled: + if self.url and Redis is None: + logger.warning("Redis URL configured but redis package is not installed; cache disabled") + return + + try: + self._client = Redis.from_url(self.url, decode_responses=True) + await self._client.ping() + logger.info("Connected to Redis cache") + except Exception as exc: # pragma: no cover - network dependent + logger.warning("Redis unavailable, cache disabled: %s", exc) + self._client = None + + async def close(self) -> None: + if self._client is None: + return + await self._client.aclose() + self._client = None + + async def get_json(self, key: str) -> dict[str, Any] | list[Any] | None: + if self._client is None: + return None + + value = await self._client.get(key) + if value is None: + return None + + return json.loads(value) + + async def set_json(self, key: str, value: Any, ttl: int | None = None) -> None: + if self._client is None: + return + + payload = json.dumps(value, default=self._json_default) + await self._client.set(key, payload, ex=ttl or self.default_ttl) + + @staticmethod + def _json_default(value: Any) -> str: + if isinstance(value, datetime): + return value.isoformat() + raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable") diff --git a/app/services/data_service.py b/app/services/data_service.py new file mode 100644 index 0000000..6b87ce9 --- /dev/null +++ b/app/services/data_service.py @@ -0,0 +1,145 @@ +"""Market data access layer with caching support.""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import UTC, datetime +from typing import Any + +from app.services.cache import CacheService +from app.strategies.engine import StrategySelectionEngine + +logger = logging.getLogger(__name__) + +try: + import yfinance as yf +except ImportError: # pragma: no cover - optional dependency + yf = None + + +class DataService: + """Fetches portfolio and market data, using Redis when available.""" + + def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None: + self.cache = cache + self.default_symbol = default_symbol + + async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]: + ticker = (symbol or self.default_symbol).upper() + cache_key = f"portfolio:{ticker}" + + cached = await self.cache.get_json(cache_key) + if cached: + return cached + + quote = await self.get_quote(ticker) + portfolio = { + "symbol": ticker, + "spot_price": quote["price"], + "portfolio_value": round(quote["price"] * 1000, 2), + "loan_amount": 600_000.0, + "ltv_ratio": round(600_000.0 / max(quote["price"] * 1000, 1), 4), + "updated_at": datetime.now(UTC).isoformat(), + "source": quote["source"], + } + await self.cache.set_json(cache_key, portfolio) + return portfolio + + async def get_quote(self, symbol: str) -> dict[str, Any]: + cache_key = f"quote:{symbol}" + cached = await self.cache.get_json(cache_key) + if cached: + return cached + + quote = await self._fetch_quote(symbol) + await self.cache.set_json(cache_key, quote) + return quote + + async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]: + ticker = (symbol or self.default_symbol).upper() + cache_key = f"options:{ticker}" + + cached = await self.cache.get_json(cache_key) + if cached: + return cached + + quote = await self.get_quote(ticker) + base_price = quote["price"] + options_chain = { + "symbol": ticker, + "updated_at": datetime.now(UTC).isoformat(), + "calls": [ + {"strike": round(base_price * 1.05, 2), "premium": round(base_price * 0.03, 2), "expiry": "2026-06-19"}, + {"strike": round(base_price * 1.10, 2), "premium": round(base_price * 0.02, 2), "expiry": "2026-09-18"}, + ], + "puts": [ + {"strike": round(base_price * 0.95, 2), "premium": round(base_price * 0.028, 2), "expiry": "2026-06-19"}, + {"strike": round(base_price * 0.90, 2), "premium": round(base_price * 0.018, 2), "expiry": "2026-09-18"}, + ], + "source": quote["source"], + } + await self.cache.set_json(cache_key, options_chain) + return options_chain + + async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]: + ticker = (symbol or self.default_symbol).upper() + quote = await self.get_quote(ticker) + engine = StrategySelectionEngine(spot_price=quote["price"] if ticker != "GLD" else 460.0) + + return { + "symbol": ticker, + "updated_at": datetime.now(UTC).isoformat(), + "paper_parameters": { + "portfolio_value": engine.portfolio_value, + "loan_amount": engine.loan_amount, + "margin_call_threshold": engine.margin_call_threshold, + "spot_price": engine.spot_price, + "volatility": engine.volatility, + "risk_free_rate": engine.risk_free_rate, + }, + "strategies": engine.compare_all_strategies(), + "recommendations": { + profile: engine.recommend(profile) + for profile in ("conservative", "balanced", "cost_sensitive") + }, + "sensitivity_analysis": engine.sensitivity_analysis(), + } + + async def _fetch_quote(self, symbol: str) -> dict[str, Any]: + if yf is None: + return self._fallback_quote(symbol, source="fallback") + + try: + ticker = yf.Ticker(symbol) + history = await asyncio.to_thread(ticker.history, period="5d", interval="1d") + if history.empty: + return self._fallback_quote(symbol, source="fallback") + + closes = history["Close"] + last = float(closes.iloc[-1]) + previous = float(closes.iloc[-2]) if len(closes) > 1 else last + change = round(last - previous, 4) + change_percent = round((change / previous) * 100, 4) if previous else 0.0 + return { + "symbol": symbol, + "price": round(last, 4), + "change": change, + "change_percent": change_percent, + "updated_at": datetime.now(UTC).isoformat(), + "source": "yfinance", + } + except Exception as exc: # pragma: no cover - network dependent + logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc) + return self._fallback_quote(symbol, source="fallback") + + @staticmethod + def _fallback_quote(symbol: str, source: str) -> dict[str, Any]: + return { + "symbol": symbol, + "price": 215.0, + "change": 0.0, + "change_percent": 0.0, + "updated_at": datetime.now(UTC).isoformat(), + "source": source, + } diff --git a/app/strategies/__init__.py b/app/strategies/__init__.py new file mode 100644 index 0000000..bc209d1 --- /dev/null +++ b/app/strategies/__init__.py @@ -0,0 +1,17 @@ +from .base import BaseStrategy, StrategyConfig +from .engine import StrategySelectionEngine +from .laddered_put import LadderSpec, LadderedPutStrategy +from .lease import LeaseAnalysisSpec, LeaseStrategy +from .protective_put import ProtectivePutSpec, ProtectivePutStrategy + +__all__ = [ + "BaseStrategy", + "StrategyConfig", + "ProtectivePutSpec", + "ProtectivePutStrategy", + "LadderSpec", + "LadderedPutStrategy", + "LeaseAnalysisSpec", + "LeaseStrategy", + "StrategySelectionEngine", +] diff --git a/app/strategies/base.py b/app/strategies/base.py new file mode 100644 index 0000000..248e344 --- /dev/null +++ b/app/strategies/base.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from app.models.portfolio import LombardPortfolio + + +@dataclass(frozen=True) +class StrategyConfig: + """Common research inputs used by all strategy implementations.""" + + portfolio: LombardPortfolio + spot_price: float + volatility: float + risk_free_rate: float + + +class BaseStrategy(ABC): + """Abstract strategy interface for paper-based hedge analysis.""" + + def __init__(self, config: StrategyConfig) -> None: + self.config = config + + @property + @abstractmethod + def name(self) -> str: # pragma: no cover - interface only + raise NotImplementedError + + @abstractmethod + def calculate_cost(self) -> dict: + raise NotImplementedError + + @abstractmethod + def calculate_protection(self) -> dict: + raise NotImplementedError + + @abstractmethod + def get_scenarios(self) -> list[dict]: + raise NotImplementedError diff --git a/app/strategies/engine.py b/app/strategies/engine.py new file mode 100644 index 0000000..3555808 --- /dev/null +++ b/app/strategies/engine.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from app.core.pricing.black_scholes import DEFAULT_GLD_PRICE, DEFAULT_RISK_FREE_RATE, DEFAULT_VOLATILITY +from app.models.portfolio import LombardPortfolio +from app.strategies.base import BaseStrategy, StrategyConfig +from app.strategies.laddered_put import LadderSpec, LadderedPutStrategy +from app.strategies.lease import LeaseStrategy +from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy + +RiskProfile = Literal["conservative", "balanced", "cost_sensitive"] + +RESEARCH_PORTFOLIO_VALUE = 1_000_000.0 +RESEARCH_LOAN_AMOUNT = 600_000.0 +RESEARCH_MARGIN_CALL_THRESHOLD = 0.75 +RESEARCH_GLD_SPOT = 460.0 +RESEARCH_VOLATILITY = 0.16 +RESEARCH_RISK_FREE_RATE = 0.045 + + +@dataclass(frozen=True) +class StrategySelectionEngine: + """Compare paper strategies and recommend the best fit by risk profile.""" + + portfolio_value: float = RESEARCH_PORTFOLIO_VALUE + loan_amount: float = RESEARCH_LOAN_AMOUNT + margin_call_threshold: float = RESEARCH_MARGIN_CALL_THRESHOLD + spot_price: float = RESEARCH_GLD_SPOT + volatility: float = RESEARCH_VOLATILITY + risk_free_rate: float = RESEARCH_RISK_FREE_RATE + + def _config(self) -> StrategyConfig: + portfolio = LombardPortfolio( + gold_ounces=self.portfolio_value / self.spot_price, + gold_price_per_ounce=self.spot_price, + loan_amount=self.loan_amount, + initial_ltv=self.loan_amount / self.portfolio_value, + margin_call_ltv=self.margin_call_threshold, + ) + return StrategyConfig( + portfolio=portfolio, + spot_price=self.spot_price, + volatility=self.volatility, + risk_free_rate=self.risk_free_rate, + ) + + def _strategies(self) -> list[BaseStrategy]: + config = self._config() + return [ + ProtectivePutStrategy(config, ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12)), + ProtectivePutStrategy(config, ProtectivePutSpec(label="OTM_95", strike_pct=0.95, months=12)), + ProtectivePutStrategy(config, ProtectivePutSpec(label="OTM_90", strike_pct=0.90, months=12)), + LadderedPutStrategy( + config, + LadderSpec(label="50_50_ATM_OTM95", weights=(0.5, 0.5), strike_pcts=(1.0, 0.95), months=12), + ), + LadderedPutStrategy( + config, + LadderSpec(label="33_33_33_ATM_OTM95_OTM90", weights=(1 / 3, 1 / 3, 1 / 3), strike_pcts=(1.0, 0.95, 0.90), months=12), + ), + LeaseStrategy(config), + ] + + def compare_all_strategies(self) -> list[dict]: + comparisons: list[dict] = [] + for strategy in self._strategies(): + cost = strategy.calculate_cost() + protection = strategy.calculate_protection() + scenarios = strategy.get_scenarios() + annual_cost = cost.get("annualized_cost", cost.get("lowest_annual_cost", 0.0)) + protection_ltv = protection.get("hedged_ltv_at_threshold") + if protection_ltv is None: + duration_rows = protection.get("durations", []) + protection_ltv = min((row["hedged_ltv_at_threshold"] for row in duration_rows), default=1.0) + comparisons.append( + { + "name": strategy.name, + "cost": cost, + "protection": protection, + "scenarios": scenarios, + "score_inputs": { + "annual_cost": annual_cost, + "hedged_ltv_at_threshold": protection_ltv, + }, + } + ) + return comparisons + + def recommend(self, risk_profile: RiskProfile = "balanced") -> dict: + comparisons = self.compare_all_strategies() + + def score(item: dict) -> tuple[float, float]: + annual_cost = item["score_inputs"]["annual_cost"] + hedged_ltv = item["score_inputs"]["hedged_ltv_at_threshold"] + if risk_profile == "conservative": + return (hedged_ltv, annual_cost) + if risk_profile == "cost_sensitive": + return (annual_cost, hedged_ltv) + return (hedged_ltv + (annual_cost / self.portfolio_value), annual_cost) + + recommended = min(comparisons, key=score) + return { + "risk_profile": risk_profile, + "recommended_strategy": recommended["name"], + "rationale": { + "portfolio_value": self.portfolio_value, + "loan_amount": self.loan_amount, + "margin_call_threshold": self.margin_call_threshold, + "spot_price": self.spot_price, + "volatility": self.volatility, + "risk_free_rate": self.risk_free_rate, + }, + "comparison_summary": [ + { + "name": item["name"], + "annual_cost": round(item["score_inputs"]["annual_cost"], 2), + "hedged_ltv_at_threshold": round(item["score_inputs"]["hedged_ltv_at_threshold"], 6), + } + for item in comparisons + ], + } + + def sensitivity_analysis(self) -> dict: + results: dict[str, list[dict]] = {"volatility": [], "spot_price": []} + for volatility in (0.12, 0.16, 0.20): + engine = StrategySelectionEngine( + portfolio_value=self.portfolio_value, + loan_amount=self.loan_amount, + margin_call_threshold=self.margin_call_threshold, + spot_price=self.spot_price, + volatility=volatility, + risk_free_rate=self.risk_free_rate, + ) + recommendation = engine.recommend("balanced") + results["volatility"].append( + { + "volatility": volatility, + "recommended_strategy": recommendation["recommended_strategy"], + } + ) + for spot_price in (DEFAULT_GLD_PRICE * 0.9, DEFAULT_GLD_PRICE, DEFAULT_GLD_PRICE * 1.1): + engine = StrategySelectionEngine( + portfolio_value=self.portfolio_value, + loan_amount=self.loan_amount, + margin_call_threshold=self.margin_call_threshold, + spot_price=spot_price, + volatility=DEFAULT_VOLATILITY, + risk_free_rate=DEFAULT_RISK_FREE_RATE, + ) + recommendation = engine.recommend("balanced") + results["spot_price"].append( + { + "spot_price": round(spot_price, 2), + "recommended_strategy": recommendation["recommended_strategy"], + } + ) + return results diff --git a/app/strategies/laddered_put.py b/app/strategies/laddered_put.py new file mode 100644 index 0000000..6c8a0ce --- /dev/null +++ b/app/strategies/laddered_put.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.strategies.base import BaseStrategy, StrategyConfig +from app.strategies.protective_put import DEFAULT_SCENARIO_CHANGES, ProtectivePutSpec, ProtectivePutStrategy + + +@dataclass(frozen=True) +class LadderSpec: + label: str + weights: tuple[float, ...] + strike_pcts: tuple[float, ...] + months: int = 12 + + +class LadderedPutStrategy(BaseStrategy): + """Multi-strike protective put ladder with blended premium and protection analysis.""" + + def __init__(self, config: StrategyConfig, spec: LadderSpec) -> None: + super().__init__(config) + if len(spec.weights) != len(spec.strike_pcts): + raise ValueError("weights and strike_pcts must have the same length") + if abs(sum(spec.weights) - 1.0) > 1e-9: + raise ValueError("weights must sum to 1.0") + self.spec = spec + + @property + def name(self) -> str: + return f"laddered_put_{self.spec.label.lower()}" + + def _legs(self) -> list[tuple[float, ProtectivePutStrategy]]: + legs: list[tuple[float, ProtectivePutStrategy]] = [] + for index, (weight, strike_pct) in enumerate(zip(self.spec.weights, self.spec.strike_pcts, strict=True), start=1): + leg = ProtectivePutStrategy( + self.config, + ProtectivePutSpec(label=f"{self.spec.label}_leg_{index}", strike_pct=strike_pct, months=self.spec.months), + ) + legs.append((weight, leg)) + return legs + + def calculate_cost(self) -> dict: + blended_cost = 0.0 + blended_premium = 0.0 + legs_summary: list[dict] = [] + for weight, leg in self._legs(): + contract = leg.build_contract() + weighted_cost = contract.total_premium * weight + blended_cost += weighted_cost + blended_premium += contract.premium * weight + legs_summary.append( + { + "weight": weight, + "strike": round(contract.strike, 2), + "premium_per_share": round(contract.premium, 4), + "weighted_cost": round(weighted_cost, 2), + } + ) + annualized_cost = blended_cost / (self.spec.months / 12.0) + return { + "strategy": self.name, + "label": self.spec.label, + "legs": legs_summary, + "blended_premium_per_share": round(blended_premium, 4), + "blended_cost": round(blended_cost, 2), + "cost_pct_of_portfolio": round(blended_cost / self.config.portfolio.gold_value, 6), + "annualized_cost": round(annualized_cost, 2), + "annualized_cost_pct": round(annualized_cost / self.config.portfolio.gold_value, 6), + } + + def calculate_protection(self) -> dict: + threshold_price = self.config.portfolio.margin_call_price() + total_payoff = 0.0 + floor_value = 0.0 + leg_protection: list[dict] = [] + for weight, leg in self._legs(): + contract = leg.build_contract() + weighted_payoff = contract.payoff(threshold_price) * weight + total_payoff += weighted_payoff + floor_value += contract.strike * leg.hedge_units * weight + leg_protection.append( + { + "weight": weight, + "strike": round(contract.strike, 2), + "weighted_payoff_at_threshold": round(weighted_payoff, 2), + } + ) + hedged_value_at_threshold = self.config.portfolio.gold_value_at_price(threshold_price) + total_payoff + protected_ltv = self.config.portfolio.loan_amount / hedged_value_at_threshold + return { + "strategy": self.name, + "threshold_price": round(threshold_price, 2), + "portfolio_floor_value": round(floor_value, 2), + "payoff_at_threshold": round(total_payoff, 2), + "unhedged_ltv_at_threshold": round(self.config.portfolio.ltv_at_price(threshold_price), 6), + "hedged_ltv_at_threshold": round(protected_ltv, 6), + "maintains_margin_call_buffer": protected_ltv < self.config.portfolio.margin_call_ltv, + "legs": leg_protection, + } + + def get_scenarios(self) -> list[dict]: + cost = self.calculate_cost()["blended_cost"] + scenarios: list[dict] = [] + for change in DEFAULT_SCENARIO_CHANGES: + price = self.config.spot_price * (1 + change) + if price <= 0: + continue + gold_value = self.config.portfolio.gold_value_at_price(price) + option_payoff = 0.0 + for weight, leg in self._legs(): + option_payoff += leg.build_contract().payoff(price) * weight + hedged_collateral = gold_value + option_payoff + scenarios.append( + { + "price_change_pct": round(change, 2), + "gld_price": round(price, 2), + "gold_value": round(gold_value, 2), + "option_payoff": round(option_payoff, 2), + "hedge_cost": round(cost, 2), + "net_portfolio_value": round(gold_value + option_payoff - cost, 2), + "unhedged_ltv": round(self.config.portfolio.loan_amount / gold_value, 6), + "hedged_ltv": round(self.config.portfolio.loan_amount / hedged_collateral, 6), + "margin_call_without_hedge": (self.config.portfolio.loan_amount / gold_value) + >= self.config.portfolio.margin_call_ltv, + "margin_call_with_hedge": (self.config.portfolio.loan_amount / hedged_collateral) + >= self.config.portfolio.margin_call_ltv, + } + ) + return scenarios diff --git a/app/strategies/lease.py b/app/strategies/lease.py new file mode 100644 index 0000000..38bbedb --- /dev/null +++ b/app/strategies/lease.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from app.strategies.base import BaseStrategy, StrategyConfig +from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy + + +@dataclass(frozen=True) +class LeaseAnalysisSpec: + strike_pct: float = 1.0 + durations_months: tuple[int, ...] = (3, 6, 12, 18, 24) + + +class LeaseStrategy(BaseStrategy): + """LEAPS duration analysis with roll timing and annualized cost comparison.""" + + def __init__(self, config: StrategyConfig, spec: LeaseAnalysisSpec | None = None) -> None: + super().__init__(config) + self.spec = spec or LeaseAnalysisSpec() + + @property + def name(self) -> str: + return "lease_duration_analysis" + + def _protective_put(self, months: int) -> ProtectivePutStrategy: + return ProtectivePutStrategy( + self.config, + ProtectivePutSpec(label=f"LEAPS_{months}M", strike_pct=self.spec.strike_pct, months=months), + ) + + def _duration_rows(self) -> list[dict]: + rows: list[dict] = [] + for months in self.spec.durations_months: + strategy = self._protective_put(months) + cost = strategy.calculate_cost() + rolls_per_year = 12 / months + rows.append( + { + "months": months, + "strike": cost["strike"], + "premium_per_share": cost["premium_per_share"], + "total_cost": cost["total_cost"], + "annualized_cost": cost["annualized_cost"], + "annualized_cost_pct": cost["annualized_cost_pct"], + "rolls_per_year": round(rolls_per_year, 4), + "recommended_roll_month": max(1, months - 1), + } + ) + return rows + + def calculate_cost(self) -> dict: + rows = self._duration_rows() + optimal = min(rows, key=lambda item: item["annualized_cost"]) + return { + "strategy": self.name, + "comparison": rows, + "optimal_duration_months": optimal["months"], + "lowest_annual_cost": optimal["annualized_cost"], + "lowest_annual_cost_pct": optimal["annualized_cost_pct"], + } + + def calculate_protection(self) -> dict: + threshold_price = self.config.portfolio.margin_call_price() + rows: list[dict] = [] + for months in self.spec.durations_months: + strategy = self._protective_put(months) + protection = strategy.calculate_protection() + rows.append( + { + "months": months, + "payoff_at_threshold": protection["payoff_at_threshold"], + "hedged_ltv_at_threshold": protection["hedged_ltv_at_threshold"], + "maintains_margin_call_buffer": protection["maintains_margin_call_buffer"], + } + ) + return { + "strategy": self.name, + "threshold_price": round(threshold_price, 2), + "durations": rows, + } + + def get_scenarios(self) -> list[dict]: + scenarios: list[dict] = [] + for months in self.spec.durations_months: + strategy = self._protective_put(months) + scenarios.append( + { + "months": months, + "annualized_cost": strategy.calculate_cost()["annualized_cost"], + "annualized_cost_pct": strategy.calculate_cost()["annualized_cost_pct"], + "sample_scenarios": strategy.get_scenarios(), + } + ) + return scenarios diff --git a/app/strategies/protective_put.py b/app/strategies/protective_put.py new file mode 100644 index 0000000..eb7dcf3 --- /dev/null +++ b/app/strategies/protective_put.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta + +from app.core.pricing.black_scholes import BlackScholesInputs, black_scholes_price_and_greeks +from app.models.option import Greeks, OptionContract +from app.models.strategy import HedgingStrategy +from app.strategies.base import BaseStrategy, StrategyConfig + +DEFAULT_SCENARIO_CHANGES = (-0.6, -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5) + + +@dataclass(frozen=True) +class ProtectivePutSpec: + label: str + strike_pct: float + months: int = 12 + + +class ProtectivePutStrategy(BaseStrategy): + """Single-leg protective put strategy using ATM or configurable OTM strikes.""" + + def __init__(self, config: StrategyConfig, spec: ProtectivePutSpec) -> None: + super().__init__(config) + self.spec = spec + + @property + def name(self) -> str: + return f"protective_put_{self.spec.label.lower()}" + + @property + def hedge_units(self) -> float: + return self.config.portfolio.gold_value / self.config.spot_price + + @property + def strike(self) -> float: + return self.config.spot_price * self.spec.strike_pct + + @property + def term_years(self) -> float: + return self.spec.months / 12.0 + + def build_contract(self) -> OptionContract: + pricing = black_scholes_price_and_greeks( + BlackScholesInputs( + spot=self.config.spot_price, + strike=self.strike, + time_to_expiry=self.term_years, + risk_free_rate=self.config.risk_free_rate, + volatility=self.config.volatility, + option_type="put", + ) + ) + return OptionContract( + option_type="put", + strike=self.strike, + expiry=date.today() + timedelta(days=max(1, round(365 * self.term_years))), + premium=pricing.price, + quantity=1.0, + contract_size=self.hedge_units, + underlying_price=self.config.spot_price, + greeks=Greeks( + delta=pricing.delta, + gamma=pricing.gamma, + theta=pricing.theta, + vega=pricing.vega, + rho=pricing.rho, + ), + ) + + def build_hedging_strategy(self) -> HedgingStrategy: + return HedgingStrategy( + strategy_type="single_put", + long_contracts=(self.build_contract(),), + description=f"{self.spec.label} protective put", + ) + + def calculate_cost(self) -> dict: + contract = self.build_contract() + total_cost = contract.total_premium + return { + "strategy": self.name, + "label": self.spec.label, + "strike": round(contract.strike, 2), + "strike_pct": self.spec.strike_pct, + "premium_per_share": round(contract.premium, 4), + "total_cost": round(total_cost, 2), + "cost_pct_of_portfolio": round(total_cost / self.config.portfolio.gold_value, 6), + "term_months": self.spec.months, + "annualized_cost": round(total_cost / self.term_years, 2), + "annualized_cost_pct": round((total_cost / self.term_years) / self.config.portfolio.gold_value, 6), + } + + def calculate_protection(self) -> dict: + contract = self.build_contract() + threshold_price = self.config.portfolio.margin_call_price() + payoff_at_threshold = contract.payoff(threshold_price) + hedged_value_at_threshold = self.config.portfolio.gold_value_at_price(threshold_price) + payoff_at_threshold + protected_ltv = self.config.portfolio.loan_amount / hedged_value_at_threshold + floor_value = contract.strike * self.hedge_units + return { + "strategy": self.name, + "threshold_price": round(threshold_price, 2), + "strike": round(contract.strike, 2), + "portfolio_floor_value": round(floor_value, 2), + "unhedged_ltv_at_threshold": round(self.config.portfolio.ltv_at_price(threshold_price), 6), + "hedged_ltv_at_threshold": round(protected_ltv, 6), + "payoff_at_threshold": round(payoff_at_threshold, 2), + "maintains_margin_call_buffer": protected_ltv < self.config.portfolio.margin_call_ltv, + } + + def get_scenarios(self) -> list[dict]: + strategy = self.build_hedging_strategy() + scenarios: list[dict] = [] + for change in DEFAULT_SCENARIO_CHANGES: + price = self.config.spot_price * (1 + change) + if price <= 0: + continue + gold_value = self.config.portfolio.gold_value_at_price(price) + option_payoff = strategy.gross_payoff(price) + hedged_collateral = gold_value + option_payoff + scenarios.append( + { + "price_change_pct": round(change, 2), + "gld_price": round(price, 2), + "gold_value": round(gold_value, 2), + "option_payoff": round(option_payoff, 2), + "hedge_cost": round(strategy.hedge_cost, 2), + "net_portfolio_value": round(gold_value + option_payoff - strategy.hedge_cost, 2), + "unhedged_ltv": round(self.config.portfolio.loan_amount / gold_value, 6), + "hedged_ltv": round(self.config.portfolio.loan_amount / hedged_collateral, 6), + "margin_call_without_hedge": (self.config.portfolio.loan_amount / gold_value) + >= self.config.portfolio.margin_call_ltv, + "margin_call_with_hedge": (self.config.portfolio.loan_amount / hedged_collateral) + >= self.config.portfolio.margin_call_ltv, + } + ) + return scenarios diff --git a/config/.gitignore b/config/.gitignore new file mode 100644 index 0000000..03cdeec --- /dev/null +++ b/config/.gitignore @@ -0,0 +1 @@ +secrets.yaml diff --git a/config/settings.example.yaml b/config/settings.example.yaml new file mode 100644 index 0000000..b2cfc80 --- /dev/null +++ b/config/settings.example.yaml @@ -0,0 +1,26 @@ +app: + name: Vault Dashboard + version: 1.0.0 + host: 0.0.0.0 + port: 8000 + +portfolio: + gold_value: 1000000 + loan_amount: 600000 + ltv_ratio: 0.60 + margin_call_threshold: 0.75 + gold_price: 4600 + +data: + primary_source: yfinance + cache_ttl: 300 + +ibkr: + enabled: false + host: 127.0.0.1 + port: 7497 + client_id: 1 + +alerts: + ltv_warning: 0.70 + ltv_critical: 0.75 diff --git a/config/settings.yaml b/config/settings.yaml new file mode 100644 index 0000000..b2cfc80 --- /dev/null +++ b/config/settings.yaml @@ -0,0 +1,26 @@ +app: + name: Vault Dashboard + version: 1.0.0 + host: 0.0.0.0 + port: 8000 + +portfolio: + gold_value: 1000000 + loan_amount: 600000 + ltv_ratio: 0.60 + margin_call_threshold: 0.75 + gold_price: 4600 + +data: + primary_source: yfinance + cache_ttl: 300 + +ibkr: + enabled: false + host: 127.0.0.1 + port: 7497 + client_id: 1 + +alerts: + ltv_warning: 0.70 + ltv_critical: 0.75 diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml new file mode 100644 index 0000000..f244eb7 --- /dev/null +++ b/docker-compose.deploy.yml @@ -0,0 +1,25 @@ +services: + vault-dash: + image: ${APP_IMAGE} + container_name: vault-dash + restart: unless-stopped + env_file: + - .env + environment: + APP_ENV: ${APP_ENV:-production} + REDIS_URL: ${REDIS_URL:-} + APP_NAME: ${APP_NAME:-Vault Dashboard} + DEFAULT_SYMBOL: ${DEFAULT_SYMBOL:-GLD} + CACHE_TTL: ${CACHE_TTL:-300} + WEBSOCKET_INTERVAL_SECONDS: ${WEBSOCKET_INTERVAL_SECONDS:-5} + NICEGUI_MOUNT_PATH: ${NICEGUI_MOUNT_PATH:-/} + NICEGUI_STORAGE_SECRET: ${NICEGUI_STORAGE_SECRET} + CORS_ORIGINS: ${CORS_ORIGINS:-*} + ports: + - "${APP_BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-8000}:8000" + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..6723326 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,63 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + image: vault-dash:prod + ports: + - "8000:8000" + environment: + APP_ENV: production + APP_HOST: 0.0.0.0 + APP_PORT: 8000 + LOG_LEVEL: INFO + REDIS_URL: redis://redis:6379/0 + CACHE_TTL: 300 + DEFAULT_SYMBOL: GLD + UVICORN_WORKERS: 2 + RUN_MIGRATIONS: 0 + depends_on: + redis: + condition: service_healthy + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: always + mem_limit: 512m + cpus: 1.00 + pids_limit: 256 + healthcheck: + test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3); sys.exit(0)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: always + mem_limit: 256m + cpus: 0.50 + pids_limit: 128 + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + redis-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..340a785 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + image: vault-dash:dev + ports: + - "8000:8000" + environment: + APP_ENV: development + APP_HOST: 0.0.0.0 + APP_PORT: 8000 + LOG_LEVEL: DEBUG + REDIS_URL: redis://redis:6379/0 + CACHE_TTL: 300 + DEFAULT_SYMBOL: GLD + NICEGUI_STORAGE_SECRET: vault-dash-dev-secret + UVICORN_WORKERS: 1 + RUN_MIGRATIONS: 0 + volumes: + - ./:/app + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import sys, urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3); sys.exit(0)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + restart: unless-stopped + + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + restart: unless-stopped + +volumes: + redis-data: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..957240c --- /dev/null +++ b/docs/API.md @@ -0,0 +1,593 @@ +# API Documentation + +## Overview + +Vault Dashboard exposes a small read-only HTTP API plus a WebSocket stream. + +Base capabilities: + +- health monitoring +- portfolio snapshot retrieval +- options chain retrieval +- strategy analysis retrieval +- periodic real-time portfolio updates over WebSocket + +Unless noted otherwise, all responses are JSON. + +--- + +## Conventions + +### Base URL + +Examples: + +```text +http://localhost:8000 +https://vault.example.com +``` + +### Content type + +Responses use: + +```http +Content-Type: application/json +``` + +### Authentication + +There is currently **no application-layer authentication** on these endpoints. + +If the deployment requires restricted access, enforce it at the network or reverse-proxy layer. + +### Errors + +The app currently relies mostly on framework defaults. Typical failures may include: + +- `422 Unprocessable Entity` for invalid query parameters +- `500 Internal Server Error` for unexpected runtime issues + +--- + +## HTTP endpoints + +## 1. Health + +### `GET /health` + +Deployment and uptime check. + +#### Query parameters + +None. + +#### Response schema + +```json +{ + "status": "ok", + "environment": "production", + "redis_enabled": false +} +``` + +#### Field definitions + +- `status` (`string`): expected value is currently `"ok"` +- `environment` (`string`): runtime environment from `APP_ENV` or `ENVIRONMENT` +- `redis_enabled` (`boolean`): `true` when Redis is configured and connected + +#### Example request + +```bash +curl -fsS http://localhost:8000/health +``` + +#### Example response + +```json +{ + "status": "ok", + "environment": "development", + "redis_enabled": false +} +``` + +--- + +## 2. Portfolio + +### `GET /api/portfolio` + +Returns a portfolio snapshot derived from the current quote for a symbol. + +#### Query parameters + +| Name | Type | Required | Default | Description | +|---|---|---:|---|---| +| `symbol` | string | no | `GLD` | Ticker symbol to analyze | + +#### Response schema + +```json +{ + "symbol": "GLD", + "spot_price": 215.0, + "portfolio_value": 215000.0, + "loan_amount": 600000.0, + "ltv_ratio": 2.7907, + "updated_at": "2026-03-21T12:34:56.000000+00:00", + "source": "fallback" +} +``` + +#### Field definitions + +- `symbol` (`string`): requested ticker, uppercased +- `spot_price` (`number`): latest spot/quote price +- `portfolio_value` (`number`): current modeled collateral value, currently `spot_price * 1000` +- `loan_amount` (`number`): modeled loan balance, currently fixed at `600000.0` +- `ltv_ratio` (`number`): `loan_amount / portfolio_value` +- `updated_at` (`string`, ISO 8601): response generation timestamp +- `source` (`string`): quote source such as `yfinance` or `fallback` + +#### Example request + +```bash +curl "http://localhost:8000/api/portfolio?symbol=GLD" +``` + +#### Example response + +```json +{ + "symbol": "GLD", + "spot_price": 215.0, + "portfolio_value": 215000.0, + "loan_amount": 600000.0, + "ltv_ratio": 2.7907, + "updated_at": "2026-03-21T12:34:56.000000+00:00", + "source": "fallback" +} +``` + +--- + +## 3. Options chain + +### `GET /api/options` + +Returns a simplified options chain snapshot for the symbol. + +#### Query parameters + +| Name | Type | Required | Default | Description | +|---|---|---:|---|---| +| `symbol` | string | no | `GLD` | Ticker symbol to analyze | + +#### Response schema + +```json +{ + "symbol": "GLD", + "updated_at": "2026-03-21T12:34:56.000000+00:00", + "calls": [ + { + "strike": 225.75, + "premium": 6.45, + "expiry": "2026-06-19" + } + ], + "puts": [ + { + "strike": 204.25, + "premium": 6.02, + "expiry": "2026-06-19" + } + ], + "source": "fallback" +} +``` + +#### Array item schema + +Each option row in `calls` or `puts` has: + +- `strike` (`number`) +- `premium` (`number`) +- `expiry` (`string`, `YYYY-MM-DD`) + +#### Field definitions + +- `symbol` (`string`): requested ticker, uppercased +- `updated_at` (`string`, ISO 8601): response generation timestamp +- `calls` (`array`): example call rows derived from spot +- `puts` (`array`): example put rows derived from spot +- `source` (`string`): upstream quote source used to derive the chain + +#### Notes + +The current options chain is synthetic. It is not yet a full broker-grade chain feed. + +#### Example request + +```bash +curl "http://localhost:8000/api/options?symbol=GLD" +``` + +--- + +## 4. Strategies + +### `GET /api/strategies` + +Returns strategy comparison data, recommendations by risk profile, and sensitivity analysis. + +#### Query parameters + +| Name | Type | Required | Default | Description | +|---|---|---:|---|---| +| `symbol` | string | no | `GLD` | Ticker symbol to analyze | + +#### Top-level response schema + +```json +{ + "symbol": "GLD", + "updated_at": "2026-03-21T12:34:56.000000+00:00", + "paper_parameters": {}, + "strategies": [], + "recommendations": {}, + "sensitivity_analysis": {} +} +``` + +#### Top-level field definitions + +- `symbol` (`string`): requested ticker, uppercased +- `updated_at` (`string`, ISO 8601): response generation timestamp +- `paper_parameters` (`object`): engine inputs used for the analysis +- `strategies` (`array`): detailed strategy comparison rows +- `recommendations` (`object`): recommendation results keyed by risk profile +- `sensitivity_analysis` (`object`): recommendation changes across parameter shifts + +### 4.1 `paper_parameters` schema + +```json +{ + "portfolio_value": 1000000.0, + "loan_amount": 600000.0, + "margin_call_threshold": 0.75, + "spot_price": 460.0, + "volatility": 0.16, + "risk_free_rate": 0.045 +} +``` + +Fields: + +- `portfolio_value` (`number`) +- `loan_amount` (`number`) +- `margin_call_threshold` (`number`) +- `spot_price` (`number`) +- `volatility` (`number`) +- `risk_free_rate` (`number`) + +### 4.2 `strategies[]` schema + +Each element in `strategies` is produced by `StrategySelectionEngine.compare_all_strategies()`: + +```json +{ + "name": "protective_put_atm", + "cost": {}, + "protection": {}, + "scenarios": [], + "score_inputs": { + "annual_cost": 0.0, + "hedged_ltv_at_threshold": 0.0 + } +} +``` + +Fields: + +- `name` (`string`): internal strategy identifier +- `cost` (`object`): strategy-specific cost payload +- `protection` (`object`): strategy-specific protection payload +- `scenarios` (`array`): scenario analysis rows +- `score_inputs` (`object`): normalized inputs used for recommendation scoring + +#### Protective put cost schema + +Typical `cost` object for `protective_put_*`: + +```json +{ + "strategy": "protective_put_atm", + "label": "ATM", + "strike": 460.0, + "strike_pct": 1.0, + "premium_per_share": 21.1234, + "total_cost": 45920.43, + "cost_pct_of_portfolio": 0.04592, + "term_months": 12, + "annualized_cost": 45920.43, + "annualized_cost_pct": 0.04592 +} +``` + +#### Protective put protection schema + +```json +{ + "strategy": "protective_put_atm", + "threshold_price": 368.0, + "strike": 460.0, + "portfolio_floor_value": 1000000.0, + "unhedged_ltv_at_threshold": 0.75, + "hedged_ltv_at_threshold": 0.652174, + "payoff_at_threshold": 200000.0, + "maintains_margin_call_buffer": true +} +``` + +#### Protective put scenario row schema + +```json +{ + "price_change_pct": -0.2, + "gld_price": 368.0, + "gold_value": 800000.0, + "option_payoff": 200000.0, + "hedge_cost": 45920.43, + "net_portfolio_value": 954079.57, + "unhedged_ltv": 0.75, + "hedged_ltv": 0.6, + "margin_call_without_hedge": true, + "margin_call_with_hedge": false +} +``` + +#### Laddered put cost schema + +Typical `cost` object for `laddered_put_*`: + +```json +{ + "strategy": "laddered_put_50_50_atm_otm95", + "label": "50_50_ATM_OTM95", + "legs": [ + { + "weight": 0.5, + "strike": 460.0, + "premium_per_share": 21.1234, + "weighted_cost": 22960.22 + } + ], + "blended_premium_per_share": 18.4567, + "blended_cost": 40123.45, + "cost_pct_of_portfolio": 0.040123, + "annualized_cost": 40123.45, + "annualized_cost_pct": 0.040123 +} +``` + +#### Laddered put protection schema + +```json +{ + "strategy": "laddered_put_50_50_atm_otm95", + "threshold_price": 368.0, + "portfolio_floor_value": 975000.0, + "payoff_at_threshold": 175000.0, + "unhedged_ltv_at_threshold": 0.75, + "hedged_ltv_at_threshold": 0.615385, + "maintains_margin_call_buffer": true, + "legs": [ + { + "weight": 0.5, + "strike": 460.0, + "weighted_payoff_at_threshold": 100000.0 + } + ] +} +``` + +#### Lease duration analysis cost schema + +Typical `cost` object for `lease_duration_analysis`: + +```json +{ + "strategy": "lease_duration_analysis", + "comparison": [ + { + "months": 3, + "strike": 460.0, + "premium_per_share": 9.1234, + "total_cost": 19833.48, + "annualized_cost": 79333.92, + "annualized_cost_pct": 0.079334, + "rolls_per_year": 4.0, + "recommended_roll_month": 2 + } + ], + "optimal_duration_months": 12, + "lowest_annual_cost": 45920.43, + "lowest_annual_cost_pct": 0.04592 +} +``` + +#### Lease duration analysis protection schema + +```json +{ + "strategy": "lease_duration_analysis", + "threshold_price": 368.0, + "durations": [ + { + "months": 12, + "payoff_at_threshold": 200000.0, + "hedged_ltv_at_threshold": 0.6, + "maintains_margin_call_buffer": true + } + ] +} +``` + +### 4.3 `recommendations` schema + +The object contains keys: + +- `conservative` +- `balanced` +- `cost_sensitive` + +Each recommendation object has this shape: + +```json +{ + "risk_profile": "balanced", + "recommended_strategy": "laddered_put_50_50_atm_otm95", + "rationale": { + "portfolio_value": 1000000.0, + "loan_amount": 600000.0, + "margin_call_threshold": 0.75, + "spot_price": 460.0, + "volatility": 0.16, + "risk_free_rate": 0.045 + }, + "comparison_summary": [ + { + "name": "protective_put_atm", + "annual_cost": 45920.43, + "hedged_ltv_at_threshold": 0.6 + } + ] +} +``` + +### 4.4 `sensitivity_analysis` schema + +```json +{ + "volatility": [ + { + "volatility": 0.12, + "recommended_strategy": "protective_put_otm_95" + } + ], + "spot_price": [ + { + "spot_price": 414.0, + "recommended_strategy": "protective_put_otm_95" + } + ] +} +``` + +--- + +## WebSocket API + +## Endpoint + +### `WS /ws/updates` + +Used for server-pushed real-time updates. + +### Connection lifecycle + +1. Client opens a WebSocket connection to `/ws/updates` +2. Server accepts the connection +3. Server immediately sends a `connected` event +4. Server periodically broadcasts `portfolio_update` events +5. Client may keep the connection alive by sending text frames +6. Server removes the connection when disconnected or on send failure + +### Event: `connected` + +Sent once after successful connection. + +#### Schema + +```json +{ + "type": "connected", + "message": "Real-time updates enabled" +} +``` + +Fields: + +- `type` (`string`): event name +- `message` (`string`): human-readable confirmation + +### Event: `portfolio_update` + +Broadcast on an interval controlled by `WEBSOCKET_INTERVAL_SECONDS`. + +#### Schema + +```json +{ + "type": "portfolio_update", + "connections": 2, + "portfolio": { + "symbol": "GLD", + "spot_price": 215.0, + "portfolio_value": 215000.0, + "loan_amount": 600000.0, + "ltv_ratio": 2.7907, + "updated_at": "2026-03-21T12:34:56.000000+00:00", + "source": "fallback" + } +} +``` + +Fields: + +- `type` (`string`): event name +- `connections` (`integer`): current number of connected WebSocket clients +- `portfolio` (`object`): same schema as `GET /api/portfolio` + +### Example JavaScript client + +```js +const ws = new WebSocket('ws://localhost:8000/ws/updates'); + +ws.onmessage = (event) => { + const payload = JSON.parse(event.data); + console.log(payload.type, payload); +}; + +ws.onopen = () => { + // optional keepalive or ping surrogate + setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send('ping'); + } + }, 10000); +}; +``` + +--- + +## OpenAPI + +Because the app is built on FastAPI, interactive docs are typically available at: + +- `/docs` +- `/redoc` + +This file is the human-oriented reference for payload semantics and current behavior. + +## Notes and limitations + +- Endpoints are read-only today +- There are no POST/PUT/DELETE endpoints yet +- The options chain is currently synthetic +- Strategy outputs are paper-analysis results, not execution instructions +- Symbol validation is minimal and currently delegated to downstream quote behavior diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..01fd461 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,437 @@ +# Architecture + +## Overview + +Vault Dashboard is a FastAPI application with NiceGUI pages for the frontend, a lightweight API layer for market and strategy data, a strategy engine for paper hedge comparisons, and an optional Redis cache. + +At runtime the app exposes: + +- HTML/UI pages via NiceGUI +- REST-style JSON endpoints under `/api` +- a health endpoint at `/health` +- a WebSocket feed at `/ws/updates` + +The system is currently optimized for research, visualization, and paper analysis of Lombard-loan hedging strategies rather than live trade execution. + +--- + +## System components + +### 1. Application entry point + +**File:** `app/main.py` + +Responsibilities: + +- create the FastAPI app +- load environment-driven settings +- configure CORS +- initialize cache and data services during lifespan startup +- start a background publisher task for WebSocket updates +- mount NiceGUI onto the app +- expose `/health` and `/ws/updates` + +### 2. API layer + +**File:** `app/api/routes.py` + +Responsibilities: + +- expose JSON endpoints under `/api` +- resolve the shared `DataService` from application state +- provide read-only portfolio, options, and strategy data + +Current endpoints: + +- `GET /api/portfolio` +- `GET /api/options` +- `GET /api/strategies` + +### 3. UI layer + +**Files:** `app/pages/*.py`, `app/components/*.py` + +Responsibilities: + +- render dashboard pages using NiceGUI +- present charts, tables, strategy views, and scenario widgets +- consume data generated within the app and, in production, align with API/WebSocket-backed state + +Representative pages: + +- `app/pages/overview.py` +- `app/pages/options.py` +- `app/pages/hedge.py` +- `app/pages/settings.py` + +### 4. Data service + +**File:** `app/services/data_service.py` + +Responsibilities: + +- fetch quote data +- build a synthetic options chain response +- build portfolio snapshots +- invoke the strategy engine and shape strategy comparison responses +- cache results when Redis is available + +Data source behavior: + +- primary live quote source: `yfinance` when installed and reachable +- fallback quote source: static fallback data if live fetch fails + +### 5. Cache service + +**File:** `app/services/cache.py` + +Responsibilities: + +- provide async JSON get/set operations +- wrap Redis without making the rest of the app depend directly on Redis primitives +- degrade gracefully when Redis is unavailable + +The app remains functional without Redis; caching is optional. + +### 6. Strategy engine + +**Files:** + +- `app/strategies/engine.py` +- `app/strategies/base.py` +- `app/strategies/protective_put.py` +- `app/strategies/laddered_put.py` +- `app/strategies/lease.py` + +Responsibilities: + +- construct standardized paper strategies for comparison +- calculate cost and protection metrics +- run scenario analysis across price shocks +- recommend a strategy by risk profile +- run simple sensitivity analysis + +### 7. Domain models + +**Files:** + +- `app/models/portfolio.py` +- `app/models/option.py` +- `app/models/strategy.py` + +Responsibilities: + +- represent Lombard-backed portfolios +- represent option contracts and Greeks +- represent multi-leg hedging structures +- enforce validation rules at object boundaries + +### 8. Pricing layer + +**Files:** `app/core/pricing/*.py` + +Responsibilities: + +- compute option prices and Greeks +- support Black-Scholes-based valuation inputs used by the research strategies + +--- + +## High-level data flow + +```mermaid +flowchart TD + A[Browser / NiceGUI Client] -->|HTTP| B[FastAPI + NiceGUI app] + A -->|WebSocket /ws/updates| B + B --> C[API routes] + B --> D[ConnectionManager + background publisher] + C --> E[DataService] + D --> E + E --> F[CacheService] + F -->|optional| G[(Redis)] + E --> H[yfinance] + E --> I[StrategySelectionEngine] + I --> J[ProtectivePutStrategy] + I --> K[LadderedPutStrategy] + I --> L[LeaseStrategy] + J --> M[Pricing + models] + K --> M + L --> M +``` + +### Request/response flow + +1. Client sends an HTTP request to an API endpoint or loads a NiceGUI page +2. FastAPI resolves shared app services from `app.state` +3. `DataService` checks Redis cache first when enabled +4. If cache misses, `DataService` fetches or builds the payload: + - quote via `yfinance` or fallback + - synthetic options chain + - strategy comparison via `StrategySelectionEngine` +5. Response is returned as JSON or used by the UI +6. Background task periodically broadcasts portfolio snapshots over WebSocket + +--- + +## Runtime lifecycle + +### Startup + +When the app starts: + +1. environment variables are loaded into `Settings` +2. `CacheService` is created and attempts Redis connection +3. `DataService` is initialized +4. `ConnectionManager` is initialized +5. background task `publish_updates()` starts +6. NiceGUI is mounted on the FastAPI app + +### Steady state + +During normal operation: + +- API requests are served on demand +- WebSocket clients stay connected to `/ws/updates` +- every `WEBSOCKET_INTERVAL_SECONDS`, the app publishes a fresh portfolio payload +- Redis caches repeated quote/portfolio/options requests when configured + +### Shutdown + +On shutdown: + +- publisher task is cancelled +- cache connection is closed +- FastAPI lifecycle exits cleanly + +--- + +## Strategy engine design + +## Core design goals + +The strategy subsystem is built to compare paper hedging approaches for a Lombard loan secured by gold exposure. It emphasizes: + +- deterministic calculations +- shared configuration across strategies +- comparable output shapes +- easy extension for new strategies + +### Base contract + +**File:** `app/strategies/base.py` + +All strategies implement: + +- `name` +- `calculate_cost()` +- `calculate_protection()` +- `get_scenarios()` + +All strategies receive a shared `StrategyConfig` containing: + +- `portfolio` +- `spot_price` +- `volatility` +- `risk_free_rate` + +### Portfolio construction + +**File:** `app/strategies/engine.py` + +`StrategySelectionEngine` builds a canonical research portfolio using: + +- portfolio value +- loan amount +- margin call threshold +- spot price +- volatility +- risk-free rate + +The engine converts these into a validated `LombardPortfolio`, then instantiates a suite of candidate strategies. + +### Candidate strategies + +Current strategy set: + +1. `protective_put_atm` +2. `protective_put_otm_95` +3. `protective_put_otm_90` +4. `laddered_put_50_50_atm_otm95` +5. `laddered_put_33_33_33_atm_otm95_otm90` +6. `lease_duration_analysis` + +### Strategy outputs + +Each strategy returns three complementary views: + +#### Cost view + +Examples: + +- premium per share +- total hedge cost +- annualized cost +- cost as percentage of portfolio +- weighted leg costs for ladders + +#### Protection view + +Examples: + +- threshold price where margin stress occurs +- payoff at threshold +- hedged LTV at threshold +- whether the strategy maintains a buffer below margin-call LTV +- floor value implied by option strikes + +#### Scenario view + +Examples: + +- underlying price change percentage +- simulated spot price +- unhedged vs hedged LTV +- option payoff +- hedge cost +- net portfolio value +- margin call triggered or avoided + +### Recommendation model + +`StrategySelectionEngine.recommend()` scores strategies using a small heuristic. + +Risk profiles: + +- `conservative`: prioritize lower hedged LTV, then lower annual cost +- `cost_sensitive`: prioritize lower annual cost, then lower hedged LTV +- `balanced`: combine hedged LTV and normalized annual cost + +This is a ranking heuristic, not an optimizer or live execution model. + +### Sensitivity analysis + +The engine also reruns recommendations across: + +- multiple volatility assumptions +- multiple spot-price assumptions + +This helps identify whether a recommendation is robust to input changes. + +--- + +## Data flow diagram for strategy computation + +```mermaid +sequenceDiagram + participant Client + participant API as /api/strategies + participant DS as DataService + participant SE as StrategySelectionEngine + participant S as Strategy implementations + + Client->>API: GET /api/strategies?symbol=GLD + API->>DS: get_strategies(symbol) + DS->>DS: get_quote(symbol) + DS->>SE: create engine with spot and research parameters + SE->>S: compare_all_strategies() + S-->>SE: cost/protection/scenario payloads + SE->>SE: recommend() by risk profile + SE->>SE: sensitivity_analysis() + SE-->>DS: comparison + recommendations + sensitivity + DS-->>API: JSON response + API-->>Client: strategies payload +``` + +--- + +## API endpoints + +### Health + +- `GET /health` + +Purpose: + +- liveness/readiness-style check for deploy validation + +Returns: + +- application status +- current environment +- whether Redis is enabled + +### Portfolio API + +- `GET /api/portfolio?symbol=GLD` + +Purpose: + +- return a current portfolio snapshot derived from the latest quote + +### Options API + +- `GET /api/options?symbol=GLD` + +Purpose: + +- return a simplified options chain snapshot for the selected symbol + +### Strategies API + +- `GET /api/strategies?symbol=GLD` + +Purpose: + +- return strategy comparisons, recommendations, and sensitivity analysis + +### WebSocket updates + +- `WS /ws/updates` + +Purpose: + +- push periodic `portfolio_update` messages to connected clients + +--- + +## Deployment architecture + +Production deployment currently assumes: + +- containerized app on a VPS +- image stored in GitLab Container Registry +- deployment initiated by GitLab CI/CD over SSH +- optional Redis, depending on runtime configuration +- VPN-restricted network access preferred +- reverse proxy/TLS termination recommended in front of the app + +```mermaid +flowchart LR + A[GitLab CI/CD] -->|build + push| B[GitLab Container Registry] + A -->|SSH deploy| C[VPS] + B -->|docker pull| C + C --> D[vault-dash container] + E[VPN / Reverse Proxy] --> D +``` + +--- + +## Architectural constraints and assumptions + +- Strategy calculations are currently research-oriented, not broker-executed trades +- Quote retrieval is best-effort and may fall back to static data +- Options chain payloads are synthetic examples, not a full market data feed +- Redis is optional and the app must work without it +- WebSocket updates currently publish portfolio snapshots only +- NiceGUI and API routes run in the same Python application process + +## Near-term extension points + +Likely future architecture additions: + +- real broker integration for positions and option chains +- persistent storage for scenarios, settings, and user sessions +- reverse proxy configuration in deployment Compose files +- authenticated API access +- OAuth provider integration over HTTPS +- richer WebSocket event types diff --git a/docs/STRATEGIES.md b/docs/STRATEGIES.md new file mode 100644 index 0000000..a62ae50 --- /dev/null +++ b/docs/STRATEGIES.md @@ -0,0 +1,424 @@ +# Strategy Documentation + +## Overview + +Vault Dashboard currently documents and compares hedging approaches for a Lombard-style loan backed by gold exposure. The implementation focuses on paper analysis using option pricing, LTV protection metrics, and scenario analysis. + +The strategy subsystem currently includes: + +- protective puts +- laddered puts +- lease/LEAPS duration analysis + +This document focuses on the two primary hedge structures requested here: + +- protective put +- laddered put + +--- + +## Common portfolio assumptions + +The default research engine in `app/strategies/engine.py` uses: + +- portfolio value: `1,000,000` +- loan amount: `600,000` +- margin-call threshold: `0.75` +- spot price: `460` +- volatility: `0.16` +- risk-free rate: `0.045` + +From these values, the portfolio is modeled as a `LombardPortfolio`: + +- gold ounces = `portfolio_value / spot_price` +- initial LTV = `loan_amount / portfolio_value` +- margin call price = `loan_amount / (margin_call_ltv * gold_ounces)` + +These assumptions create the common basis used to compare all strategies. + +--- + +## Protective put + +## What it is + +A protective put is the simplest downside hedge in the project. + +Structure: + +- long the underlying collateral exposure implicitly represented by the gold-backed portfolio +- buy one put hedge sized to the portfolio's underlying units + +In this codebase, `ProtectivePutStrategy` creates a single long put whose strike is defined as a percentage of spot. + +Examples currently used by the engine: + +- ATM protective put: strike = `100%` of spot +- 95% OTM protective put: strike = `95%` of spot +- 90% OTM protective put: strike = `90%` of spot + +## Why use it + +A protective put sets a floor on downside beyond the strike, helping reduce the chance that falling collateral value pushes the portfolio above the margin-call LTV. + +## How it is implemented + +**File:** `app/strategies/protective_put.py` + +Main properties: + +- `hedge_units`: `portfolio.gold_value / spot_price` +- `strike`: `spot_price * strike_pct` +- `term_years`: `months / 12` + +A put contract is priced with Black-Scholes inputs: + +- current spot +- strike +- time to expiry +- risk-free rate +- volatility +- option type = `put` + +The resulting `OptionContract` uses: + +- `quantity = 1.0` +- `contract_size = hedge_units` + +That means one model contract covers the full portfolio exposure in underlying units. + +## Protective put payoff intuition + +At expiry: + +- if spot is above strike, the put expires worthless +- if spot is below strike, payoff rises linearly as `strike - spot` + +Total gross payoff is: + +```text +max(strike - spot, 0) * hedge_units +``` + +## Protective put trade-offs + +Advantages: + +- simple to explain +- clear downside floor +- strongest protection when strike is high + +Costs: + +- premium can be expensive, especially at-the-money and for longer tenor +- full notional protection may overspend relative to a client's risk budget +- upside is preserved, but cost drags returns + +--- + +## Laddered put + +## What it is + +A laddered put splits the hedge across multiple put strikes instead of buying the full hedge at one strike. + +Structure: + +- multiple long put legs +- each leg covers a weighted fraction of the total hedge +- lower strikes usually reduce premium while preserving some tail protection + +Examples currently used by the engine: + +- `50/50` ATM + 95% OTM +- `33/33/33` ATM + 95% OTM + 90% OTM + +## Why use it + +A ladder can reduce hedge cost versus a full ATM protective put, while still providing meaningful protection as the underlying falls. + +This is useful when: + +- full-cost protection is too expensive +- some drawdown can be tolerated before the hedge fully engages +- the client wants a better cost/protection balance + +## How it is implemented + +**File:** `app/strategies/laddered_put.py` + +A `LadderSpec` defines: + +- `weights` +- `strike_pcts` +- `months` + +Validation rules: + +- number of weights must equal number of strikes +- weights must sum to `1.0` + +Each leg is implemented by internally creating a `ProtectivePutStrategy`, then weighting its premium and payoff. + +## Ladder payoff intuition + +Each leg pays off independently: + +```text +max(leg_strike - spot, 0) * hedge_units * weight +``` + +Total ladder payoff is the sum across legs. + +Relative to a single-strike hedge: + +- protection turns on in stages +- blended premium is lower when some legs are farther OTM +- downside support is smoother but less absolute near the first loss zone than a full ATM hedge + +## Ladder trade-offs + +Advantages: + +- lower blended hedge cost +- more flexible cost/protection shaping +- better fit for cost-sensitive clients + +Costs and limitations: + +- weaker immediate protection than a fully ATM hedge +- more complex to explain to users +- floor value depends on weight distribution across strikes + +--- + +## Cost calculations + +## Protective put cost calculation + +`ProtectivePutStrategy.calculate_cost()` returns: + +- `premium_per_share` +- `total_cost` +- `cost_pct_of_portfolio` +- `term_months` +- `annualized_cost` +- `annualized_cost_pct` + +### Formula summary + +Let: + +- `P` = option premium per underlying unit +- `U` = hedge units +- `T` = term in years +- `V` = portfolio value + +Then: + +```text +total_cost = P * U +cost_pct_of_portfolio = total_cost / V +annualized_cost = total_cost / T +annualized_cost_pct = annualized_cost / V +``` + +Because the model contract size equals the full hedge units, the total premium directly represents the whole-portfolio hedge cost. + +## Laddered put cost calculation + +`LadderedPutStrategy.calculate_cost()` computes weighted leg costs. + +For each leg `i`: + +- `weight_i` +- `premium_i` +- `hedge_units` + +Leg cost: + +```text +leg_cost_i = premium_i * hedge_units * weight_i +``` + +Blended totals: + +```text +blended_cost = sum(leg_cost_i) +blended_premium_per_share = sum(premium_i * weight_i) +annualized_cost = blended_cost / term_years +cost_pct_of_portfolio = blended_cost / portfolio_value +annualized_cost_pct = annualized_cost / portfolio_value +``` + +## Why annualized cost matters + +The engine compares strategies with different durations, especially in `LeaseStrategy`. Annualizing allows the system to compare short-dated and long-dated hedges on a common yearly basis. + +--- + +## Protection calculations + +## Margin-call threshold price + +The project defines the collateral price that would trigger a margin call as: + +```text +margin_call_price = loan_amount / (margin_call_ltv * gold_ounces) +``` + +This is a key reference point for all protection calculations. + +## Protective put protection calculation + +At the threshold price: + +1. compute the put payoff +2. add that payoff to the stressed collateral value +3. recompute LTV on the hedged collateral + +Formulas: + +```text +payoff_at_threshold = max(strike - threshold_price, 0) * hedge_units +hedged_value_at_threshold = gold_value_at_threshold + payoff_at_threshold +hedged_ltv_at_threshold = loan_amount / hedged_value_at_threshold +``` + +The strategy is flagged as maintaining a margin buffer when: + +```text +hedged_ltv_at_threshold < margin_call_ltv +``` + +## Laddered put protection calculation + +For a ladder, threshold payoff is the weighted sum of all leg payoffs: + +```text +weighted_payoff_i = max(strike_i - threshold_price, 0) * hedge_units * weight_i +payoff_at_threshold = sum(weighted_payoff_i) +hedged_value_at_threshold = gold_value_at_threshold + payoff_at_threshold +hedged_ltv_at_threshold = loan_amount / hedged_value_at_threshold +``` + +The ladder's implied floor value is approximated as the weighted strike coverage: + +```text +portfolio_floor_value = sum(strike_i * hedge_units * weight_i) +``` + +--- + +## Scenario analysis methodology + +## Scenario grid + +The current scenario engine in `ProtectivePutStrategy` uses a fixed price-change grid: + +```text +-60%, -50%, -40%, -30%, -20%, -10%, 0%, +10%, +20%, +30%, +40%, +50% +``` + +For each change: + +```text +scenario_price = spot_price * (1 + change) +``` + +Negative or zero prices are ignored. + +## Metrics produced per scenario + +For each scenario, the strategy computes: + +- scenario spot price +- unhedged gold value +- option payoff +- hedge cost +- net portfolio value after hedge cost +- unhedged LTV +- hedged LTV +- whether a margin call occurs without the hedge +- whether a margin call occurs with the hedge + +### Protective put scenario formulas + +Let `S` be scenario spot. + +```text +gold_value = gold_ounces * S +option_payoff = max(strike - S, 0) * hedge_units +hedged_collateral = gold_value + option_payoff +net_portfolio_value = gold_value + option_payoff - hedge_cost +unhedged_ltv = loan_amount / gold_value +hedged_ltv = loan_amount / hedged_collateral +``` + +Margin-call flags: + +```text +margin_call_without_hedge = unhedged_ltv >= margin_call_ltv +margin_call_with_hedge = hedged_ltv >= margin_call_ltv +``` + +### Laddered put scenario formulas + +For ladders: + +```text +option_payoff = sum(max(strike_i - S, 0) * hedge_units * weight_i) +hedged_collateral = gold_value + option_payoff +net_portfolio_value = gold_value + option_payoff - blended_cost +``` + +All other LTV and margin-call logic is the same. + +## Interpretation methodology + +Scenario analysis is used to answer four practical questions: + +1. **Cost:** How much premium is paid upfront? +2. **Activation:** At what downside level does protection meaningfully start? +3. **Buffer:** Does the hedge keep LTV below the margin-call threshold under stress? +4. **Efficiency:** How much protection is obtained per dollar of annualized hedge cost? + +This is why each strategy exposes both: + +- a `calculate_protection()` summary around the threshold price +- a full `get_scenarios()` table across broad upside/downside moves + +--- + +## Comparing protective puts vs laddered puts + +| Dimension | Protective put | Laddered put | +|---|---|---| +| Structure | Single put strike | Multiple weighted put strikes | +| Simplicity | Highest | Moderate | +| Upfront cost | Usually higher | Usually lower | +| Near-threshold protection | Stronger if ATM-heavy | Depends on ladder weights | +| Tail downside protection | Strong | Strong, but blended | +| Customization | Limited | High | +| Best fit | conservative protection | balanced or cost-sensitive protection | + +--- + +## Important limitations + +- The strategy engine is currently research-oriented, not an execution engine +- Black-Scholes assumptions simplify real-world market behavior +- Transaction costs, slippage, taxes, liquidity, and early exercise effects are not modeled here +- The API payloads should be treated as analytical outputs, not trade recommendations +- For non-`GLD` symbols, the engine currently still uses research-style assumptions rather than a complete live instrument-specific calibration + +## Future strategy extensions + +Natural follow-ups for this subsystem: + +- collars and financed hedges +- partial notional hedging +- dynamic re-hedging rules +- volatility surface-based pricing +- broker-native contract sizing and expiries +- user-configurable scenario grids diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5df0efb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "vault-dash" +version = "0.1.0" +description = "Real-time options hedging dashboard" +requires-python = ">=3.11" + +[tool.black] +line-length = 88 +target-version = ["py311"] + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +ignore_missing_imports = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9ff9887 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +black>=24.0.0 +ruff>=0.2.0 +mypy>=1.8.0 +httpx>=0.26.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ce0b863 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +nicegui>=2.0.0 +fastapi>=0.110.0 +uvicorn>=0.27.0 +QuantLib>=1.31 +yfinance>=0.2.0 +lightweight-charts>=2.0.0 +polars>=0.20.0 +pydantic>=2.5.0 +pyyaml>=6.0 +redis>=5.0.0 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..a0ade39 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +: "${DEPLOY_USER:?DEPLOY_USER is required}" +: "${DEPLOY_HOST:?DEPLOY_HOST is required}" +: "${CI_REGISTRY_IMAGE:?CI_REGISTRY_IMAGE is required}" +: "${CI_REGISTRY_USER:?CI_REGISTRY_USER is required}" +: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD is required}" + +DEPLOY_PORT="${DEPLOY_PORT:-22}" +DEPLOY_PATH="${DEPLOY_PATH:-/opt/vault-dash}" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.deploy.yml}" +COMPOSE_SERVICE="${COMPOSE_SERVICE:-vault-dash}" +DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-120}" +HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://127.0.0.1:${APP_PORT:-8000}/health}" +IMAGE_TAG="${IMAGE_TAG:-${CI_COMMIT_SHA}}" +APP_IMAGE="${APP_IMAGE:-${CI_REGISTRY_IMAGE}:${IMAGE_TAG}}" +REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-$DEPLOY_PATH/.env}" +SSH_OPTS=(-p "$DEPLOY_PORT" -o StrictHostKeyChecking=no) +REMOTE_TARGET="${DEPLOY_USER}@${DEPLOY_HOST}" + +ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p '$DEPLOY_PATH'" + +if [[ -n "${APP_ENV_FILE:-}" ]]; then + printf '%s\n' "$APP_ENV_FILE" | ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "cat > '$REMOTE_ENV_FILE'" +else + ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "cat > '$REMOTE_ENV_FILE'" </dev/null 2>&1; then + CURRENT_CONTAINER="$(docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" ps -q "$COMPOSE_SERVICE" || true)" + if [[ -n "$CURRENT_CONTAINER" ]]; then + CURRENT_IMAGE="$(docker inspect -f '{{ .Config.Image }}' "$CURRENT_CONTAINER")" + if [[ -n "$CURRENT_IMAGE" ]]; then + PREVIOUS_IMAGE="$CURRENT_IMAGE" + fi + fi +fi + +echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + +docker pull "$APP_IMAGE" +sed -i.bak "/^APP_IMAGE=/d" "$REMOTE_ENV_FILE" +printf 'APP_IMAGE=%s\n' "$APP_IMAGE" | cat - "$REMOTE_ENV_FILE.bak" > "$REMOTE_ENV_FILE" +rm -f "$REMOTE_ENV_FILE.bak" + +docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" up -d --remove-orphans + +end_time=$((SECONDS + DEPLOY_TIMEOUT)) +until curl -fsS "$HEALTHCHECK_URL" >/dev/null; do + if (( SECONDS >= end_time )); then + echo "Deployment health check failed, attempting rollback" >&2 + if [[ -n "$PREVIOUS_IMAGE" ]]; then + sed -i.bak "/^APP_IMAGE=/d" "$REMOTE_ENV_FILE" + printf 'APP_IMAGE=%s\n' "$PREVIOUS_IMAGE" | cat - "$REMOTE_ENV_FILE.bak" > "$REMOTE_ENV_FILE" + rm -f "$REMOTE_ENV_FILE.bak" + docker pull "$PREVIOUS_IMAGE" || true + docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" up -d --remove-orphans + fi + exit 1 + fi + sleep 5 +done + +echo "$APP_IMAGE" > .last_successful_image +EOF diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100755 index 0000000..af0a583 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,70 @@ +#!/bin/sh +set -eu + +APP_MODULE="${APP_MODULE:-app.main:app}" +APP_HOST="${APP_HOST:-0.0.0.0}" +APP_PORT="${APP_PORT:-8000}" +APP_ENV="${APP_ENV:-production}" +WORKERS="${UVICORN_WORKERS:-1}" +REDIS_WAIT_TIMEOUT="${REDIS_WAIT_TIMEOUT:-30}" + +wait_for_redis() { + if [ -z "${REDIS_URL:-}" ]; then + echo "REDIS_URL not set, skipping Redis wait" + return 0 + fi + + echo "Waiting for Redis: ${REDIS_URL}" + python - <<'PY' +import os +import sys +import time +from urllib.parse import urlparse + +redis_url = os.environ.get("REDIS_URL", "") +timeout = int(os.environ.get("REDIS_WAIT_TIMEOUT", "30")) +parsed = urlparse(redis_url) +host = parsed.hostname or "redis" +port = parsed.port or 6379 +deadline = time.time() + timeout + +while time.time() < deadline: + try: + import socket + with socket.create_connection((host, port), timeout=2): + print(f"Redis is reachable at {host}:{port}") + raise SystemExit(0) + except OSError: + time.sleep(1) + +print(f"Timed out waiting for Redis at {host}:{port}", file=sys.stderr) +raise SystemExit(1) +PY +} + +run_migrations() { + if [ "${RUN_MIGRATIONS:-0}" != "1" ]; then + echo "RUN_MIGRATIONS disabled, skipping" + return 0 + fi + + if [ -f alembic.ini ] && command -v alembic >/dev/null 2>&1; then + echo "Running Alembic migrations" + alembic upgrade head + return 0 + fi + + if [ -f manage.py ]; then + echo "Running Django migrations" + python manage.py migrate --noinput + return 0 + fi + + echo "No supported migration command found, skipping" +} + +wait_for_redis +run_migrations + +echo "Starting application on ${APP_HOST}:${APP_PORT}" +exec python -m uvicorn "${APP_MODULE}" --host "${APP_HOST}" --port "${APP_PORT}" --workers "${WORKERS}" diff --git a/scripts/healthcheck.py b/scripts/healthcheck.py new file mode 100755 index 0000000..46a2bc9 --- /dev/null +++ b/scripts/healthcheck.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Simple deployment health check utility.""" + +from __future__ import annotations + +import argparse +import sys +import time +from urllib.error import HTTPError, URLError +from urllib.request import urlopen +import json + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Poll an HTTP health endpoint until it is ready.") + parser.add_argument("url", help="Health endpoint URL, e.g. https://vault.example.com/health") + parser.add_argument("--timeout", type=int, default=120, help="Maximum wait time in seconds") + parser.add_argument("--interval", type=int, default=5, help="Poll interval in seconds") + parser.add_argument("--expect-status", default="ok", help="Expected JSON status field") + parser.add_argument("--expect-environment", default=None, help="Optional expected JSON environment field") + return parser.parse_args() + + +def fetch_json(url: str) -> dict: + with urlopen(url, timeout=10) as response: + body = response.read().decode("utf-8") + return json.loads(body) + + +def main() -> int: + args = parse_args() + deadline = time.time() + args.timeout + last_error = "unknown error" + + while time.time() < deadline: + try: + payload = fetch_json(args.url) + if payload.get("status") != args.expect_status: + raise RuntimeError(f"unexpected status: {payload!r}") + if args.expect_environment and payload.get("environment") != args.expect_environment: + raise RuntimeError(f"unexpected environment: {payload!r}") + print(f"healthcheck passed: {payload}") + return 0 + except (HTTPError, URLError, TimeoutError, ValueError, RuntimeError) as exc: + last_error = str(exc) + time.sleep(args.interval) + + print(f"healthcheck failed after {args.timeout}s: {last_error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..adb82db --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import datetime + +import pandas as pd +import pytest + +from app.models.portfolio import LombardPortfolio +from app.strategies.base import StrategyConfig + + +@pytest.fixture +def sample_portfolio() -> LombardPortfolio: + """Research-paper baseline portfolio: 1M collateral, 600k loan, 460 spot, 75% LTV trigger.""" + gold_ounces = 1_000_000.0 / 460.0 + return LombardPortfolio( + gold_ounces=gold_ounces, + gold_price_per_ounce=460.0, + loan_amount=600_000.0, + initial_ltv=0.60, + margin_call_ltv=0.75, + ) + + +@pytest.fixture +def sample_strategy_config(sample_portfolio: LombardPortfolio) -> StrategyConfig: + return StrategyConfig( + portfolio=sample_portfolio, + spot_price=sample_portfolio.gold_price_per_ounce, + volatility=0.16, + risk_free_rate=0.045, + ) + + +@pytest.fixture +def sample_option_chain(sample_portfolio: LombardPortfolio) -> dict[str, object]: + """Deterministic mock option chain around a 460 GLD reference price.""" + spot = sample_portfolio.gold_price_per_ounce + return { + "symbol": "GLD", + "updated_at": datetime(2026, 3, 21, 0, 0).isoformat(), + "source": "mock", + "calls": [ + {"strike": round(spot * 1.05, 2), "premium": round(spot * 0.03, 2), "expiry": "2026-06-19"}, + {"strike": round(spot * 1.10, 2), "premium": round(spot * 0.02, 2), "expiry": "2026-09-18"}, + ], + "puts": [ + {"strike": round(spot * 0.95, 2), "premium": round(spot * 0.028, 2), "expiry": "2026-06-19"}, + {"strike": round(spot * 0.90, 2), "premium": round(spot * 0.018, 2), "expiry": "2026-09-18"}, + ], + } + + +@pytest.fixture +def mock_yfinance_data(monkeypatch): + """Patch yfinance in the data layer with deterministic historical close data.""" + # Lazy import here to avoid side effects when the environment lacks Python 3.11's + # datetime.UTC symbol used in the data_service module. + from app.services import data_service as data_service_module + + history = pd.DataFrame({"Close": [458.0, 460.0]}, index=pd.date_range("2026-03-20", periods=2, freq="D")) + + class FakeTicker: + def __init__(self, symbol: str) -> None: + self.symbol = symbol + + def history(self, period: str, interval: str): + return history.copy() + + class FakeYFinance: + Ticker = FakeTicker + + monkeypatch.setattr(data_service_module, "yf", FakeYFinance()) + return { + "symbol": "GLD", + "history": history, + "last_price": 460.0, + "previous_price": 458.0, + } + + +@pytest.fixture +def mock_yfinance(mock_yfinance_data): + """Compatibility alias for tests that request a yfinance fixture name.""" + return mock_yfinance_data diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..639f73a --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient + +from app.main import app + + +def test_health_endpoint_returns_ok() -> None: + with TestClient(app) as client: + response = client.get("/health") + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ok" + assert "environment" in payload diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py new file mode 100644 index 0000000..a70af0f --- /dev/null +++ b/tests/test_portfolio.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import pytest + + +def test_ltv_calculation(sample_portfolio) -> None: + assert sample_portfolio.current_ltv == pytest.approx(0.60, rel=1e-12) + assert sample_portfolio.ltv_at_price(460.0) == pytest.approx(0.60, rel=1e-12) + assert sample_portfolio.ltv_at_price(368.0) == pytest.approx(0.75, rel=1e-12) + + +def test_net_equity_calculation(sample_portfolio) -> None: + assert sample_portfolio.net_equity == pytest.approx(400_000.0, rel=1e-12) + assert sample_portfolio.net_equity_at_price(420.0) == pytest.approx( + sample_portfolio.gold_ounces * 420.0 - 600_000.0, + rel=1e-12, + ) + + +def test_margin_call_threshold(sample_portfolio) -> None: + assert sample_portfolio.margin_call_price() == pytest.approx(368.0, rel=1e-12) + assert sample_portfolio.ltv_at_price(sample_portfolio.margin_call_price()) == pytest.approx(0.75, rel=1e-12) diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 0000000..46a7b96 --- /dev/null +++ b/tests/test_pricing.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import pytest + +import app.core.pricing.black_scholes as black_scholes +from app.core.pricing.black_scholes import BlackScholesInputs + + +@pytest.mark.parametrize( + "params, expected_price", + [ + ( + BlackScholesInputs( + spot=460.0, + strike=460.0, + time_to_expiry=1.0, + risk_free_rate=0.045, + volatility=0.16, + option_type="put", + dividend_yield=0.0, + ), + 19.68944358516964, + ) + ], +) +def test_put_price_calculation( + monkeypatch: pytest.MonkeyPatch, + params: BlackScholesInputs, + expected_price: float, +) -> None: + """European put price from the research-paper ATM example.""" + monkeypatch.setattr(black_scholes, "ql", None) + result = black_scholes.black_scholes_price_and_greeks(params) + assert result.price == pytest.approx(expected_price, rel=1e-9) + + +def test_greeks_values(monkeypatch: pytest.MonkeyPatch) -> None: + """Validate Black-Scholes Greeks against research-paper baseline inputs.""" + monkeypatch.setattr(black_scholes, "ql", None) + result = black_scholes.black_scholes_price_and_greeks( + BlackScholesInputs( + spot=460.0, + strike=460.0, + time_to_expiry=1.0, + risk_free_rate=0.045, + volatility=0.16, + option_type="put", + dividend_yield=0.0, + ) + ) + + assert result.delta == pytest.approx(-0.35895628379355216, rel=1e-9) + assert result.gamma == pytest.approx(0.005078017547110844, rel=1e-9) + assert result.theta == pytest.approx(-5.4372889301396174, rel=1e-9) + assert result.vega == pytest.approx(171.92136207498476, rel=1e-9) + assert result.rho == pytest.approx(-184.80933413020364, rel=1e-9) + + +def test_margin_call_price_calculation() -> None: + """Margin-call trigger from research defaults: 460 spot, 1,000,000 collateral, 600,000 loan.""" + threshold = black_scholes.margin_call_threshold_price( + portfolio_value=1_000_000.0, + loan_amount=600_000.0, + current_price=460.0, + margin_call_ltv=0.75, + ) + assert threshold == pytest.approx(368.0, rel=1e-12) diff --git a/tests/test_strategies.py b/tests/test_strategies.py new file mode 100644 index 0000000..20bf85a --- /dev/null +++ b/tests/test_strategies.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +import pytest + +import app.core.pricing.black_scholes as black_scholes +from app.strategies.base import StrategyConfig +from app.strategies.laddered_put import LadderSpec, LadderedPutStrategy +from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy + + +def _force_analytic_pricing(monkeypatch: pytest.MonkeyPatch) -> None: + """Use deterministic analytical pricing for stable expected values.""" + monkeypatch.setattr(black_scholes, "ql", None) + + +def test_protective_put_costs( + monkeypatch: pytest.MonkeyPatch, + sample_strategy_config: StrategyConfig, +) -> None: + _force_analytic_pricing(monkeypatch) + strategy = ProtectivePutStrategy(sample_strategy_config, ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12)) + cost = strategy.calculate_cost() + + assert cost["strategy"] == "protective_put_atm" + assert cost["label"] == "ATM" + assert cost["strike"] == 460.0 + assert cost["premium_per_share"] == pytest.approx(19.6894, abs=1e-4) + assert cost["total_cost"] == pytest.approx(42803.14, abs=1e-2) + assert cost["cost_pct_of_portfolio"] == pytest.approx(0.042803, abs=1e-6) + assert cost["annualized_cost"] == pytest.approx(42803.14, abs=1e-2) + assert cost["annualized_cost_pct"] == pytest.approx(0.042803, abs=1e-6) + + +def test_laddered_strategy(sample_strategy_config: StrategyConfig, monkeypatch: pytest.MonkeyPatch) -> None: + _force_analytic_pricing(monkeypatch) + strategy = LadderedPutStrategy( + sample_strategy_config, + LadderSpec(label="50_50_ATM_OTM95", weights=(0.5, 0.5), strike_pcts=(1.0, 0.95), months=12), + ) + cost = strategy.calculate_cost() + protection = strategy.calculate_protection() + + assert cost["strategy"] == "laddered_put_50_50_atm_otm95" + assert len(cost["legs"]) == 2 + assert cost["legs"][0]["weight"] == 0.5 + assert cost["legs"][0]["strike"] == 460.0 + assert cost["legs"][1]["strike"] == 437.0 + assert cost["blended_cost"] == pytest.approx(34200.72, abs=1e-2) + assert cost["cost_pct_of_portfolio"] == pytest.approx(0.034201, abs=1e-6) + + assert protection["portfolio_floor_value"] == pytest.approx(975000.0, rel=1e-12) + assert protection["payoff_at_threshold"] == pytest.approx(175000.0, abs=1e-2) + assert protection["hedged_ltv_at_threshold"] == pytest.approx(0.615385, rel=1e-6) + assert protection["maintains_margin_call_buffer"] is True + + +def test_scenario_analysis( + monkeypatch: pytest.MonkeyPatch, + sample_strategy_config: StrategyConfig, +) -> None: + _force_analytic_pricing(monkeypatch) + protective = ProtectivePutStrategy( + sample_strategy_config, + ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12), + ) + ladder = LadderedPutStrategy( + sample_strategy_config, + LadderSpec(label="50_50_ATM_OTM95", weights=(0.5, 0.5), strike_pcts=(1.0, 0.95), months=12), + ) + + protective_scenarios = protective.get_scenarios() + ladder_scenarios = ladder.get_scenarios() + + assert len(protective_scenarios) == 12 + assert len(ladder_scenarios) == 12 + + first_protective = protective_scenarios[0] + assert first_protective["price_change_pct"] == -0.6 + assert first_protective["gld_price"] == 184.0 + assert first_protective["option_payoff"] == pytest.approx(600000.0, abs=1e-2) + assert first_protective["hedge_cost"] == pytest.approx(42803.14, abs=1e-2) + assert first_protective["hedged_ltv"] == pytest.approx(0.6, rel=1e-12) + assert first_protective["margin_call_with_hedge"] is False + + first_ladder = ladder_scenarios[0] + assert first_ladder["gld_price"] == 184.0 + assert first_ladder["option_payoff"] == pytest.approx(575000.0, abs=1e-2) + assert first_ladder["hedge_cost"] == pytest.approx(34200.72, abs=1e-2) + assert first_ladder["hedged_ltv"] == pytest.approx(0.615385, rel=1e-6) + + worst_ladder = ladder_scenarios[-1] + assert worst_ladder["gld_price"] == 690.0 + assert worst_ladder["hedged_ltv"] == pytest.approx(0.4, rel=1e-12) + assert worst_ladder["margin_call_with_hedge"] is False