Initial commit: Vault Dashboard for options hedging

- FastAPI + NiceGUI web application
- QuantLib-based Black-Scholes pricing with Greeks
- Protective put, laddered, and LEAPS strategies
- Real-time WebSocket updates
- TradingView-style charts via Lightweight-Charts
- Docker containerization
- GitLab CI/CD pipeline for VPS deployment
- VPN-only access configuration
This commit is contained in:
Bu5hm4nn
2026-03-21 19:21:40 +01:00
commit 00a68bc767
63 changed files with 6239 additions and 0 deletions

91
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
set -Eeuo pipefail
: "${DEPLOY_USER:?DEPLOY_USER is required}"
: "${DEPLOY_HOST:?DEPLOY_HOST is required}"
: "${CI_REGISTRY_IMAGE:?CI_REGISTRY_IMAGE is required}"
: "${CI_REGISTRY_USER:?CI_REGISTRY_USER is required}"
: "${CI_REGISTRY_PASSWORD:?CI_REGISTRY_PASSWORD is required}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/vault-dash}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.deploy.yml}"
COMPOSE_SERVICE="${COMPOSE_SERVICE:-vault-dash}"
DEPLOY_TIMEOUT="${DEPLOY_TIMEOUT:-120}"
HEALTHCHECK_URL="${HEALTHCHECK_URL:-http://127.0.0.1:${APP_PORT:-8000}/health}"
IMAGE_TAG="${IMAGE_TAG:-${CI_COMMIT_SHA}}"
APP_IMAGE="${APP_IMAGE:-${CI_REGISTRY_IMAGE}:${IMAGE_TAG}}"
REMOTE_ENV_FILE="${REMOTE_ENV_FILE:-$DEPLOY_PATH/.env}"
SSH_OPTS=(-p "$DEPLOY_PORT" -o StrictHostKeyChecking=no)
REMOTE_TARGET="${DEPLOY_USER}@${DEPLOY_HOST}"
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "mkdir -p '$DEPLOY_PATH'"
if [[ -n "${APP_ENV_FILE:-}" ]]; then
printf '%s\n' "$APP_ENV_FILE" | ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "cat > '$REMOTE_ENV_FILE'"
else
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "cat > '$REMOTE_ENV_FILE'" <<EOF
APP_IMAGE=$APP_IMAGE
APP_ENV=${APP_ENV:-production}
APP_NAME=${APP_NAME:-Vault Dashboard}
APP_PORT=${APP_PORT:-8000}
APP_BIND_ADDRESS=${APP_BIND_ADDRESS:-127.0.0.1}
REDIS_URL=${REDIS_URL:-}
DEFAULT_SYMBOL=${DEFAULT_SYMBOL:-GLD}
CACHE_TTL=${CACHE_TTL:-300}
WEBSOCKET_INTERVAL_SECONDS=${WEBSOCKET_INTERVAL_SECONDS:-5}
NICEGUI_MOUNT_PATH=${NICEGUI_MOUNT_PATH:-/}
NICEGUI_STORAGE_SECRET=${NICEGUI_STORAGE_SECRET:-}
CORS_ORIGINS=${CORS_ORIGINS:-*}
EOF
fi
scp "${SSH_OPTS[@]}" docker-compose.deploy.yml "$REMOTE_TARGET:$DEPLOY_PATH/$COMPOSE_FILE"
ssh "${SSH_OPTS[@]}" "$REMOTE_TARGET" "export CI_REGISTRY='${CI_REGISTRY:-registry.gitlab.com}' CI_REGISTRY_USER='$CI_REGISTRY_USER' CI_REGISTRY_PASSWORD='$CI_REGISTRY_PASSWORD' DEPLOY_PATH='$DEPLOY_PATH' COMPOSE_FILE='$COMPOSE_FILE' COMPOSE_SERVICE='$COMPOSE_SERVICE' APP_IMAGE='$APP_IMAGE' HEALTHCHECK_URL='$HEALTHCHECK_URL' DEPLOY_TIMEOUT='$DEPLOY_TIMEOUT' REMOTE_ENV_FILE='$REMOTE_ENV_FILE'; bash -s" <<'EOF'
set -Eeuo pipefail
cd "$DEPLOY_PATH"
if [[ -f .last_successful_image ]]; then
PREVIOUS_IMAGE="$(cat .last_successful_image)"
else
PREVIOUS_IMAGE=""
fi
if docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" ps -q "$COMPOSE_SERVICE" >/dev/null 2>&1; then
CURRENT_CONTAINER="$(docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" ps -q "$COMPOSE_SERVICE" || true)"
if [[ -n "$CURRENT_CONTAINER" ]]; then
CURRENT_IMAGE="$(docker inspect -f '{{ .Config.Image }}' "$CURRENT_CONTAINER")"
if [[ -n "$CURRENT_IMAGE" ]]; then
PREVIOUS_IMAGE="$CURRENT_IMAGE"
fi
fi
fi
echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
docker pull "$APP_IMAGE"
sed -i.bak "/^APP_IMAGE=/d" "$REMOTE_ENV_FILE"
printf 'APP_IMAGE=%s\n' "$APP_IMAGE" | cat - "$REMOTE_ENV_FILE.bak" > "$REMOTE_ENV_FILE"
rm -f "$REMOTE_ENV_FILE.bak"
docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" up -d --remove-orphans
end_time=$((SECONDS + DEPLOY_TIMEOUT))
until curl -fsS "$HEALTHCHECK_URL" >/dev/null; do
if (( SECONDS >= end_time )); then
echo "Deployment health check failed, attempting rollback" >&2
if [[ -n "$PREVIOUS_IMAGE" ]]; then
sed -i.bak "/^APP_IMAGE=/d" "$REMOTE_ENV_FILE"
printf 'APP_IMAGE=%s\n' "$PREVIOUS_IMAGE" | cat - "$REMOTE_ENV_FILE.bak" > "$REMOTE_ENV_FILE"
rm -f "$REMOTE_ENV_FILE.bak"
docker pull "$PREVIOUS_IMAGE" || true
docker compose -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE" up -d --remove-orphans
fi
exit 1
fi
sleep 5
done
echo "$APP_IMAGE" > .last_successful_image
EOF

