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:
26
.dockerignore
Normal file
26
.dockerignore
Normal 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
4
.env.example
Normal 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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
.venv/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
config/secrets.yaml
|
||||||
|
data/cache/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
101
.gitlab-ci.yml
Normal file
101
.gitlab-ci.yml
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.11
|
||||||
620
DEPLOYMENT.md
Normal file
620
DEPLOYMENT.md
Normal 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
52
Dockerfile
Normal 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
21
LICENSE
Normal 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
17
Makefile
Normal 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
94
README.md
Normal 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
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Vault dashboard application package."""
|
||||||
28
app/api/routes.py
Normal file
28
app/api/routes.py
Normal 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)
|
||||||
13
app/components/__init__.py
Normal file
13
app/components/__init__.py
Normal 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
182
app/components/charts.py
Normal 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});
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
)
|
||||||
104
app/components/greeks_table.py
Normal file
104
app/components/greeks_table.py
Normal 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>'
|
||||||
|
)
|
||||||
68
app/components/portfolio_view.py
Normal file
68
app/components/portfolio_view.py
Normal 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;")
|
||||||
158
app/components/strategy_panel.py
Normal file
158
app/components/strategy_panel.py
Normal 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
21
app/core/__init__.py
Normal 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
98
app/core/calculations.py
Normal 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,
|
||||||
|
)
|
||||||
58
app/core/pricing/__init__.py
Normal file
58
app/core/pricing/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
|
)
|
||||||
194
app/core/pricing/american_pricing.py
Normal file
194
app/core/pricing/american_pricing.py
Normal 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,
|
||||||
|
)
|
||||||
210
app/core/pricing/black_scholes.py
Normal file
210
app/core/pricing/black_scholes.py
Normal 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,
|
||||||
|
)
|
||||||
127
app/core/pricing/volatility.py
Normal file
127
app/core/pricing/volatility.py
Normal 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
172
app/main.py
Normal 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
15
app/models/__init__.py
Normal 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
109
app/models/option.py
Normal 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
71
app/models/portfolio.py
Normal 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
101
app/models/strategy.py
Normal 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
3
app/pages/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from . import hedge, options, overview, settings
|
||||||
|
|
||||||
|
__all__ = ["overview", "hedge", "options", "settings"]
|
||||||
203
app/pages/common.py
Normal file
203
app/pages/common.py
Normal 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
126
app/pages/hedge.py
Normal 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
126
app/pages/options.py
Normal 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
66
app/pages/overview.py
Normal 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
58
app/pages/settings.py
Normal 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
72
app/services/cache.py
Normal 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")
|
||||||
145
app/services/data_service.py
Normal file
145
app/services/data_service.py
Normal 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,
|
||||||
|
}
|
||||||
17
app/strategies/__init__.py
Normal file
17
app/strategies/__init__.py
Normal 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
40
app/strategies/base.py
Normal 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
159
app/strategies/engine.py
Normal 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
|
||||||
129
app/strategies/laddered_put.py
Normal file
129
app/strategies/laddered_put.py
Normal 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
95
app/strategies/lease.py
Normal 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
|
||||||
139
app/strategies/protective_put.py
Normal file
139
app/strategies/protective_put.py
Normal 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
1
config/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
secrets.yaml
|
||||||
26
config/settings.example.yaml
Normal file
26
config/settings.example.yaml
Normal 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
26
config/settings.yaml
Normal 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
25
docker-compose.deploy.yml
Normal 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
63
docker-compose.prod.yml
Normal 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
48
docker-compose.yml
Normal 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
593
docs/API.md
Normal 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
437
docs/ARCHITECTURE.md
Normal 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
424
docs/STRATEGIES.md
Normal 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
32
pyproject.toml
Normal 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
6
requirements-dev.txt
Normal 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
10
requirements.txt
Normal 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
91
scripts/deploy.sh
Executable 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
70
scripts/entrypoint.sh
Executable 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
53
scripts/healthcheck.py
Executable 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
85
tests/conftest.py
Normal 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
13
tests/test_health.py
Normal 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
22
tests/test_portfolio.py
Normal 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
67
tests/test_pricing.py
Normal 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
94
tests/test_strategies.py
Normal 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
|
||||||
Reference in New Issue
Block a user