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
This commit is contained in:
Bu5hm4nn
2026-03-21 19:21:40 +01:00
commit 00a68bc767
63 changed files with 6239 additions and 0 deletions

26
.dockerignore Normal file
View File

@@ -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

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
APP_HOST=0.0.0.0
APP_PORT=8000
REDIS_URL=redis://localhost:6379
CONFIG_PATH=/app/config/settings.yaml

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
.venv/
*.pyc
.env
config/secrets.yaml
data/cache/
.idea/
.vscode/

101
.gitlab-ci.yml Normal file
View File

@@ -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

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

620
DEPLOYMENT.md Normal file
View File

@@ -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

52
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

17
Makefile Normal file
View File

@@ -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

94
README.md Normal file
View File

@@ -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

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Vault dashboard application package."""

28
app/api/routes.py Normal file
View File

@@ -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)

View File

@@ -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",
]

182
app/components/charts.py Normal file
View File

@@ -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(
"""
<script src="https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js"></script>
"""
)
_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'<div id="{self.chart_id}" class="w-full rounded-xl"></div>').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});
}})();
"""
)

View File

@@ -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"""
<div class=\"overflow-x-auto\">
<table class=\"min-w-full\">
<thead class=\"bg-slate-100 dark:bg-slate-800\">
<tr>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Option</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Delta</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Gamma</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Theta</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Vega</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Rho</th>
</tr>
</thead>
<tbody>{''.join(rows) if rows else self._empty_row()}</tbody>
</table>
</div>
"""
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'<td class="px-4 py-3 font-semibold {self._risk_class(name, value)}">{value:+.4f}</td>'
for name, value in greeks.items()
)
return (
'<tr class="border-b border-slate-200 dark:border-slate-800">'
f'<td class="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{label}</td>'
f'{cells}'
'</tr>'
)
@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 (
'<tr><td colspan="6" class="px-4 py-6 text-center text-slate-500 dark:text-slate-400">'
'No options selected'
'</td></tr>'
)

View File

@@ -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;")

View File

@@ -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"""
<div class=\"h-full rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950\">
<div class=\"mb-3 flex items-start justify-between gap-3\">
<div>
<div class=\"text-base font-semibold text-slate-900 dark:text-slate-100\">{name}</div>
<div class=\"mt-1 text-sm text-slate-500 dark:text-slate-400\">{description}</div>
</div>
<div class=\"rounded-full bg-slate-900 px-2 py-1 text-xs font-semibold text-white dark:bg-slate-100 dark:text-slate-900\">Live Scenario</div>
</div>
<div class=\"grid grid-cols-2 gap-3\">
<div class=\"rounded-lg bg-white p-3 dark:bg-slate-900\">
<div class=\"text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400\">Est. Cost</div>
<div class=\"mt-1 text-lg font-bold text-slate-900 dark:text-slate-100\">${cost:,.2f}</div>
</div>
<div class=\"rounded-lg bg-white p-3 dark:bg-slate-900\">
<div class=\"text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400\">Scenario Benefit</div>
<div class=\"mt-1 text-lg font-bold {payoff_class}\">${payoff:,.2f}</div>
</div>
</div>
</div>
"""
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"""
<tr class=\"border-b border-slate-200 dark:border-slate-800\">
<td class=\"px-4 py-3 font-medium text-slate-900 dark:text-slate-100\">{name}</td>
<td class=\"px-4 py-3 text-slate-600 dark:text-slate-300\">${cost:,.2f}</td>
<td class=\"px-4 py-3 text-slate-600 dark:text-slate-300\">{self._fmt_optional_money(floor)}</td>
<td class=\"px-4 py-3 text-slate-600 dark:text-slate-300\">{self._fmt_optional_money(cap)}</td>
<td class=\"px-4 py-3 font-semibold {scenario_class}\">${scenario:,.2f}</td>
</tr>
"""
)
return f"""
<div class=\"overflow-x-auto\">
<table class=\"min-w-full rounded-xl overflow-hidden\">
<thead class=\"bg-slate-100 dark:bg-slate-800\">
<tr>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Strategy</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Estimated Cost</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Protection Floor</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Upside Cap</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Scenario Benefit</th>
</tr>
</thead>
<tbody>{''.join(rows) if rows else self._empty_row()}</tbody>
</table>
</div>
"""
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 (
'<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500 dark:text-slate-400">'
'No strategies loaded'
'</td></tr>'
)

21
app/core/__init__.py Normal file
View File

@@ -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",
]

98
app/core/calculations.py Normal file
View File

@@ -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,
)

View File

@@ -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",
]
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)
)

172
app/main.py Normal file
View File

@@ -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")

15
app/models/__init__.py Normal file
View File

@@ -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",
]

109
app/models/option.py Normal file
View File

@@ -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

71
app/models/portfolio.py Normal file
View File