70
scripts/entrypoint.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/sh
set -eu
APP_MODULE="${APP_MODULE:-app.main:app}"
APP_HOST="${APP_HOST:-0.0.0.0}"
APP_PORT="${APP_PORT:-8000}"
APP_ENV="${APP_ENV:-production}"
WORKERS="${UVICORN_WORKERS:-1}"
REDIS_WAIT_TIMEOUT="${REDIS_WAIT_TIMEOUT:-30}"
wait_for_redis() {
if [ -z "${REDIS_URL:-}" ]; then
echo "REDIS_URL not set, skipping Redis wait"
return 0
fi
echo "Waiting for Redis: ${REDIS_URL}"
python - <<'PY'
import os
import sys
import time
from urllib.parse import urlparse
redis_url = os.environ.get("REDIS_URL", "")
timeout = int(os.environ.get("REDIS_WAIT_TIMEOUT", "30"))
parsed = urlparse(redis_url)
host = parsed.hostname or "redis"
port = parsed.port or 6379
deadline = time.time() + timeout
while time.time() < deadline:
try:
import socket
with socket.create_connection((host, port), timeout=2):
print(f"Redis is reachable at {host}:{port}")
raise SystemExit(0)
except OSError:
time.sleep(1)
print(f"Timed out waiting for Redis at {host}:{port}", file=sys.stderr)
raise SystemExit(1)
PY
}
run_migrations() {
if [ "${RUN_MIGRATIONS:-0}" != "1" ]; then
echo "RUN_MIGRATIONS disabled, skipping"
return 0
fi
if [ -f alembic.ini ] && command -v alembic >/dev/null 2>&1; then
echo "Running Alembic migrations"
alembic upgrade head
return 0
fi
if [ -f manage.py ]; then
echo "Running Django migrations"
python manage.py migrate --noinput
return 0
fi
echo "No supported migration command found, skipping"
}
wait_for_redis
run_migrations
echo "Starting application on ${APP_HOST}:${APP_PORT}"
exec python -m uvicorn "${APP_MODULE}" --host "${APP_HOST}" --port "${APP_PORT}" --workers "${WORKERS}"

53
scripts/healthcheck.py Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Simple deployment health check utility."""
from __future__ import annotations
import argparse
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
import json
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Poll an HTTP health endpoint until it is ready.")
parser.add_argument("url", help="Health endpoint URL, e.g. https://vault.example.com/health")
parser.add_argument("--timeout", type=int, default=120, help="Maximum wait time in seconds")
parser.add_argument("--interval", type=int, default=5, help="Poll interval in seconds")
parser.add_argument("--expect-status", default="ok", help="Expected JSON status field")
parser.add_argument("--expect-environment", default=None, help="Optional expected JSON environment field")
return parser.parse_args()
def fetch_json(url: str) -> dict:
with urlopen(url, timeout=10) as response:
body = response.read().decode("utf-8")
return json.loads(body)
def main() -> int:
args = parse_args()
deadline = time.time() + args.timeout
last_error = "unknown error"
while time.time() < deadline:
try:
payload = fetch_json(args.url)
if payload.get("status") != args.expect_status:
raise RuntimeError(f"unexpected status: {payload!r}")
if args.expect_environment and payload.get("environment") != args.expect_environment:
raise RuntimeError(f"unexpected environment: {payload!r}")
print(f"healthcheck passed: {payload}")
return 0
except (HTTPError, URLError, TimeoutError, ValueError, RuntimeError) as exc:
last_error = str(exc)
time.sleep(args.interval)
print(f"healthcheck failed after {args.timeout}s: {last_error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())