@@ -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)

101
app/models/strategy.py Normal file
View File

@@ -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

3
app/pages/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from . import hedge, options, overview, settings
__all__ = ["overview", "hedge", "options", "settings"]

203
app/pages/common.py Normal file
View File

@@ -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")

126
app/pages/hedge.py Normal file
View File

@@ -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()

126
app/pages/options.py Normal file
View File

@@ -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 = """
<div class='overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900'>
<table class='min-w-full'>
<thead class='bg-slate-100 dark:bg-slate-800'>
<tr>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Contract</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Type</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Strike</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Bid / Ask</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Greeks</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Action</th>
</tr>
</thead>
<tbody>
""" + "".join(
f"""
<tr class='border-b border-slate-200 dark:border-slate-800'>
<td class='px-4 py-3 font-medium text-slate-900 dark:text-slate-100'>{row['symbol']}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{row['type'].upper()}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${row['strike']:.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${row['bid']:.2f} / ${row['ask']:.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'{row['delta']:+.3f} · Γ {row['gamma']:.3f} · Θ {row['theta']:+.3f}</td>
<td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td>
</tr>
"""
for row in rows
) + ("" if rows else "<tr><td colspan='6' class='px-4 py-6 text-center text-slate-500 dark:text-slate-400'>No contracts match the current filter.</td></tr>") + """
</tbody>
</table>
</div>
"""
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()

66
app/pages/overview.py Normal file
View File

@@ -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")

58
app/pages/settings.py Normal file
View File

@@ -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")

72
app/services/cache.py Normal file
View File

@@ -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")

View File

@@ -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,
}

View File

@@ -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",
]

40
app/strategies/base.py Normal file
View File

@@ -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

159
app/strategies/engine.py Normal file
View File

@@ -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

View File

@@ -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

95
app/strategies/lease.py Normal file
View File

@@ -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

View File

@@ -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

1
config/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
secrets.yaml

View File

@@ -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

26
config/settings.yaml Normal file
View File

@@ -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

25
docker-compose.deploy.yml Normal file
View File

@@ -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

63
docker-compose.prod.yml Normal file
View File

@@ -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:

48
docker-compose.yml Normal file
View File

@@ -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:

593
docs/API.md Normal file
View File

@@ -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<object>`): example call rows derived from spot
- `puts` (`array<object>`): 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<object>`): 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<object>`): 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

437
docs/ARCHITECTURE.md Normal file
View File

@@ -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

424
docs/STRATEGIES.md Normal file
View File

@@ -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

32
pyproject.toml Normal file
View File

@@ -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"]

6
requirements-dev.txt Normal file
View File

@@ -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

10
requirements.txt Normal file
View File

@@ -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

91
scripts/deploy.sh Executable file
View File

@@ -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'" <<EOF
APP_IMAGE=$APP_IMAGE
APP_ENV=${APP_ENV:-production}
APP_NAME=${APP_NAME:-Vault Dashboard}
APP_PORT=${APP_PORT:-8000}
APP_BIND_ADDRESS=${APP_BIND_ADDRESS:-127.0.0.1}
REDIS_URL=${REDIS_URL:-}
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:-*}
EOF
fi
scp "${SSH_OPTS[@]}" docker-compose.deploy.yml "$REMOTE_TARGET:$DEPLOY_PATH/$COMPOSE_FILE"
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "export CI_REGISTRY='${CI_REGISTRY:-registry.gitlab.com}' CI_REGISTRY_USER='$CI_REGISTRY_USER' CI_REGISTRY_PASSWORD='$CI_REGISTRY_PASSWORD' DEPLOY_PATH='$DEPLOY_PATH' COMPOSE_FILE='$COMPOSE_FILE' COMPOSE_SERVICE='$COMPOSE_SERVICE' APP_IMAGE='$APP_IMAGE' HEALTHCHECK_URL='$HEALTHCHECK_URL' DEPLOY_TIMEOUT='$DEPLOY_TIMEOUT' REMOTE_ENV_FILE='$REMOTE_ENV_FILE'; bash -s" <<'EOF'
set -Eeuo pipefail
cd "$DEPLOY_PATH"
if [[ -f .last_successful_image ]]; then
PREVIOUS_IMAGE="$(cat .last_successful_image)"
else
PREVIOUS_IMAGE=""
fi
if docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" ps -q "$COMPOSE_SERVICE" >/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

70
scripts/entrypoint.sh Executable file
View File

@@ -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}"

53
scripts/healthcheck.py Executable file
View File

@@ -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())

85
tests/conftest.py Normal file
View File

@@ -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

13
tests/test_health.py Normal file
View File

@@ -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

22
tests/test_portfolio.py Normal file
View File

@@ -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)

67
tests/test_pricing.py Normal file
View File

@@ -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)

94
tests/test_strategies.py Normal file
View File

@@ -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