feat: launch NEXUS TERMINAL — Bloomberg-style FX options analytics platform

Full-stack forex options analytics terminal with Bloomberg-inspired UI.

Backend (FastAPI + Python):
- Garman-Kohlhagen options pricing engine with full Greeks
- Goldman Sachs gs-quant AI signals (RSI, MACD, Bollinger, Hurst, OU)
- Monte Carlo GBM simulation and volatility surface generation
- CFTC COT institutional positioning + Forex Factory economic calendar
- Live data proxy: OpenSky aircraft + USGS earthquakes (CORS-safe)
- Multi-leg strategy library (straddle, iron condor, butterfly, spreads)

Frontend (React 18 + Vite):
- NEXUS animated orbital logo (3-ring SVG) + canvas favicon animation
- Bloomberg terminal design: JetBrains Mono, color-mix() tokens
- 11 dashboard tabs: Greeks, Chart, AI Signals, 3D Surfaces, Breakeven,
  Scenarios, Monte Carlo, Institutional, Calendar, Live Map, Live Feeds
- Live World Map (react-leaflet): aircraft, earthquakes, weather radar
- Live Feeds: CoinGecko crypto top-12 + Windy.com global webcams
- Economic calendar with filters + institutional flow (CFTC COT)
- Animated landing page + session-based routing
- Fully responsive dark-only terminal design system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kansaram
2026-06-07 18:19:23 +05:30
commit 61e145a442
75 changed files with 13323 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Python Check
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Verify script executes without error
# Use a non-interactive matplotlib backend so plots don't open a window
env:
MPLBACKEND: Agg
run: |
cd src
python -c "
import sys, os
sys.path.insert(0, os.path.abspath('.'))
from config import *
from greeks import portfolio_greeks
from surface import risk_surface, pnl_surface
import numpy as np
S_range = np.linspace(SPOT_LOW, SPOT_HIGH, 10)
sigma_range = np.linspace(VOL_LOW, VOL_HIGH, 10)
D, G, V, Th = risk_surface(EXAMPLE_PORTFOLIO, S_range, sigma_range, DEFAULT_TIME_TO_EXPIRY, DEFAULT_RISK_FREE_RATE)
P = pnl_surface(EXAMPLE_PORTFOLIO, S_range, sigma_range, DEFAULT_TIME_TO_EXPIRY, DEFAULT_RISK_FREE_RATE, DEFAULT_SPOT, DEFAULT_VOLATILITY)
assert D.shape == (10, 10), 'Delta surface shape mismatch'
assert G.shape == (10, 10), 'Gamma surface shape mismatch'
assert V.shape == (10, 10), 'Vega surface shape mismatch'
assert Th.shape == (10, 10), 'Theta surface shape mismatch'
assert P.shape == (10, 10), 'PnL surface shape mismatch'
print('All checks passed.')
"
+61
View File
@@ -0,0 +1,61 @@
# ── Python ───────────────────────────────────────────
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
*.egg
*.whl
# Virtual environments
.venv/
venv/
env/
ENV/
.env
.env.*
!.env.example
# ── Node / Frontend ───────────────────────────────────
node_modules/
frontend/node_modules/
frontend/dist/
frontend/.vite/
*.local
# ── IDE / Editor ──────────────────────────────────────
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
desktop.ini
# ── Logs ─────────────────────────────────────────────
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
vite.log
# ── Testing / Coverage ────────────────────────────────
.coverage
htmlcov/
.pytest_cache/
.playwright-mcp/
# ── Generated / Temp ──────────────────────────────────
*.tmp
*.temp
src/plots/*.png
# ── Secrets ───────────────────────────────────────────
*.pem
*.key
secrets.json
credentials.json
+202
View File
@@ -0,0 +1,202 @@
<div align="center">
# ⚛ NEXUS TERMINAL
### Institutional-Grade FX Options Analytics Platform
[![Python](https://img.shields.io/badge/Python-3.11+-3776AB?style=flat-square&logo=python&logoColor=white)](https://python.org)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.110+-009688?style=flat-square&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com)
[![React](https://img.shields.io/badge/React-18+-61DAFB?style=flat-square&logo=react&logoColor=black)](https://react.dev)
[![Vite](https://img.shields.io/badge/Vite-5+-646CFF?style=flat-square&logo=vite&logoColor=white)](https://vitejs.dev)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE)
*Three orbital rings. One terminal. Every market.*
![Landing Page](screenshots/01-landing.png)
</div>
---
## What is NEXUS?
NEXUS TERMINAL is a Bloomberg-style web application for professional forex options analysis. It combines **Garman-Kohlhagen options pricing**, **Goldman Sachs gs-quant signals**, **live global data feeds**, and a **real-time world map** — all in one free, no-account-required platform.
![Dashboard](screenshots/02-dashboard.png)
---
## Features
### Options Analytics
| Module | Description |
|--------|-------------|
| **Greeks Dashboard** | Δ Delta · Γ Gamma · ν Vega · Θ Theta · ρ Rho · φ Phi — per leg and portfolio total via Garman-Kohlhagen (1983) |
| **Volatility Surfaces** | Interactive 3D surface plots (Delta, Gamma, Vega, Theta, P&L) across full spot × vol grid |
| **Breakeven Profile** | Payoff curve at expiry with breakeven strikes, max profit/loss, net premium |
| **Scenario Analysis** | Stress tests across spot ±5%/±10% and vol ±1pp/±2pp shocks |
| **Monte Carlo** | GBM path simulation — up to 5,000 paths, terminal P&L distribution, probability of profit |
| **Strategy Library** | Pre-built multi-leg strategies: straddle, strangle, bull/bear spreads, iron condor, butterfly |
### Live Intelligence
![AI Signals](screenshots/03-ai-signals.png)
| Module | Description |
|--------|-------------|
| **AI Quant Signals** | 9 signals: RSI, MACD, Bollinger Bands, Hurst exponent, mean reversion (OU), momentum, carry, volatility regime — powered by gs-quant |
| **Institutional Flow** | CFTC COT positioning — Asset Managers, Hedge Funds, Dealers — with Volume Profile, POC, VAH, VAL |
| **Economic Calendar** | Forex Factory events (120+ per week) with impact levels, forecasts, countdowns, and currency filters |
### Live World Map
![Live Map](screenshots/04-live-map.png)
| Layer | Source | Refresh |
|-------|--------|---------|
| ✈ Aircraft positions | OpenSky Network (free, anonymous) | 30s |
| 🔴 Earthquake markers | USGS GeoJSON feed (M4.5+, 7 days) | 5min |
| 🌦 Weather radar | RainViewer animated tiles | on-demand |
| 📍 Forex events | Forex Factory via backend cache | with calendar |
### Live Feeds
![Live Feeds](screenshots/05-live-feeds.png)
- **Crypto prices** — Top 12 by market cap via CoinGecko free API, 60s refresh
- **Global webcams** — Windy.com live camera feeds for 6 financial centers: New York · London · Tokyo · Frankfurt · Singapore · Dubai
### Economic Calendar
![Calendar](screenshots/06-calendar.png)
---
## Tech Stack
```
NEXUS TERMINAL
├── backend/ FastAPI (Python 3.11+)
│ └── app/
│ ├── core/ Garman-Kohlhagen · Greeks · Monte Carlo · Quant Analysis
│ └── routers/ REST API endpoints
│ ├── greeks.py Options pricing & Greeks
│ ├── surface.py Volatility surface grid
│ ├── montecarlo.py GBM simulation
│ ├── scenarios.py Stress testing
│ ├── forex.py Live FX rates
│ ├── institutional.py CFTC COT data
│ ├── news.py Forex Factory calendar
│ └── livedata.py Aircraft & earthquake proxy
└── frontend/ React 18 + Vite 5
└── src/
├── components/
│ ├── GreeksDashboard.jsx
│ ├── QuantSignals.jsx (gs-quant AI signals)
│ ├── WorldMap.jsx (Leaflet + live layers)
│ ├── LiveFeeds.jsx (Crypto + Webcams)
│ ├── EconomicCalendar.jsx
│ ├── InstitutionalFlow.jsx
│ └── NexusLogo.jsx (animated SVG + canvas favicon)
└── pages/
├── LandingPage.jsx
└── Dashboard.jsx
```
### Key Libraries
**Backend**
- `fastapi` — REST API framework
- `gs-quant` — Goldman Sachs quant library (RSI, MACD, Bollinger, volatility)
- `numpy` / `scipy` — Monte Carlo, OU process, Hurst exponent
- `httpx` — Async HTTP client for proxying live data
**Frontend**
- `react-leaflet` + `leaflet` — Interactive world map with live layers
- `plotly.js` — 3D volatility surface plots
- `zustand` — Global portfolio state management
- `JetBrains Mono` — Bloomberg-terminal monospace font
---
## Getting Started
### Prerequisites
- Python 3.11+
- Node.js 18+
### Backend
```bash
cd backend
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
API available at `http://localhost:8000` · Docs at `http://localhost:8000/docs`
### Frontend
```bash
cd frontend
npm install
npm run dev
```
App available at `http://localhost:5173`
---
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/greeks` | Compute portfolio Greeks (Garman-Kohlhagen) |
| `POST` | `/api/surface` | Generate volatility surface grid |
| `POST` | `/api/montecarlo` | Run GBM Monte Carlo simulation |
| `POST` | `/api/scenarios` | Stress-test scenario matrix |
| `GET` | `/api/forex/rates` | Live FX spot rates |
| `GET` | `/api/news/calendar` | Forex Factory economic calendar |
| `GET` | `/api/institutional/{pair}` | CFTC COT positioning data |
| `GET` | `/api/signals/{pair}` | AI quant signals (gs-quant) |
| `GET` | `/api/live/aircraft` | Live aircraft positions (OpenSky proxy) |
| `GET` | `/api/live/earthquakes` | USGS earthquake feed proxy |
---
## Design System
NEXUS uses a Bloomberg terminal-inspired design:
- **Colors** — Navy dark (`#080e1a`) base, cyan (`#00c8f0`) accent, semantic red/green/amber/purple
- **Typography** — JetBrains Mono for all data panels, `tabular-nums` for price columns
- **Panels** — 3px left-border accent system (worldmonitor-style)
- **Tokens** — `color-mix(in srgb, ...)` for semi-transparent semantic backgrounds
---
## Data Sources
| Source | Data | Cost |
|--------|------|------|
| Goldman Sachs gs-quant | RSI, MACD, Bollinger, volatility | Free (open-source) |
| OpenSky Network | Live aircraft positions | Free, anonymous |
| USGS Earthquake API | M4.5+ seismic events | Free |
| RainViewer | Weather radar tiles | Free |
| Forex Factory | Economic calendar (120+ events/week) | Free |
| CoinGecko | Crypto prices (top 12) | Free |
| Windy.com | Global webcam feeds | Free embed |
| Yahoo Finance (via yfinance) | FX historical rates | Free |
---
## License
MIT © 2026 — Built with ⚛ NEXUS TERMINAL
View File
View File
+49
View File
@@ -0,0 +1,49 @@
import numpy as np
from scipy.stats import norm
def _d1_d2(S, K, T, r, sigma):
S = np.asarray(S, dtype=float)
sqrt_T = np.sqrt(T)
d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrt_T)
d2 = d1 - sigma * sqrt_T
return d1, d2
def bs_price(S, K, T, r, sigma, option_type="call"):
d1, d2 = _d1_d2(S, K, T, r, sigma)
if option_type == "call":
return float(np.asarray(S) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2))
return float(K * np.exp(-r * T) * norm.cdf(-d2) - np.asarray(S) * norm.cdf(-d1))
def bs_delta(S, K, T, r, sigma, option_type="call"):
d1, _ = _d1_d2(S, K, T, r, sigma)
return float(norm.cdf(d1) if option_type == "call" else norm.cdf(d1) - 1.0)
def bs_gamma(S, K, T, r, sigma):
d1, _ = _d1_d2(S, K, T, r, sigma)
return float(norm.pdf(d1) / (np.asarray(S) * sigma * np.sqrt(T)))
def bs_vega(S, K, T, r, sigma):
d1, _ = _d1_d2(S, K, T, r, sigma)
return float(np.asarray(S) * norm.pdf(d1) * np.sqrt(T))
def bs_theta(S, K, T, r, sigma, option_type="call"):
d1, d2 = _d1_d2(S, K, T, r, sigma)
S = np.asarray(S)
decay = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T))
if option_type == "call":
return float(decay - r * K * np.exp(-r * T) * norm.cdf(d2))
return float(decay + r * K * np.exp(-r * T) * norm.cdf(-d2))
def bs_rho(S, K, T, r, sigma, option_type="call"):
"""Rho — sensitivity to interest rate changes."""
_, d2 = _d1_d2(S, K, T, r, sigma)
if option_type == "call":
return float(K * T * np.exp(-r * T) * norm.cdf(d2))
return float(-K * T * np.exp(-r * T) * norm.cdf(-d2))
+105
View File
@@ -0,0 +1,105 @@
"""
garman_kohlhagen.py — Garman-Kohlhagen (1983) model for European forex options.
Extension of Black-Scholes that accounts for BOTH the domestic and foreign
risk-free interest rates — essential for currency options pricing.
Reference: Garman, M.B. & Kohlhagen, S.W. (1983). "Foreign currency option values."
Journal of International Money and Finance, 2(3), 231237.
Model:
d1 = [ln(S/K) + (r_d r_f + σ²/2)·T] / (σ·√T)
d2 = d1 σ·√T
C = S·e^(r_f·T)·N(d1) K·e^(r_d·T)·N(d2)
P = K·e^(r_d·T)·N(d2) S·e^(r_f·T)·N(d1)
"""
import numpy as np
from scipy.stats import norm
def _d1_d2(S: float, K: float, T: float, r_d: float, r_f: float, sigma: float):
"""Compute GK d1 and d2 intermediate values."""
S = np.asarray(S, dtype=float)
sqrt_T = np.sqrt(T)
d1 = (np.log(S / K) + (r_d - r_f + 0.5 * sigma ** 2) * T) / (sigma * sqrt_T)
d2 = d1 - sigma * sqrt_T
return d1, d2
def gk_price(S, K, T, r_d, r_f, sigma, option_type="call"):
d1, d2 = _d1_d2(S, K, T, r_d, r_f, sigma)
S = np.asarray(S, dtype=float)
if option_type == "call":
return float(S * np.exp(-r_f * T) * norm.cdf(d1) - K * np.exp(-r_d * T) * norm.cdf(d2))
return float(K * np.exp(-r_d * T) * norm.cdf(-d2) - S * np.exp(-r_f * T) * norm.cdf(-d1))
def gk_delta(S, K, T, r_d, r_f, sigma, option_type="call"):
"""
Delta — sensitivity of option price to spot rate change.
Call: e^(r_f·T)·N(d1) Put: e^(r_f·T)·N(d1)
"""
d1, _ = _d1_d2(S, K, T, r_d, r_f, sigma)
factor = np.exp(-r_f * T)
if option_type == "call":
return float(factor * norm.cdf(d1))
return float(-factor * norm.cdf(-d1))
def gk_gamma(S, K, T, r_d, r_f, sigma):
"""
Gamma — rate of change of delta.
Γ = e^(r_f·T)·N'(d1) / (S·σ·√T) (same sign for calls and puts)
"""
d1, _ = _d1_d2(S, K, T, r_d, r_f, sigma)
return float(np.exp(-r_f * T) * norm.pdf(d1) / (np.asarray(S) * sigma * np.sqrt(T)))
def gk_vega(S, K, T, r_d, r_f, sigma):
"""
Vega — sensitivity to implied volatility.
ν = S·e^(r_f·T)·N'(d1)·√T (same for calls and puts)
"""
d1, _ = _d1_d2(S, K, T, r_d, r_f, sigma)
return float(np.asarray(S) * np.exp(-r_f * T) * norm.pdf(d1) * np.sqrt(T))
def gk_theta(S, K, T, r_d, r_f, sigma, option_type="call"):
"""
Theta — time decay (per year).
Call: S·σ·e^(r_f·T)·N'(d1)/(2√T) r_d·K·e^(r_d·T)·N(d2) + r_f·S·e^(r_f·T)·N(d1)
Put: S·σ·e^(r_f·T)·N'(d1)/(2√T) + r_d·K·e^(r_d·T)·N(d2) r_f·S·e^(r_f·T)·N(d1)
"""
d1, d2 = _d1_d2(S, K, T, r_d, r_f, sigma)
S = np.asarray(S, dtype=float)
decay = -(S * sigma * np.exp(-r_f * T) * norm.pdf(d1)) / (2 * np.sqrt(T))
if option_type == "call":
return float(decay - r_d * K * np.exp(-r_d * T) * norm.cdf(d2)
+ r_f * S * np.exp(-r_f * T) * norm.cdf(d1))
return float(decay + r_d * K * np.exp(-r_d * T) * norm.cdf(-d2)
- r_f * S * np.exp(-r_f * T) * norm.cdf(-d1))
def gk_rho_d(S, K, T, r_d, r_f, sigma, option_type="call"):
"""
Rho_d — sensitivity to DOMESTIC interest rate.
Call: K·T·e^(r_d·T)·N(d2) Put: K·T·e^(r_d·T)·N(d2)
"""
_, d2 = _d1_d2(S, K, T, r_d, r_f, sigma)
factor = K * T * np.exp(-r_d * T)
if option_type == "call":
return float(factor * norm.cdf(d2))
return float(-factor * norm.cdf(-d2))
def gk_phi(S, K, T, r_d, r_f, sigma, option_type="call"):
"""
Phi (ρ_f) — sensitivity to FOREIGN interest rate.
Call: S·T·e^(r_f·T)·N(d1) Put: S·T·e^(r_f·T)·N(d1)
"""
d1, _ = _d1_d2(S, K, T, r_d, r_f, sigma)
factor = np.asarray(S) * T * np.exp(-r_f * T)
if option_type == "call":
return float(-factor * norm.cdf(d1))
return float(factor * norm.cdf(-d1))
+41
View File
@@ -0,0 +1,41 @@
"""greeks.py — Portfolio-level Greek aggregation using Garman-Kohlhagen model."""
from .garman_kohlhagen import (
gk_delta, gk_gamma, gk_vega, gk_theta, gk_rho_d, gk_phi
)
def portfolio_greeks(
options: list[dict], S: float, sigma: float,
T: float, r_d: float, r_f: float
) -> dict:
"""Aggregate GK Greeks across all legs of a forex options portfolio."""
total = {"delta": 0.0, "gamma": 0.0, "vega": 0.0,
"theta": 0.0, "rho_d": 0.0, "phi": 0.0}
legs = []
for opt in options:
otype = opt["type"]
K, qty = float(opt["K"]), float(opt["qty"])
leg_T = float(opt.get("T", T))
d = gk_delta(S, K, leg_T, r_d, r_f, sigma, otype) * qty
g = gk_gamma(S, K, leg_T, r_d, r_f, sigma) * qty
v = gk_vega(S, K, leg_T, r_d, r_f, sigma) * qty
th = gk_theta(S, K, leg_T, r_d, r_f, sigma, otype) * qty
rho = gk_rho_d(S, K, leg_T, r_d, r_f, sigma, otype) * qty
phi = gk_phi(S, K, leg_T, r_d, r_f, sigma, otype) * qty
total["delta"] += d; total["gamma"] += g; total["vega"] += v
total["theta"] += th; total["rho_d"] += rho; total["phi"] += phi
legs.append({
"label": f"{'+'if qty>0 else ''}{int(qty)} {otype.upper()} K={K}",
"delta": round(d, 5), "gamma": round(g, 7),
"vega": round(v, 4), "theta": round(th, 4),
})
return {
"total": {k: round(v, 6) for k, v in total.items()},
"legs": legs,
}
+44
View File
@@ -0,0 +1,44 @@
"""montecarlo.py — GBM Monte Carlo for forex options (uses GK cost basis)."""
import numpy as np
from .garman_kohlhagen import gk_price
def run_montecarlo(options, S0, sigma, r_d, r_f, T, n_paths=1000, n_steps=100):
"""Simulate GBM price paths and compute P&L distribution at expiry."""
dt = T / n_steps
Z = np.random.standard_normal((n_paths, n_steps))
paths = np.zeros((n_paths, n_steps + 1)); paths[:,0] = S0
# GBM: dS = (r_d - r_f)·S·dt + σ·S·dW (Garman-Kohlhagen drift)
for t in range(1, n_steps + 1):
paths[:,t] = paths[:,t-1] * np.exp(
(r_d - r_f - 0.5*sigma**2)*dt + sigma*np.sqrt(dt)*Z[:,t-1]
)
terminal = paths[:,-1]
pnl = np.zeros(n_paths)
for opt in options:
K, qty = float(opt["K"]), float(opt["qty"])
payoff = (np.maximum(terminal - K, 0) if opt["type"]=="call"
else np.maximum(K - terminal, 0))
pnl += payoff * qty
# Subtract initial GK cost
cost = sum(
gk_price(S0, float(o["K"]), float(o.get("T",T)), r_d, r_f, sigma, o["type"])
* float(o["qty"]) for o in options
)
pnl -= cost
idx = np.random.choice(n_paths, size=min(60, n_paths), replace=False)
return {
"time_axis": [round(i*dt, 4) for i in range(n_steps+1)],
"sample_paths": paths[idx].tolist(),
"pnl": pnl.tolist(),
"pnl_mean": round(float(pnl.mean()), 5),
"pnl_std": round(float(pnl.std()), 5),
"pnl_5pct": round(float(np.percentile(pnl, 5)), 5),
"pnl_95pct": round(float(np.percentile(pnl, 95)), 5),
"prob_profit": round(float((pnl > 0).mean()), 4),
}
+274
View File
@@ -0,0 +1,274 @@
"""
quant_analysis.py — Quantitative signals for forex pairs.
Core analytics powered by:
• gs-quant (Goldman Sachs, 2024) — volatility, RSI, MACD, Bollinger Bands,
max drawdown, z-scores, rolling statistics
• Custom implementations for models not in gs-quant:
1. EWMA Vol Forecast — RiskMetrics λ=0.94 (JP Morgan, 1994)
2. VaR / CVaR — Parametric Normal + Historical ES (Basel III)
3. Hurst Exponent — Variance-scaling (Hurst 1951)
4. Ornstein-Uhlenbeck — OLS AR(1) (Uhlenbeck & Ornstein 1930)
5. Carry Signal — Uncovered Interest Parity (Fama 1984)
6. Momentum — Price momentum (Jegadeesh & Titman 1993)
"""
import numpy as np
import pandas as pd
from scipy import stats
# GS-Quant timeseries — all work offline, no credentials required
from gs_quant.timeseries import econometrics as gseco
from gs_quant.timeseries import statistics as gsstat
from gs_quant.timeseries import technicals as gstech
# ─── helpers ──────────────────────────────────────────────────────────────────
def _to_series(prices) -> pd.Series:
arr = np.array(prices, dtype=float)
idx = pd.date_range(end=pd.Timestamp.today().normalize(), periods=len(arr), freq='D')
return pd.Series(arr, index=idx)
def _safe_last(series: pd.Series, default=0.0) -> float:
try:
v = series.dropna()
return float(v.iloc[-1]) if len(v) else default
except Exception:
return default
def _hurst_exponent(prices: np.ndarray) -> float:
"""Variance-scaling H estimator: Var[ΔX_τ] ~ τ^(2H). (Hurst 1951)"""
prices = np.array(prices, dtype=float)
max_lag = min(len(prices) // 3, 30)
if max_lag < 3:
return 0.5
lags = range(2, max_lag)
variances = [np.var(np.diff(prices, n=lag)) for lag in lags]
try:
slope, *_ = stats.linregress(np.log(list(lags)), np.log(variances))
return float(np.clip(slope / 2, 0.01, 0.99))
except Exception:
return 0.5
def _estimate_ou_params(prices: np.ndarray) -> dict:
"""OLS AR(1) → OU parameters. (Uhlenbeck & Ornstein 1930)"""
X = np.array(prices, dtype=float)
Xl, Xc = X[:-1], X[1:]
n = len(Xl)
b_num = n * np.dot(Xl, Xc) - Xl.sum() * Xc.sum()
b_den = n * np.dot(Xl, Xl) - Xl.sum() ** 2
b = float(np.clip(b_num / b_den if b_den else 0.999, 0.001, 0.9999))
a = float(Xc.mean() - b * Xl.mean())
resid_sigma = float(np.std(Xc - (a + b * Xl)) * np.sqrt(252))
kappa = float(-np.log(b) * 252)
theta = float(a / (1 - b))
half_life = float(np.log(2) / max(kappa, 1e-6) / 252 * 365)
return {"kappa": round(kappa, 4), "theta": round(theta, 6),
"sigma": round(resid_sigma, 6), "half_life_days": round(half_life, 1)}
# ─── main signal engine ───────────────────────────────────────────────────────
def compute_signals(prices: list, r_d: float = 0.05, r_f: float = 0.04) -> dict:
"""
Full quant signal suite — core analytics via gs-quant (Goldman Sachs),
extended with OU, Hurst, VaR and carry trade signals.
"""
px_raw = np.array(prices, dtype=float)
n = len(px_raw)
if n < 10:
return {"error": "Need ≥10 observations."}
px = _to_series(px_raw)
w20 = min(20, n)
w60 = min(60, n)
rets_raw = np.diff(np.log(px_raw))
# ── 1. Volatility (gs-quant econometrics.volatility) ────────────────────
# GS implementation: annualized realized vol over rolling window
hv20_s = gseco.volatility(px, w20)
hv60_s = gseco.volatility(px, w60)
hv20 = _safe_last(hv20_s, np.std(rets_raw[-w20:]) * np.sqrt(252))
hv60 = _safe_last(hv60_s, np.std(rets_raw[-w60:]) * np.sqrt(252))
vol_regime = ("HIGH" if hv20 > hv60 * 1.25 else
"LOW" if hv20 < hv60 * 0.80 else "NORMAL")
# EWMA forecast (RiskMetrics λ=0.94, JP Morgan 1994)
lam, ewma_var = 0.94, float(np.var(rets_raw[-w20:]))
for r in rets_raw[-w20:]:
ewma_var = lam * ewma_var + (1 - lam) * r ** 2
ewma_vol = float(np.sqrt(ewma_var * 252))
# GS max-drawdown (institutional risk metric)
dd_s = gseco.max_drawdown(px, w60)
max_dd = _safe_last(dd_s, -0.01)
# ── 2. VaR / CVaR (custom — Parametric Normal + Basel III) ─────────────
mu_d, sig_d = float(np.mean(rets_raw[-w20:])), float(np.std(rets_raw[-w20:]))
var95 = float(-(mu_d + sig_d * stats.norm.ppf(0.05)))
var99 = float(-(mu_d + sig_d * stats.norm.ppf(0.01)))
tail = np.sort(rets_raw[-w60:])[:max(1, int(0.05 * w60))]
cvar95 = float(-np.mean(tail))
# ── 3. Hurst Exponent (custom — Hurst 1951) ─────────────────────────────
hurst = _hurst_exponent(px_raw[-w60:])
hurst_regime = ("MEAN-REVERTING" if hurst < 0.45 else
"TRENDING" if hurst > 0.55 else "RANDOM WALK")
hurst_action = {"MEAN-REVERTING": "FADE extreme moves — OU strategies apply",
"TRENDING": "FOLLOW momentum — trend-following applies",
"RANDOM WALK": "No structural edge — vol strategies apply"}[hurst_regime]
# ── 4. Ornstein-Uhlenbeck (custom — Uhlenbeck & Ornstein 1930) ──────────
ou = _estimate_ou_params(px_raw[-w60:])
ou_std = ou["sigma"] / np.sqrt(max(ou["kappa"], 0.01) * 252)
# Z-score via gs-quant statistics (institutional grade)
z_s = gsstat.zscores(px, w20)
gs_z = _safe_last(z_s, 0.0)
ou_z = float((px_raw[-1] - ou["theta"]) / max(ou_std, 1e-9))
ou_signal = "SELL" if ou_z > 2 else ("BUY" if ou_z < -2 else "NEUTRAL")
ou_conf = min(92, 50 + int(abs(ou_z) * 15)) if ou_signal != "NEUTRAL" else 40
# ── 5. RSI (gs-quant technicals.relative_strength_index) ────────────────
rsi_s = gstech.relative_strength_index(px, 14)
rsi = _safe_last(rsi_s, 50.0)
rsi_signal = ("OVERBOUGHT" if rsi > 70 else
"OVERSOLD" if rsi < 30 else "NEUTRAL")
rsi_bias = ("BEARISH" if rsi > 70 else
"BULLISH" if rsi < 30 else "NEUTRAL")
# ── 6. MACD (gs-quant technicals.macd) ──────────────────────────────────
macd_s = gstech.macd(px)
macd = _safe_last(macd_s, 0.0)
macd_signal = "BULLISH" if macd > 0 else "BEARISH"
# ── 7. Bollinger Bands (gs-quant technicals.bollinger_bands) ────────────
bb_s = gstech.bollinger_bands(px, w20)
current_px = float(px_raw[-1])
sma_s = gstech.moving_average(px, w20)
sma = _safe_last(sma_s, current_px)
std_s = gsstat.std(px, w20)
gsstd = _safe_last(std_s, float(np.std(px_raw[-w20:])))
bb_upper = sma + 2.0 * gsstd
bb_lower = sma - 2.0 * gsstd
bb_pct = float((current_px - bb_lower) / max(bb_upper - bb_lower, 1e-9)) # 0=lower,1=upper
bb_signal = ("NEAR UPPER BAND" if bb_pct > 0.85 else
"NEAR LOWER BAND" if bb_pct < 0.15 else "MID BAND")
bb_bias = ("BEARISH" if bb_pct > 0.85 else
"BULLISH" if bb_pct < 0.15 else "NEUTRAL")
# ── 8. Momentum (Jegadeesh & Titman 1993) ───────────────────────────────
ret5 = float(px_raw[-1] / px_raw[max(-5, -n)] - 1) if n >= 5 else 0.0
ret20 = float(px_raw[-1] / px_raw[max(-20, -n)] - 1) if n >= 20 else 0.0
mom_signal = ("BULLISH" if ret5 > 0 and ret20 > 0 else
"BEARISH" if ret5 < 0 and ret20 < 0 else "MIXED")
# ── 9. Carry (Uncovered Interest Parity, Fama 1984) ─────────────────────
carry_diff = r_d - r_f
carry_signal = ("BUY BASE" if carry_diff > 0.005 else
"SELL BASE" if carry_diff < -0.005 else "NEUTRAL")
# ── Composite signal (8 inputs, gs-quant enhanced) ────────────────────────
bull = sum([
ret5 > 0, ret20 > 0,
ou_signal == "BUY",
carry_signal == "BUY BASE",
macd_signal == "BULLISH",
rsi_bias == "BULLISH",
bb_bias == "BULLISH",
hurst_regime == "TRENDING" and ret5 > 0,
])
bear = sum([
ret5 < 0, ret20 < 0,
ou_signal == "SELL",
carry_signal == "SELL BASE",
macd_signal == "BEARISH",
rsi_bias == "BEARISH",
bb_bias == "BEARISH",
hurst_regime == "TRENDING" and ret5 < 0,
])
if bull >= bear + 3:
composite, conf = "BULLISH", min(95, 50 + bull * 6)
elif bear >= bull + 3:
composite, conf = "BEARISH", min(95, 50 + bear * 6)
elif bull > bear:
composite, conf = "BULLISH", min(70, 50 + bull * 4)
elif bear > bull:
composite, conf = "BEARISH", min(70, 50 + bear * 4)
else:
composite, conf = "NEUTRAL", 45
return {
"composite": composite,
"confidence": conf,
"n_observations": n,
"powered_by": "gs-quant (Goldman Sachs) + custom quant models",
"volatility": {
"hv_20_pct": round(hv20, 2),
"hv_60_pct": round(hv60, 2),
"regime": vol_regime,
"ewma_forecast_pct": round(ewma_vol * 100, 2),
"max_drawdown_pct": round(max_dd * 100, 2),
"method": "gs-quant econometrics.volatility() + EWMA λ=0.94 (RiskMetrics 1994)",
},
"risk": {
"var_95_pct": round(var95 * 100, 3),
"var_99_pct": round(var99 * 100, 3),
"cvar_95_pct": round(cvar95 * 100, 3),
"method": "Parametric Normal VaR / Historical CVaR (Basel III)",
},
"hurst": {
"exponent": round(hurst, 3),
"regime": hurst_regime,
"action": hurst_action,
"method": "Variance-scaling estimator (Hurst 1951)",
},
"mean_reversion": {
"kappa": ou["kappa"],
"theta": ou["theta"],
"half_life_days": ou["half_life_days"],
"zscore": round(ou_z, 2),
"gs_zscore": round(gs_z, 2),
"signal": ou_signal,
"confidence": ou_conf,
"method": "OLS AR(1) → OU SDE (Uhlenbeck & Ornstein 1930) + gs-quant zscores",
},
"rsi": {
"value": round(rsi, 2),
"signal": rsi_signal,
"bias": rsi_bias,
"method": "gs-quant technicals.relative_strength_index(14) (Wilder 1978)",
},
"macd": {
"value": round(macd, 6),
"signal": macd_signal,
"method": "gs-quant technicals.macd() — 12/26/9 EMA crossover (Appel 1979)",
},
"bollinger": {
"upper": round(bb_upper, 5),
"lower": round(bb_lower, 5),
"sma": round(sma, 5),
"pct_b": round(bb_pct, 3),
"signal": bb_signal,
"bias": bb_bias,
"method": "gs-quant technicals.bollinger_bands(20,2σ) (Bollinger 1983)",
},
"momentum": {
"return_5d_pct": round(ret5 * 100, 3),
"return_20d_pct": round(ret20 * 100, 3),
"signal": mom_signal,
"method": "Price momentum (Jegadeesh & Titman 1993)",
},
"carry": {
"r_d": round(r_d * 100, 2),
"r_f": round(r_f * 100, 2),
"differential_pct": round(carry_diff * 100, 2),
"signal": carry_signal,
"method": "Uncovered Interest Parity (Fama 1984)",
},
}
+33
View File
@@ -0,0 +1,33 @@
"""surface.py — 2-D risk surface computation over spot × vol grid."""
import numpy as np
from .greeks import portfolio_greeks
from .garman_kohlhagen import gk_price
def compute_surfaces(options, S_range, sigma_range, T, r_d, r_f):
"""Compute Delta, Gamma, Vega, Theta, and P&L surfaces."""
n_s, n_v = len(S_range), len(sigma_range)
D = np.zeros((n_s, n_v)); G = np.zeros((n_s, n_v))
V = np.zeros((n_s, n_v)); Th = np.zeros((n_s, n_v))
for i, S in enumerate(S_range):
for j, sigma in enumerate(sigma_range):
g = portfolio_greeks(options, S, sigma, T, r_d, r_f)["total"]
D[i,j]=g["delta"]; G[i,j]=g["gamma"]
V[i,j]=g["vega"]; Th[i,j]=g["theta"]
# P&L via Delta-Gamma approx around grid midpoint
S0 = S_range[len(S_range)//2]
sig0 = sigma_range[len(sigma_range)//2]
base = portfolio_greeks(options, S0, sig0, T, r_d, r_f)["total"]
PnL = np.zeros((n_s, n_v))
for i, S in enumerate(S_range):
dS = S - S0
PnL[i,:] = base["delta"]*dS + 0.5*base["gamma"]*dS**2
return {
"S_range": S_range.tolist(), "sigma_range": sigma_range.tolist(),
"delta": D.tolist(), "gamma": G.tolist(),
"vega": V.tolist(), "theta": Th.tolist(), "pnl": PnL.tolist(),
}
+17
View File
@@ -0,0 +1,17 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata
app = FastAPI(title="QuantRisk FX Terminal API",
description="Forex options risk analytics — Garman-Kohlhagen model.",
version="3.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata]:
app.include_router(router.router, prefix="/api")
@app.get("/")
def root():
return {"status": "ok", "version": "3.0.0", "model": "Garman-Kohlhagen (1983)", "docs": "/docs"}
View File
+43
View File
@@ -0,0 +1,43 @@
import io, csv
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from ..schemas import GreeksRequest
from ..core.greeks import portfolio_greeks
from ..core.garman_kohlhagen import gk_price
router = APIRouter(prefix="/export", tags=["export"])
@router.post("/csv")
def export_csv(req: GreeksRequest):
options = [o.model_dump() for o in req.options]
result = portfolio_greeks(options, req.S, req.sigma, req.T, req.r_d, req.r_f)
out = io.StringIO()
w = csv.writer(out)
w.writerow(["QUANTRISK FX — GARMAN-KOHLHAGEN GREEKS REPORT"])
w.writerow(["Spot", req.S, "Sigma", req.sigma, "T", req.T,
"r_d", req.r_d, "r_f", req.r_f])
w.writerow([])
w.writerow(["PORTFOLIO TOTALS"])
w.writerow(["Greek", "Value", "Description"])
desc = {"delta":"Price sensitivity","gamma":"Delta curvature","vega":"Vol sensitivity",
"theta":"Time decay/yr","rho_d":"Dom rate sensitivity","phi":"For rate sensitivity"}
for k, v in result["total"].items():
w.writerow([k.upper(), v, desc.get(k,"")])
w.writerow([])
w.writerow(["LEG BREAKDOWN"])
w.writerow(["Leg","Delta","Gamma","Vega","Theta"])
for leg in result["legs"]:
w.writerow([leg["label"],leg["delta"],leg["gamma"],leg["vega"],leg["theta"]])
w.writerow([])
w.writerow(["OPTION PRICES (Garman-Kohlhagen)"])
w.writerow(["Leg","GK Price"])
for opt in options:
price = gk_price(req.S, opt["K"], opt.get("T",req.T), req.r_d, req.r_f,
req.sigma, opt["type"])
w.writerow([f"{opt['type'].upper()} K={opt['K']} qty={opt['qty']}", round(price,5)])
out.seek(0)
return StreamingResponse(
io.BytesIO(out.getvalue().encode()),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=gk_greeks_report.csv"},
)
+147
View File
@@ -0,0 +1,147 @@
"""forex.py — Live forex data and quant signal endpoints via yfinance."""
import numpy as np
import yfinance as yf
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..core.quant_analysis import compute_signals
router = APIRouter(prefix="/forex", tags=["forex"])
# Supported pairs: yfinance symbol → display label + default rates
PAIRS = {
"EURUSD": {"sym": "EURUSD=X", "r_d": 0.0525, "r_f": 0.0400, "pip": 0.0001},
"GBPUSD": {"sym": "GBPUSD=X", "r_d": 0.0525, "r_f": 0.0525, "pip": 0.0001},
"USDJPY": {"sym": "USDJPY=X", "r_d": 0.0010, "r_f": 0.0525, "pip": 0.01},
"USDCHF": {"sym": "USDCHF=X", "r_d": 0.0175, "r_f": 0.0525, "pip": 0.0001},
"AUDUSD": {"sym": "AUDUSD=X", "r_d": 0.0525, "r_f": 0.0435, "pip": 0.0001},
"USDCAD": {"sym": "USDCAD=X", "r_d": 0.0500, "r_f": 0.0525, "pip": 0.0001},
"NZDUSD": {"sym": "NZDUSD=X", "r_d": 0.0525, "r_f": 0.0550, "pip": 0.0001},
"EURJPY": {"sym": "EURJPY=X", "r_d": 0.0010, "r_f": 0.0400, "pip": 0.01},
"GBPJPY": {"sym": "GBPJPY=X", "r_d": 0.0010, "r_f": 0.0525, "pip": 0.01},
"EURGBP": {"sym": "EURGBP=X", "r_d": 0.0525, "r_f": 0.0400, "pip": 0.0001},
"XAUUSD": {"sym": "GC=F", "r_d": 0.0525, "r_f": 0.0000, "pip": 0.01},
}
def _fetch_rate(sym: str) -> dict:
t = yf.Ticker(sym)
fi = t.fast_info
spot = fi.last_price
prev = fi.previous_close
if not spot:
return None
change = round((spot - prev) / prev * 100, 3) if prev else 0.0
return {"spot": round(float(spot), 5), "prev": round(float(prev), 5) if prev else None,
"change_pct": change}
@router.get("/pairs")
def list_pairs():
"""Return metadata for all supported forex pairs."""
return [{"pair": k, **{f: v for f, v in meta.items() if f != "sym"}}
for k, meta in PAIRS.items()]
@router.get("/rates")
def all_rates():
"""Fetch current rates for all major pairs (bulk call)."""
results = []
for pair, meta in PAIRS.items():
try:
data = _fetch_rate(meta["sym"])
if data:
results.append({"pair": pair, **data,
"r_d": meta["r_d"], "r_f": meta["r_f"]})
except Exception:
pass
return results
@router.get("/rate/{pair}")
def get_rate(pair: str):
"""Current spot rate + 24h change for a single pair."""
pair = pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'. Supported: {list(PAIRS)}")
meta = PAIRS[pair]
data = _fetch_rate(meta["sym"])
if not data:
raise HTTPException(503, "Rate unavailable from data provider.")
return {"pair": pair, **data, "r_d": meta["r_d"], "r_f": meta["r_f"],
"pip": meta["pip"]}
@router.get("/ohlc/{pair}")
def get_ohlc(pair: str, interval: str = "5m", period: str = "2d"):
"""
OHLC candlestick data for a pair.
interval: 1m 5m 15m 30m 1h 4h 1d
period: 1d 2d 5d 1mo
"""
pair = pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
sym = PAIRS[pair]["sym"]
valid_intervals = {"1m", "5m", "15m", "30m", "1h", "4h", "1d"}
if interval not in valid_intervals:
interval = "5m"
try:
hist = yf.download(sym, period=period, interval=interval,
progress=False, auto_adjust=True)
if hist.empty:
raise HTTPException(503, "No OHLC data returned.")
hist = hist.dropna()
# Flatten MultiIndex columns if present
if isinstance(hist.columns, type(hist.columns)) and hasattr(hist.columns, 'droplevel'):
try:
hist.columns = hist.columns.droplevel(1)
except Exception:
pass
return {
"pair": pair,
"interval": interval,
"dates": [str(d) for d in hist.index],
"open": [round(float(v), 5) for v in hist["Open"]],
"high": [round(float(v), 5) for v in hist["High"]],
"low": [round(float(v), 5) for v in hist["Low"]],
"close": [round(float(v), 5) for v in hist["Close"]],
"volume": [int(v) for v in hist.get("Volume", [0]*len(hist))],
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(503, f"Data fetch failed: {e}")
@router.get("/signals/{pair}")
def get_signals(pair: str):
"""
Compute full quant signal suite using 60 days of daily closes.
Signals: EWMA vol, VaR/CVaR, Hurst exponent, OU mean-reversion,
momentum, carry — all with academic citations.
"""
pair = pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
meta = PAIRS[pair]
try:
hist = yf.download(meta["sym"], period="90d", interval="1d",
progress=False, auto_adjust=True)
if hist.empty or len(hist) < 10:
raise HTTPException(503, "Insufficient history for signal computation.")
if isinstance(hist.columns, type(hist.columns)) and hasattr(hist.columns, 'droplevel'):
try:
hist.columns = hist.columns.droplevel(1)
except Exception:
pass
closes = [float(v) for v in hist["Close"].dropna()]
signals = compute_signals(closes, r_d=meta["r_d"], r_f=meta["r_f"])
signals["pair"] = pair
signals["current_price"] = round(closes[-1], 5)
return signals
except HTTPException:
raise
except Exception as e:
raise HTTPException(503, f"Signal computation failed: {e}")
+10
View File
@@ -0,0 +1,10 @@
from fastapi import APIRouter
from ..schemas import GreeksRequest
from ..core.greeks import portfolio_greeks
router = APIRouter(prefix="/greeks", tags=["greeks"])
@router.post("")
def compute_greeks(req: GreeksRequest):
options = [o.model_dump() for o in req.options]
return portfolio_greeks(options, req.S, req.sigma, req.T, req.r_d, req.r_f)
+270
View File
@@ -0,0 +1,270 @@
"""
institutional.py — Free institutional flow data.
Sources:
1. CFTC TFF (Traders in Financial Futures) — Socrata API on publicreporting.cftc.gov
Dataset: gpe5-46if (no API key needed, weekly, official CFTC data)
Categories: Dealers, Asset Managers, Leveraged Money (hedge funds), Other, Non-reportable
2. Volume Profile — approximated from yfinance OHLCV daily bars
(distributes bar volume proportionally across the High-Low range)
"""
import numpy as np
import httpx
import urllib.parse
import yfinance as yf
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/institutional", tags=["institutional"])
CFTC_BASE = "https://publicreporting.cftc.gov"
CFTC_DATASET = "gpe5-46if"
# Map forex pair → CFTC market name + yfinance symbol
COT_MAP = {
"EURUSD": {"cot": "EURO FX - CHICAGO MERCANTILE EXCHANGE", "sym": "EURUSD=X"},
"GBPUSD": {"cot": "BRITISH POUND STERLING - CHICAGO MERCANTILE EXCHANGE", "sym": "GBPUSD=X"},
"USDJPY": {"cot": "JAPANESE YEN - CHICAGO MERCANTILE EXCHANGE", "sym": "USDJPY=X"},
"AUDUSD": {"cot": "AUSTRALIAN DOLLAR - CHICAGO MERCANTILE EXCHANGE", "sym": "AUDUSD=X"},
"USDCAD": {"cot": "CANADIAN DOLLAR - CHICAGO MERCANTILE EXCHANGE", "sym": "USDCAD=X"},
"NZDUSD": {"cot": "NEW ZEALAND DOLLAR - CHICAGO MERCANTILE EXCHANGE", "sym": "NZDUSD=X"},
"USDCHF": {"cot": "SWISS FRANC - CHICAGO MERCANTILE EXCHANGE", "sym": "USDCHF=X"},
"EURJPY": {"cot": "EURO FX - CHICAGO MERCANTILE EXCHANGE", "sym": "EURJPY=X"},
"GBPJPY": {"cot": "BRITISH POUND STERLING - CHICAGO MERCANTILE EXCHANGE", "sym": "GBPJPY=X"},
"EURGBP": {"cot": "EURO FX - CHICAGO MERCANTILE EXCHANGE", "sym": "EURGBP=X"},
"XAUUSD": {"cot": "GOLD - COMMODITY EXCHANGE INC.", "sym": "GC=F"},
}
def _safe_int(v) -> int:
try: return int(v or 0)
except: return 0
def _safe_float(v) -> float:
try: return float(v or 0)
except: return 0.0
def _fetch_cot(market_name: str, weeks: int = 52) -> list[dict]:
"""Fetch TFF COT data from CFTC Socrata API (publicreporting.cftc.gov)."""
where = urllib.parse.quote(f"market_and_exchange_names='{market_name}'")
url = (
f"{CFTC_BASE}/resource/{CFTC_DATASET}.json"
f"?$where={where}"
f"&$order=report_date_as_yyyy_mm_dd+DESC"
f"&$limit={weeks}"
)
try:
resp = httpx.get(url, timeout=20, follow_redirects=True)
resp.raise_for_status()
return resp.json()
except Exception:
return []
def _parse_tff(records: list[dict]) -> dict:
"""Parse TFF records into structured signal data (newest-first records → oldest-first output)."""
if not records:
return {}
rows = list(reversed(records)) # oldest first for charting
dates = []
oi, dealer_net, assetmgr_net, levmoney_net, other_net = [], [], [], [], []
chg_oi, chg_am_long, chg_am_short, chg_lm_long, chg_lm_short = [], [], [], [], []
pct_am_long, pct_am_short, pct_lm_long, pct_lm_short = [], [], [], []
am_long_list, am_short_list, lm_long_list, lm_short_list = [], [], [], []
for r in rows:
d = (r.get("report_date_as_yyyy_mm_dd") or "")[:10]
if not d:
continue
dates.append(d)
aml = _safe_int(r.get("asset_mgr_positions_long"))
ams = _safe_int(r.get("asset_mgr_positions_short"))
lml = _safe_int(r.get("lev_money_positions_long"))
lms = _safe_int(r.get("lev_money_positions_short"))
dl = _safe_int(r.get("dealer_positions_long_all"))
ds = _safe_int(r.get("dealer_positions_short_all"))
ol = _safe_int(r.get("other_rept_positions_long"))
os_ = _safe_int(r.get("other_rept_positions_short"))
total_oi = _safe_int(r.get("open_interest_all"))
oi.append(total_oi)
dealer_net.append(dl - ds)
assetmgr_net.append(aml - ams)
levmoney_net.append(lml - lms)
other_net.append(ol - os_)
am_long_list.append(aml)
am_short_list.append(ams)
lm_long_list.append(lml)
lm_short_list.append(lms)
chg_oi.append(_safe_int(r.get("change_in_open_interest_all")))
chg_am_long.append(_safe_int(r.get("change_in_asset_mgr_long")))
chg_am_short.append(_safe_int(r.get("change_in_asset_mgr_short")))
chg_lm_long.append(_safe_int(r.get("change_in_lev_money_long")))
chg_lm_short.append(_safe_int(r.get("change_in_lev_money_short")))
pct_am_long.append(_safe_float(r.get("pct_of_oi_asset_mgr_long")))
pct_am_short.append(_safe_float(r.get("pct_of_oi_asset_mgr_short")))
pct_lm_long.append(_safe_float(r.get("pct_of_oi_lev_money_long")))
pct_lm_short.append(_safe_float(r.get("pct_of_oi_lev_money_short")))
if not dates:
return {}
def _cot_index(series):
lo, hi = min(series), max(series)
return round((series[-1] - lo) / max(hi - lo, 1) * 100, 1) if hi > lo else 50.0
am_idx = _cot_index(assetmgr_net)
lm_idx = _cot_index(levmoney_net)
def _bias(idx):
return "BULLISH" if idx >= 65 else ("BEARISH" if idx <= 35 else "NEUTRAL")
am_bias = _bias(am_idx)
lm_bias = _bias(lm_idx)
# Composite: weight asset managers 60%, leveraged money 40%
composite_idx = round(am_idx * 0.6 + lm_idx * 0.4, 1)
composite_bias = _bias(composite_idx)
wk_chg_am = (assetmgr_net[-1] - assetmgr_net[-2]) if len(assetmgr_net) >= 2 else 0
wk_chg_lm = (levmoney_net[-1] - levmoney_net[-2]) if len(levmoney_net) >= 2 else 0
return {
"dates": dates,
"open_interest": oi,
"change_oi": chg_oi,
# Asset Managers (institutional — real money)
"am_net": assetmgr_net,
"am_long": am_long_list,
"am_short": am_short_list,
"am_pct_long": pct_am_long,
"am_pct_short": pct_am_short,
"am_index": am_idx,
"am_bias": am_bias,
"am_current_net": assetmgr_net[-1],
"am_wk_change": wk_chg_am,
# Leveraged Money (hedge funds, CTAs)
"lm_net": levmoney_net,
"lm_long": lm_long_list,
"lm_short": lm_short_list,
"lm_pct_long": pct_lm_long,
"lm_pct_short": pct_lm_short,
"lm_index": lm_idx,
"lm_bias": lm_bias,
"lm_current_net": levmoney_net[-1],
"lm_wk_change": wk_chg_lm,
# Dealers
"dealer_net": dealer_net,
# Other
"other_net": other_net,
# Composite
"composite_index": composite_idx,
"composite_bias": composite_bias,
"weeks": len(dates),
"latest_date": dates[-1],
"source": "CFTC TFF — Traders in Financial Futures (Socrata API)",
}
def _volume_profile(sym: str, period: str = "3mo", buckets: int = 40) -> dict:
"""
Approximate volume profile from daily OHLCV.
Distributes each bar's volume uniformly across its High-Low range.
"""
try:
hist = yf.download(sym, period=period, interval="1d",
progress=False, auto_adjust=True)
if hist.empty:
return {}
if hasattr(hist.columns, "droplevel"):
try: hist.columns = hist.columns.droplevel(1)
except Exception: pass
highs = hist["High"].dropna().values.astype(float)
lows = hist["Low"].dropna().values.astype(float)
volumes = hist["Volume"].dropna().values.astype(float)
closes = hist["Close"].dropna().values.astype(float)
global_lo = float(np.min(lows))
global_hi = float(np.max(highs))
if global_hi <= global_lo:
return {}
bucket_size = (global_hi - global_lo) / buckets
vol_profile = np.zeros(buckets)
for i in range(len(highs)):
lo_b = int((lows[i] - global_lo) / bucket_size)
hi_b = int((highs[i] - global_lo) / bucket_size)
lo_b = max(0, min(lo_b, buckets - 1))
hi_b = max(0, min(hi_b, buckets - 1))
span = max(hi_b - lo_b + 1, 1)
vol_per_bucket = volumes[i] / span if volumes[i] > 0 else 0
vol_profile[lo_b:hi_b + 1] += vol_per_bucket
prices = [round(global_lo + (j + 0.5) * bucket_size, 5) for j in range(buckets)]
poc_idx = int(np.argmax(vol_profile))
poc = prices[poc_idx]
total_vol = float(np.sum(vol_profile))
va_target = total_vol * 0.70
lo_idx, hi_idx = poc_idx, poc_idx
va_vol = float(vol_profile[poc_idx])
while va_vol < va_target and (lo_idx > 0 or hi_idx < buckets - 1):
expand_lo = vol_profile[lo_idx - 1] if lo_idx > 0 else 0
expand_hi = vol_profile[hi_idx + 1] if hi_idx < buckets - 1 else 0
if expand_hi >= expand_lo:
hi_idx = min(hi_idx + 1, buckets - 1); va_vol += expand_hi
else:
lo_idx = max(lo_idx - 1, 0); va_vol += expand_lo
return {
"prices": prices,
"volumes": [round(float(v), 0) for v in vol_profile],
"poc": poc,
"vah": prices[hi_idx],
"val": prices[lo_idx],
"current_price": round(float(closes[-1]), 5),
"global_hi": round(global_hi, 5),
"global_lo": round(global_lo, 5),
"total_volume": round(total_vol, 0),
}
except Exception:
return {}
@router.get("/{pair}")
def get_institutional(pair: str, weeks: int = 26):
"""
Institutional flow data: CFTC TFF positioning + Volume Profile.
Data sources are 100% free — no API keys required.
"""
pair = pair.upper()
if pair not in COT_MAP:
raise HTTPException(404, f"No institutional data for '{pair}'.")
meta = COT_MAP[pair]
weeks = min(max(weeks, 4), 52)
raw = _fetch_cot(meta["cot"], weeks)
cot = _parse_tff(raw)
vp = _volume_profile(meta["sym"])
if not cot and not vp:
raise HTTPException(503, "CFTC API and volume profile both unavailable.")
return {
"pair": pair,
"cot": cot,
"volume_profile": vp,
"data_sources": [
"CFTC TFF (Traders in Financial Futures) — publicreporting.cftc.gov, weekly, free",
"Volume Profile — yfinance OHLCV daily, 3 months",
],
}
+59
View File
@@ -0,0 +1,59 @@
import time
import httpx
from fastapi import APIRouter
router = APIRouter(prefix="/live", tags=["live"])
# Simple in-memory cache to avoid hammering free APIs
_cache: dict = {}
CACHE_TTL = 30 # seconds
def _cached(key: str, ttl: int = CACHE_TTL):
entry = _cache.get(key)
if entry and time.time() - entry["ts"] < ttl:
return entry["data"]
return None
def _store(key: str, data):
_cache[key] = {"ts": time.time(), "data": data}
return data
@router.get("/aircraft")
def get_aircraft():
cached = _cached("aircraft", ttl=30)
if cached is not None:
return cached
try:
with httpx.Client(timeout=10) as client:
r = client.get("https://opensky-network.org/api/states/all")
r.raise_for_status()
data = r.json()
states = data.get("states") or []
# Filter: has position, not on ground, limit 600
filtered = [
s for s in states
if s[5] is not None and s[6] is not None and not s[8]
][:600]
result = {"time": data.get("time"), "states": filtered}
return _store("aircraft", result)
except Exception as e:
return {"time": None, "states": [], "error": str(e)}
@router.get("/earthquakes")
def get_earthquakes():
cached = _cached("earthquakes", ttl=300)
if cached is not None:
return cached
try:
with httpx.Client(timeout=10) as client:
r = client.get(
"https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson"
)
r.raise_for_status()
return _store("earthquakes", r.json())
except Exception as e:
return {"features": [], "error": str(e)}
+66
View File
@@ -0,0 +1,66 @@
from fastapi import APIRouter, HTTPException
import yfinance as yf
router = APIRouter(prefix="/market", tags=["market"])
@router.get("/{ticker}")
def get_market_data(ticker: str):
"""Return current spot price, company name, and daily change for a ticker."""
try:
t = yf.Ticker(ticker.upper())
info = t.fast_info
spot = info.last_price
prev = info.previous_close
if not spot:
raise HTTPException(status_code=404, detail=f"Ticker '{ticker}' not found.")
change_pct = round((spot - prev) / prev * 100, 2) if prev else 0.0
name = getattr(info, "exchange", ticker.upper())
return {
"ticker": ticker.upper(),
"spot": round(float(spot), 2),
"prev_close": round(float(prev), 2) if prev else None,
"change_pct": change_pct,
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/iv-surface/{ticker}")
def get_iv_surface(ticker: str):
"""
Fetch the real implied volatility surface from market option chains.
Returns strikes, expiries, and IV values for a heatmap.
"""
try:
t = yf.Ticker(ticker.upper())
expiries = t.options[:6] # Limit to 6 nearest expiries
if not expiries:
raise HTTPException(status_code=404, detail="No options data found.")
rows = []
for exp in expiries:
chain = t.option_chain(exp)
for _, row in chain.calls.iterrows():
if row.get("impliedVolatility") and row["impliedVolatility"] > 0:
rows.append({
"expiry": exp,
"strike": float(row["strike"]),
"iv": round(float(row["impliedVolatility"]), 4),
"type": "call",
})
for _, row in chain.puts.iterrows():
if row.get("impliedVolatility") and row["impliedVolatility"] > 0:
rows.append({
"expiry": exp,
"strike": float(row["strike"]),
"iv": round(float(row["impliedVolatility"]), 4),
"type": "put",
})
spot = float(t.fast_info.last_price)
return {"ticker": ticker.upper(), "spot": spot, "data": rows}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter
from ..schemas import MonteCarloRequest
from ..core.montecarlo import run_montecarlo
router = APIRouter(prefix="/montecarlo", tags=["montecarlo"])
@router.post("")
def monte_carlo(req: MonteCarloRequest):
options = [o.model_dump() for o in req.options]
return run_montecarlo(options, req.S0, req.sigma, req.r_d, req.r_f,
req.T, req.n_paths, req.n_steps)
+110
View File
@@ -0,0 +1,110 @@
"""
news.py — Free economic calendar from Forex Factory.
Source: nfs.faireconomy.media (official FF data mirror, JSON format)
ff_calendar_thisweek.json — current week
(nextweek.json appears Fri/Sat only, gracefully skipped if 404)
No API key. No auth.
"""
import httpx
from datetime import datetime, timezone
from fastapi import APIRouter
router = APIRouter(prefix="/news", tags=["news"])
FF_JSON_URLS = [
"https://nfs.faireconomy.media/ff_calendar_thisweek.json",
"https://nfs.faireconomy.media/ff_calendar_nextweek.json",
]
# In-memory cache — refresh every hour
_cache: dict = {"data": None, "ts": 0.0}
_CACHE_TTL = 3600 # seconds
def _fetch_ff_json(url: str) -> list[dict]:
try:
resp = httpx.get(
url, timeout=15, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (compatible; QuantRiskFX/3.0)"}
)
if resp.status_code != 200:
return []
raw = resp.json()
events = []
for ev in (raw if isinstance(raw, list) else []):
# `date` is already ISO-8601 with TZ offset, e.g. "2026-06-07T08:30:00-04:00"
date_str = ev.get("date", "")
dt_utc = None
if date_str:
try:
dt_et = datetime.fromisoformat(date_str)
dt_utc = dt_et.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")
except ValueError:
pass
events.append({
"title": ev.get("title", "").strip(),
"country": ev.get("country", "").strip(),
"date_raw": date_str,
"datetime_utc": dt_utc,
"impact": ev.get("impact", "").strip(),
"forecast": ev.get("forecast", "").strip(),
"previous": ev.get("previous", "").strip(),
"actual": ev.get("actual", "").strip(),
"url": "",
})
return events
except Exception:
return []
def _load_calendar() -> dict:
import time
now = time.time()
if _cache["data"] is not None and (now - _cache["ts"]) < _CACHE_TTL:
return _cache["data"]
seen: set[tuple] = set()
all_events: list = []
for url in FF_JSON_URLS:
for ev in _fetch_ff_json(url):
key = (ev["title"], ev["country"], ev["datetime_utc"])
if key not in seen:
seen.add(key)
all_events.append(ev)
all_events.sort(key=lambda e: e["datetime_utc"] or "")
now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")
next_high = next(
(e for e in all_events
if e["impact"] == "High"
and (e["datetime_utc"] or "") >= now_utc
and not e["actual"]),
None,
)
result = {
"events": all_events,
"count": len(all_events),
"now_utc": now_utc,
"next_high": next_high,
"source": "Forex Factory Economic Calendar — nfs.faireconomy.media (free, no API key)",
"note": "Times in UTC (source is US Eastern Time).",
"cached": False,
}
_cache["data"] = result
_cache["ts"] = now
return result
@router.get("/calendar")
def get_calendar():
"""
Economic calendar from Forex Factory (current + next week).
Times returned as UTC ISO-8601 strings. Cached for 1 hour.
"""
result = _load_calendar()
return {**result, "cached": _cache["ts"] > 0}
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter
from ..schemas import ScenarioRequest
from ..core.garman_kohlhagen import gk_price
router = APIRouter(prefix="/scenarios", tags=["scenarios"])
@router.post("")
def compute_scenarios(req: ScenarioRequest):
options = [o.model_dump() for o in req.options]
def portfolio_value(S, sigma):
return sum(
gk_price(S, opt["K"], opt.get("T", req.T), req.r_d, req.r_f, sigma, opt["type"])
* opt["qty"] for opt in options
)
base = portfolio_value(req.S0, req.sigma0)
results = []
for shock in req.shocks:
S_s = req.S0 * (1 + shock.dS_pct)
vol_s = max(0.005, req.sigma0 + shock.dVol)
pnl = portfolio_value(S_s, vol_s) - base
results.append({
"label": shock.label,
"dS_pct": shock.dS_pct, "dVol": shock.dVol,
"S_shocked": round(S_s, 5), "vol_shocked": round(vol_s, 4),
"pnl": round(pnl, 5),
"pnl_pct": round(pnl / abs(base) * 100, 2) if base else 0,
})
return {"base_value": round(base, 5), "scenarios": results}
+93
View File
@@ -0,0 +1,93 @@
from fastapi import APIRouter
router = APIRouter(prefix="/strategies", tags=["strategies"])
# Pre-built strategy templates — all expressed relative to ATM spot (K=100 placeholder)
STRATEGIES = [
{
"name": "Long Call",
"description": "Bullish. Unlimited upside, limited downside to premium paid.",
"legs": [{"type": "call", "K_offset": 0, "qty": 1}],
},
{
"name": "Long Put",
"description": "Bearish. Profit if spot falls below strike.",
"legs": [{"type": "put", "K_offset": 0, "qty": 1}],
},
{
"name": "Covered Call",
"description": "Long stock + short OTM call. Income strategy.",
"legs": [{"type": "call", "K_offset": 5, "qty": -1}],
},
{
"name": "Protective Put",
"description": "Long stock + long put. Portfolio insurance.",
"legs": [{"type": "put", "K_offset": -5, "qty": 1}],
},
{
"name": "Straddle",
"description": "Long call + put at same strike. Profits from large moves either way.",
"legs": [
{"type": "call", "K_offset": 0, "qty": 1},
{"type": "put", "K_offset": 0, "qty": 1},
],
},
{
"name": "Strangle",
"description": "OTM call + OTM put. Cheaper than straddle, needs bigger move.",
"legs": [
{"type": "call", "K_offset": 5, "qty": 1},
{"type": "put", "K_offset": -5, "qty": 1},
],
},
{
"name": "Bull Call Spread",
"description": "Long ATM call + short OTM call. Capped upside, lower cost.",
"legs": [
{"type": "call", "K_offset": 0, "qty": 1},
{"type": "call", "K_offset": 10, "qty": -1},
],
},
{
"name": "Bear Put Spread",
"description": "Long ATM put + short OTM put. Profits from moderate decline.",
"legs": [
{"type": "put", "K_offset": 0, "qty": 1},
{"type": "put", "K_offset": -10, "qty": -1},
],
},
{
"name": "Iron Condor",
"description": "4-leg strategy. Profit from low volatility, defined risk.",
"legs": [
{"type": "put", "K_offset": -15, "qty": 1},
{"type": "put", "K_offset": -5, "qty": -1},
{"type": "call", "K_offset": 5, "qty": -1},
{"type": "call", "K_offset": 15, "qty": 1},
],
},
{
"name": "Butterfly",
"description": "3-strike spread. Max profit when spot pins at middle strike.",
"legs": [
{"type": "call", "K_offset": -10, "qty": 1},
{"type": "call", "K_offset": 0, "qty": -2},
{"type": "call", "K_offset": 10, "qty": 1},
],
},
]
@router.get("")
def list_strategies():
"""Return all available strategy templates."""
return STRATEGIES
@router.get("/{name}")
def get_strategy(name: str):
"""Return a specific strategy by name (case-insensitive)."""
for s in STRATEGIES:
if s["name"].lower() == name.lower():
return s
return {"error": f"Strategy '{name}' not found."}
+14
View File
@@ -0,0 +1,14 @@
import numpy as np
from fastapi import APIRouter
from ..schemas import SurfaceRequest
from ..core.surface import compute_surfaces
router = APIRouter(prefix="/surface", tags=["surface"])
@router.post("")
def compute_surface(req: SurfaceRequest):
options = [o.model_dump() for o in req.options]
# If caller didn't set S range, default to ±20% around midpoint — handled frontend-side
S_range = np.linspace(req.S_low, req.S_high, req.S_steps)
sigma_range = np.linspace(req.vol_low, req.vol_high, req.vol_steps)
return compute_surfaces(options, S_range, sigma_range, req.T, req.r_d, req.r_f)
+66
View File
@@ -0,0 +1,66 @@
from pydantic import BaseModel, Field
from typing import Literal
class OptionLeg(BaseModel):
type: Literal["call", "put"]
K: float = Field(..., gt=0, description="Strike price (exchange rate)")
T: float = Field(..., gt=0, description="Time to expiry in years")
qty: float = Field(..., description="Signed quantity (positive=long)")
class GreeksRequest(BaseModel):
options: list[OptionLeg]
S: float = Field(..., gt=0, description="Spot exchange rate")
sigma: float = Field(..., gt=0, lt=5)
T: float = Field(..., gt=0)
r_d: float = Field(default=0.0525, description="Domestic risk-free rate")
r_f: float = Field(default=0.0400, description="Foreign risk-free rate")
class SurfaceRequest(BaseModel):
options: list[OptionLeg]
S_low: float = Field(default=0.0)
S_high: float = Field(default=0.0)
S_steps: int = Field(default=40)
vol_low: float = Field(default=0.05)
vol_high: float = Field(default=0.30)
vol_steps: int = Field(default=40)
T: float = Field(default=0.5)
r_d: float = Field(default=0.0525)
r_f: float = Field(default=0.0400)
class MonteCarloRequest(BaseModel):
options: list[OptionLeg]
S0: float = Field(..., gt=0)
sigma: float = Field(..., gt=0)
r_d: float = Field(default=0.0525)
r_f: float = Field(default=0.0400)
T: float = Field(..., gt=0)
n_paths: int = Field(default=1000, ge=100, le=10000)
n_steps: int = Field(default=100, ge=10, le=500)
class ScenarioShock(BaseModel):
label: str
dS_pct: float
dVol: float
class ScenarioRequest(BaseModel):
options: list[OptionLeg]
S0: float
sigma0: float
T: float
r_d: float = 0.0525
r_f: float = 0.0400
shocks: list[ScenarioShock] = Field(default_factory=lambda: [
ScenarioShock(label="Flash Crash", dS_pct=-0.03, dVol=0.08),
ScenarioShock(label="Sharp Sell-off", dS_pct=-0.015,dVol=0.04),
ScenarioShock(label="Mild Weakness", dS_pct=-0.005,dVol=0.01),
ScenarioShock(label="Base Case", dS_pct=0.00, dVol=0.00),
ScenarioShock(label="Mild Strength", dS_pct=0.005, dVol=-0.01),
ScenarioShock(label="Sharp Rally", dS_pct=0.015, dVol=-0.03),
ScenarioShock(label="Breakout", dS_pct=0.03, dVol=-0.05),
])
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.110,<1.0
uvicorn[standard]>=0.29,<1.0
numpy>=1.24,<3.0
scipy>=1.10,<2.0
pandas>=2.0,<4.0
yfinance>=0.2.38
pydantic>=2.0,<3.0
reportlab>=4.0,<5.0
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NEXUS TERMINAL — FX Options Analytics</title>
<meta name="description" content="Institutional-grade forex options analytics. Greeks, AI signals, live world map, economic calendar."/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+5228
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.17.0",
"leaflet": "^1.9.4",
"plotly.js": "^3.6.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-leaflet": "^5.0.0",
"react-plotly.js": "^2.6.0",
"react-router-dom": "^7.17.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"postcss": "^8.5.15",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+149
View File
@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
import './index.css'
import Navbar from './components/Navbar'
import PortfolioBuilder from './components/PortfolioBuilder'
import Dashboard from './pages/Dashboard'
import LandingPage from './pages/LandingPage'
import { usePortfolioStore } from './store/portfolio'
function useNexusFavicon() {
useEffect(() => {
const canvas = document.createElement('canvas')
canvas.width = 64
canvas.height = 64
const ctx = canvas.getContext('2d')
let link = document.querySelector("link[rel~='icon']")
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
let a1 = 0, a2 = Math.PI / 3, a3 = -Math.PI / 3
let frame
function draw() {
const cx = 32, cy = 32
ctx.clearRect(0, 0, 64, 64)
// Background circle
ctx.fillStyle = '#080e1a'
ctx.beginPath()
ctx.arc(cx, cy, 30, 0, Math.PI * 2)
ctx.fill()
// Helper: draw one orbit + satellite
function orbit(angle, color, rx, ry, dotR) {
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(angle)
ctx.strokeStyle = color
ctx.lineWidth = 1
ctx.globalAlpha = 0.65
ctx.beginPath()
ctx.ellipse(0, 0, rx, ry, 0, 0, Math.PI * 2)
ctx.stroke()
// Satellite
ctx.globalAlpha = 1
ctx.shadowBlur = 8
ctx.shadowColor = color
ctx.fillStyle = color
ctx.beginPath()
ctx.arc(rx, 0, dotR, 0, Math.PI * 2)
ctx.fill()
ctx.shadowBlur = 0
ctx.restore()
}
orbit(a1, '#00c8f0', 27, 7.5, 3.5)
orbit(a2, '#8b5cf6', 27, 7.5, 3)
orbit(a3, '#3ddc84', 27, 7.5, 2.8)
// Core glow
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, 10)
g.addColorStop(0, 'rgba(0,200,240,0.95)')
g.addColorStop(0.45, 'rgba(0,200,240,0.35)')
g.addColorStop(1, 'rgba(0,200,240,0)')
ctx.fillStyle = g
ctx.beginPath()
ctx.arc(cx, cy, 10, 0, Math.PI * 2)
ctx.fill()
// Core dot
ctx.shadowBlur = 10
ctx.shadowColor = '#00c8f0'
ctx.fillStyle = '#00c8f0'
ctx.beginPath()
ctx.arc(cx, cy, 4.5, 0, Math.PI * 2)
ctx.fill()
ctx.shadowBlur = 0
// White center
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.arc(cx, cy, 1.8, 0, Math.PI * 2)
ctx.fill()
link.type = 'image/png'
link.href = canvas.toDataURL('image/png')
const fps = 60
a1 += (2 * Math.PI) / (7 * fps)
a2 += (2 * Math.PI) / (11 * fps)
a3 -= (2 * Math.PI) / (5 * fps)
frame = requestAnimationFrame(draw)
}
draw()
return () => cancelAnimationFrame(frame)
}, [])
}
export default function App() {
const { fromURL } = usePortfolioStore()
const [inApp, setInApp] = useState(() => sessionStorage.getItem('qfx-v') === '1')
useNexusFavicon()
useEffect(() => { fromURL() }, [])
function enterApp() {
sessionStorage.setItem('qfx-v', '1')
setInApp(true)
}
if (!inApp) return <LandingPage onEnter={enterApp}/>
return (
<div style={{ display:'flex', flexDirection:'column', height:'100vh', overflow:'hidden' }}>
<Navbar />
{/* Main layout: sidebar + content */}
<div style={{ display:'flex', flex:1, overflow:'hidden' }}>
{/* Left sidebar */}
<div style={{
width:260, minWidth:260, flexShrink:0,
display:'flex', flexDirection:'column',
overflow:'hidden',
background:'rgba(2,8,23,0.92)',
borderRight:'1px solid var(--border)',
backdropFilter:'blur(20px)',
}}>
<PortfolioBuilder />
</div>
{/* Dashboard pane */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden' }}>
{/* Subtle inner gradient */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden',
background:'radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0,212,255,0.04) 0%, transparent 60%)' }}>
<Dashboard />
</div>
</div>
</div>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import axios from 'axios'
const api = axios.create({ baseURL: '/api' })
// Market
export const fetchMarket = (ticker) => api.get(`/market/${ticker}`).then(r=>r.data)
export const fetchIVSurface = (ticker) => api.get(`/market/iv-surface/${ticker}`).then(r=>r.data)
// Forex
export const fetchForexRates = () => api.get('/forex/rates').then(r=>r.data)
export const fetchForexRate = (pair) => api.get(`/forex/rate/${pair}`).then(r=>r.data)
export const fetchOHLC = (pair,iv,period) => api.get(`/forex/ohlc/${pair}`,{params:{interval:iv,period}}).then(r=>r.data)
export const fetchSignals = (pair) => api.get(`/forex/signals/${pair}`).then(r=>r.data)
export const fetchPairs = () => api.get('/forex/pairs').then(r=>r.data)
// Options analytics
export const fetchStrategies = () => api.get('/strategies').then(r=>r.data)
export const computeGreeks = (p) => api.post('/greeks', p).then(r=>r.data)
export const computeSurface = (p) => api.post('/surface', p).then(r=>r.data)
export const computeMonteCarlo = (p) => api.post('/montecarlo', p).then(r=>r.data)
export const computeScenarios = (p) => api.post('/scenarios', p).then(r=>r.data)
export const exportCSV = (p) => api.post('/export/csv', p, {responseType:'blob'}).then(r=>r.data)
// Institutional flow
export const fetchInstitutional = (pair, weeks) =>
api.get(`/institutional/${pair}`, { params: { weeks } }).then(r => r.data)
// Economic calendar (Forex Factory)
export const fetchCalendar = () =>
api.get('/news/calendar').then(r => r.data)
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useState } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore } from '../store/portfolio'
import { computeSurface } from '../api/client'
export default function BreakevenChart() {
const { legs, S, sigma, T, r_d, r_f, pair } = usePortfolioStore()
const [chartData, setChartData] = useState(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!legs.length) return
setLoading(true)
computeSurface({
options: legs.map(({ type, K, T, qty }) => ({ type, K, T, qty })),
S_low: S * 0.70, S_high: S * 1.30, S_steps: 80,
vol_low: sigma, vol_high: sigma + 0.001, vol_steps: 2,
T, r_d, r_f,
})
.then(d => {
const spots = d.S_range
const pnl = d.pnl.map(row => row[0])
setChartData({ spots, pnl })
})
.catch(() => {})
.finally(() => setLoading(false))
}, [S, sigma, T, r_d, r_f, JSON.stringify(legs)])
if (loading) return (
<div style={{ padding:20 }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:14 }}>
<div className="live-dot"/>
<span style={{ color:'var(--text-muted)', fontSize:12 }}>Computing breakeven profile</span>
</div>
<div className="skeleton" style={{ height:300, borderRadius:8 }}/>
</div>
)
if (!chartData) return null
const { spots, pnl } = chartData
const zeroLine = spots.map(() => 0)
// Find breakeven crossings
const breakevens = []
for (let i = 0; i < pnl.length - 1; i++) {
if (Math.sign(pnl[i]) !== Math.sign(pnl[i + 1])) {
const be = spots[i] + (spots[i+1]-spots[i])*(-pnl[i]/(pnl[i+1]-pnl[i]))
breakevens.push(parseFloat(be.toFixed(5)))
}
}
// Find min/max pnl idx for annotation
const maxPnlIdx = pnl.reduce((mi,v,i,a) => v>a[mi]?i:mi, 0)
const minPnlIdx = pnl.reduce((mi,v,i,a) => v<a[mi]?i:mi, 0)
const traces = [
{
type:'scatter', mode:'lines', name:'P&L',
x: spots, y: pnl,
line:{ color:'#00d4ff', width:2.5 },
fill:'tozeroy',
fillcolor:'rgba(0,212,255,0.06)',
},
{
type:'scatter', mode:'lines', name:'Zero',
x: spots, y: zeroLine,
line:{ color:'rgba(255,255,255,0.15)', width:1, dash:'dash' },
showlegend:false, hoverinfo:'skip',
},
// Current spot
{
type:'scatter', mode:'markers+text',
x:[S], y:[pnl[Math.round((S-spots[0])/(spots[1]-spots[0]))] ?? 0],
marker:{ color:'#ffd700', size:9, symbol:'circle', line:{ color:'#0a0e1a', width:2 } },
text:['Current'], textposition:'top center',
textfont:{ color:'#ffd700', size:10 },
name:'Current Spot',
},
// Breakeven markers
...breakevens.map((be, i) => ({
type:'scatter', mode:'markers+text',
x:[be], y:[0],
marker:{ color:'#00ff88', size:9, symbol:'diamond', line:{ color:'#0a0e1a', width:2 } },
text:[`BE ${be}`], textposition:'top center',
textfont:{ color:'#00ff88', size:9 },
name:`Breakeven ${i+1}`,
})),
// Max profit / loss markers
{
type:'scatter', mode:'markers',
x:[spots[maxPnlIdx]], y:[pnl[maxPnlIdx]],
marker:{ color:'#00ff88', size:7, symbol:'triangle-up', opacity:0.8 },
showlegend:false, hovertemplate:'Max Profit: %{y:.5f}<extra></extra>',
},
{
type:'scatter', mode:'markers',
x:[spots[minPnlIdx]], y:[pnl[minPnlIdx]],
marker:{ color:'#ff3d5a', size:7, symbol:'triangle-down', opacity:0.8 },
showlegend:false, hovertemplate:'Max Loss: %{y:.5f}<extra></extra>',
},
]
return (
<div style={{ padding:16 }}>
{/* Header */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:10 }}>
<div>
<div className="section-title">P&L at Expiry vs Spot ({pair})</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Fixed vol σ={sigma} · r_d={r_d} · r_f={r_f}
</div>
</div>
{breakevens.length > 0 && (
<div style={{ display:'flex', gap:6 }}>
{breakevens.map((be, i) => (
<span key={i} className="font-mono" style={{ padding:'3px 10px', borderRadius:20,
background:'rgba(0,255,136,0.1)', color:'var(--green)',
border:'1px solid rgba(0,255,136,0.3)', fontSize:10, fontWeight:600 }}>
BE: {be}
</span>
))}
</div>
)}
</div>
{/* Stats row */}
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
{[
{ label:'Max Profit', value: Math.max(...pnl).toFixed(5), pos:true },
{ label:'Max Loss', value: Math.min(...pnl).toFixed(5), pos:false },
{ label:'Breakevens', value: breakevens.length, pos: true },
{ label:'Spot', value: S, pos:true },
].map(({ label, value, pos }) => (
<div key={label} className="glass" style={{ padding:'5px 12px', borderRadius:6, fontSize:11 }}>
<span style={{ color:'var(--text-muted)' }}>{label}: </span>
<span className="font-mono" style={{ color: pos ? 'var(--green)' : 'var(--red)', fontWeight:600 }}>
{value}
</span>
</div>
))}
</div>
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<Plot data={traces}
layout={{
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:52, r:16, t:16, b:50 },
xaxis:{ title:`${pair} Spot Rate`, gridcolor:'#1e293b', color:'#475569', zeroline:false },
yaxis:{ title:'Portfolio P&L', gridcolor:'#1e293b', color:'#475569',
zeroline:true, zerolinecolor:'rgba(255,255,255,0.15)' },
legend:{ bgcolor:'transparent', font:{ size:9 }, orientation:'h',
x:0, y:-0.15 },
hovermode:'x unified',
hoverlabel:{ bgcolor:'#0f172a', font:{ color:'#e2e8f0' }, bordercolor:'rgba(0,212,255,0.15)' },
}}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:320 }} />
</div>
</div>
)
}
@@ -0,0 +1,354 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore } from '../store/portfolio'
import { fetchOHLC } from '../api/client'
const INTERVALS = [
{ label: '1m', val: '1m', period: '1d' },
{ label: '5m', val: '5m', period: '2d' },
{ label: '15m', val: '15m', period: '5d' },
{ label: '1h', val: '1h', period: '1mo' },
{ label: '1d', val: '1d', period: '1mo' },
]
function ema(arr, period) {
const k = 2 / (period + 1)
const out = []
let e = null
for (let i = 0; i < arr.length; i++) {
if (i < period - 1) { out.push(null); continue }
if (i === period - 1) {
e = arr.slice(0, period).reduce((s, x) => s + x, 0) / period
} else {
e = arr[i] * k + e * (1 - k)
}
out.push(e)
}
return out
}
// Inject SVG linearGradient into Plotly's SVG after render
function injectSvgGradient(container, colorHex, gradId = 'qfx-price-grad') {
if (!container) return
const svg = container.querySelector('svg.main-svg')
if (!svg) return
svg.getElementById(gradId + '-defs')?.remove()
const ns = 'http://www.w3.org/2000/svg'
const defs = document.createElementNS(ns, 'defs')
defs.id = gradId + '-defs'
const grad = document.createElementNS(ns, 'linearGradient')
grad.id = gradId
grad.setAttribute('x1', '0%'); grad.setAttribute('y1', '0%')
grad.setAttribute('x2', '0%'); grad.setAttribute('y2', '100%')
const r = parseInt(colorHex.slice(1,3), 16)
const g = parseInt(colorHex.slice(3,5), 16)
const b = parseInt(colorHex.slice(5,7), 16)
;[
{ offset: '0%', opacity: 0.45 },
{ offset: '35%', opacity: 0.20 },
{ offset: '75%', opacity: 0.06 },
{ offset: '100%', opacity: 0.0 },
].forEach(({ offset, opacity }) => {
const stop = document.createElementNS(ns, 'stop')
stop.setAttribute('offset', offset)
stop.setAttribute('stop-color', `rgb(${r},${g},${b})`)
stop.setAttribute('stop-opacity', String(opacity))
grad.appendChild(stop)
})
defs.appendChild(grad)
svg.prepend(defs)
// Apply gradient to the main price area fill (2nd fill path index 1)
const fillPaths = svg.querySelectorAll('.fills path')
if (fillPaths.length >= 2) {
fillPaths[1].setAttribute('fill', `url(#${gradId})`)
}
}
export default function PriceChart() {
const { pair } = usePortfolioStore()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [interval, setInterval] = useState('5m')
const [error, setError] = useState(null)
const [fading, setFading] = useState(false)
const containerRef = useRef(null)
const load = useCallback(async (ivl) => {
setFading(true)
setLoading(true); setError(null)
const cfg = INTERVALS.find(x => x.val === ivl) || INTERVALS[1]
try {
const d = await fetchOHLC(pair, cfg.val, cfg.period)
await new Promise(r => setTimeout(r, 200))
setData(d)
setFading(false)
} catch (e) {
setError(e?.response?.data?.detail || 'Data unavailable.')
setFading(false)
} finally { setLoading(false) }
}, [pair])
useEffect(() => { load(interval) }, [pair, interval])
// Derived values all computed BEFORE any hooks that depend on them
const closes = data?.close || []
const last = closes.at(-1)
const first = closes[0]
const periodHigh = data ? Math.max(...data.high) : null
const periodLow = data ? Math.min(...data.low) : null
const change = (last && first) ? ((last - first) / first * 100) : null
const isUp = change !== null ? change >= 0 : true
const lineColor = isUp ? '#00ff88' : '#ff3d5a'
const fillColor = isUp ? 'rgba(0,255,136,0.18)' : 'rgba(255,61,90,0.18)'
const glowColor = isUp ? 'rgba(0,255,136,0.10)' : 'rgba(255,61,90,0.10)'
const ema20 = closes.length >= 20 ? ema(closes, 20) : []
const ema9 = closes.length >= 9 ? ema(closes, 9) : []
// Gradient injection whenever data or direction changes
useEffect(() => {
const t = setTimeout(() => injectSvgGradient(containerRef.current, lineColor), 150)
return () => clearTimeout(t)
}, [data, lineColor])
const handlePlotRender = useCallback(() => {
setTimeout(() => injectSvgGradient(containerRef.current, lineColor), 80)
}, [lineColor])
// Baseline just below data minimum keeps fill bounded within visible chart area
const minClose = data ? Math.min(...closes) * 0.9997 : 0
const baseDates = data?.dates || []
// Build traces
const plotData = data ? [
// Invisible baseline at bottom fill target for tonexty
{
type: 'scatter', mode: 'lines',
x: baseDates, y: baseDates.map(() => minClose),
line: { color: 'rgba(0,0,0,0)', width: 0 },
showlegend: false, hoverinfo: 'skip',
},
// Main price line fills down to the baseline above
{
type: 'scatter', mode: 'lines',
x: data.dates, y: closes,
name: pair,
line: { color: lineColor, width: 2.5, shape: 'spline', smoothing: 0.5 },
fill: 'tonexty',
fillcolor: fillColor,
hovertemplate: '<b>%{x|%H:%M %b %d}</b><br>%{y:.5f}<extra></extra>',
},
// Glow layer
{
type: 'scatter', mode: 'lines',
x: data.dates, y: closes,
line: { color: glowColor, width: 18, shape: 'spline', smoothing: 0.5 },
showlegend: false, hoverinfo: 'skip',
},
...(ema20.length ? [{
type: 'scatter', mode: 'lines',
x: data.dates, y: ema20,
name: 'EMA 20',
line: { color: 'rgba(167,139,250,0.85)', width: 1.5, shape: 'spline', smoothing: 0.5 },
hovertemplate: 'EMA20: %{y:.5f}<extra></extra>',
}] : []),
...(ema9.length ? [{
type: 'scatter', mode: 'lines',
x: data.dates, y: ema9,
name: 'EMA 9',
line: { color: 'rgba(255,215,0,0.7)', width: 1.2, shape: 'spline', smoothing: 0.5 },
hovertemplate: 'EMA9: %{y:.5f}<extra></extra>',
}] : []),
] : []
const layout = {
paper_bgcolor: 'transparent',
plot_bgcolor: 'rgba(4,10,28,0.8)',
font: { color: '#475569', family: 'JetBrains Mono, monospace', size: 10 },
margin: { l: 12, r: 68, t: 12, b: 44 },
xaxis: {
type: 'date',
gridcolor: 'rgba(0,212,255,0.05)',
linecolor: 'rgba(0,212,255,0.1)',
color: '#475569',
rangeslider: { visible: false },
showspikes: true, spikecolor: 'rgba(0,212,255,0.35)', spikethickness: 1,
tickfont: { size: 9 }, zeroline: false,
},
yaxis: {
gridcolor: 'rgba(0,212,255,0.05)',
linecolor: 'rgba(0,212,255,0.1)',
color: '#475569',
side: 'right', tickformat: '.5f',
showspikes: true, spikecolor: 'rgba(0,212,255,0.35)', spikethickness: 1,
tickfont: { size: 9 }, zeroline: false,
},
legend: {
bgcolor: 'rgba(4,10,28,0.85)',
bordercolor: 'rgba(0,212,255,0.12)', borderwidth: 1,
font: { color: '#64748b', size: 9 },
orientation: 'h', x: 0.01, y: 0.99, xanchor: 'left', yanchor: 'top',
},
dragmode: 'pan',
hovermode: 'x unified',
hoverlabel: {
bgcolor: 'rgba(4,10,28,0.95)',
bordercolor: isUp ? 'rgba(0,255,136,0.4)' : 'rgba(255,61,90,0.4)',
font: { color: '#e0f2fe', family: 'JetBrains Mono, monospace', size: 11 },
},
}
return (
<div style={{ padding: 16 }}>
{/* Header */}
<div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', marginBottom:14 }}>
<div>
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<span className="section-title" style={{ fontSize:12 }}>{pair} LINE CHART</span>
{loading && <div className="live-dot"/>}
</div>
{data && (
<div style={{ display:'flex', alignItems:'baseline', gap:10, marginTop:3 }}>
<span className="font-mono" style={{ fontSize:26, fontWeight:800,
color:'var(--text)', letterSpacing:'-0.5px' }}>
{last?.toFixed(5)}
</span>
{change !== null && (
<span className="font-mono" style={{ fontSize:13, fontWeight:700,
color: lineColor, textShadow:`0 0 14px ${lineColor}88` }}>
{isUp ? '▲' : '▼'} {Math.abs(change).toFixed(3)}%
</span>
)}
</div>
)}
</div>
{data && (
<div style={{ display:'flex', gap:6, marginTop:4 }}>
{[
{ label:'H', value: periodHigh?.toFixed(5), c:'#00ff88' },
{ label:'L', value: periodLow?.toFixed(5), c:'#ff3d5a' },
{ label:'N', value: closes.length + ' bars', c:'var(--text-muted)' },
].map(({ label, value, c }) => (
<div key={label} className="glass" style={{ padding:'4px 10px', borderRadius:6 }}>
<span style={{ fontSize:9, color:'var(--text-muted)' }}>{label} </span>
<span className="font-mono" style={{ fontSize:10, color:c, fontWeight:600 }}>{value}</span>
</div>
))}
</div>
)}
<div style={{ display:'flex', gap:3, background:'rgba(0,0,0,0.25)',
borderRadius:8, padding:3, border:'1px solid rgba(0,212,255,0.08)' }}>
{INTERVALS.map(iv => {
const active = interval === iv.val
return (
<button key={iv.val}
onClick={() => { setInterval(iv.val); load(iv.val) }}
style={{
padding:'5px 13px', fontSize:10, cursor:'pointer',
borderRadius:6, border:'none',
fontFamily:'JetBrains Mono, monospace', fontWeight: active ? 700 : 400,
background: active ? (isUp ? 'rgba(0,255,136,0.14)' : 'rgba(255,61,90,0.14)') : 'transparent',
color: active ? lineColor : '#475569',
boxShadow: active ? `0 0 10px ${lineColor}44` : 'none',
transition: 'all 0.22s ease',
}}>
{iv.label}
</button>
)
})}
</div>
</div>
{error && (
<div style={{ padding:10, borderRadius:6, marginBottom:10, fontSize:11,
background:'rgba(255,61,90,0.08)', border:'1px solid rgba(255,61,90,0.3)', color:'#ff3d5a' }}>
{error}
</div>
)}
{/* Chart container with fade+slide animation */}
<div ref={containerRef} style={{
borderRadius:10, overflow:'hidden',
border:`1px solid ${isUp ? 'rgba(0,255,136,0.12)' : 'rgba(255,61,90,0.12)'}`,
boxShadow: isUp
? '0 0 40px rgba(0,255,136,0.05), inset 0 1px 0 rgba(0,255,136,0.1)'
: '0 0 40px rgba(255,61,90,0.05), inset 0 1px 0 rgba(255,61,90,0.1)',
background: 'rgba(4,10,28,0.8)',
opacity: fading ? 0 : 1,
transform: fading ? 'translateY(8px) scale(0.995)' : 'translateY(0) scale(1)',
transition: 'opacity 0.25s ease, transform 0.25s ease',
minHeight: 460, position: 'relative',
}}>
{loading && !data && (
<div style={{ position:'absolute', inset:0, display:'flex',
flexDirection:'column', alignItems:'center', justifyContent:'center', gap:14 }}>
<div style={{ display:'flex', gap:5, alignItems:'flex-end' }}>
{[32,48,28,52,38,44,30,46,36].map((h, i) => (
<div key={i} className="skeleton" style={{
width:4, height:h, borderRadius:2, animationDelay:`${i * 0.08}s`,
}}/>
))}
</div>
<span style={{ fontSize:11, color:'var(--text-muted)', fontFamily:'JetBrains Mono' }}>
Loading {pair} {interval}
</span>
</div>
)}
{data && (
<Plot data={plotData} layout={layout}
config={{ responsive:true, displayModeBar:true, displaylogo:false,
scrollZoom:true,
modeBarButtonsToRemove:['select2d','lasso2d','autoScale2d','toImage'] }}
style={{ width:'100%', height:460 }}
onAfterPlot={handlePlotRender}
onUpdate={handlePlotRender}
/>
)}
{!data && !loading && (
<div style={{ height:460, display:'flex', flexDirection:'column',
alignItems:'center', justifyContent:'center', gap:10 }}>
<span style={{ fontSize:34 }}>📈</span>
<span style={{ color:'var(--text-muted)', fontSize:12 }}>Select a pair to load chart</span>
</div>
)}
</div>
{/* Legend strip */}
{data && (
<div style={{ display:'flex', alignItems:'center', gap:16, marginTop:10, paddingLeft:4 }}>
{[
{ color: lineColor, label:`${pair} Close`, dot:true },
{ color: 'rgba(167,139,250,0.9)', label:'EMA 20', dot:false },
{ color: 'rgba(255,215,0,0.8)', label:'EMA 9', dot:false },
].map(({ color, label, dot }) => (
<div key={label} style={{ display:'flex', alignItems:'center', gap:5 }}>
{dot
? <div style={{ width:7, height:7, borderRadius:'50%', background:color, boxShadow:`0 0 7px ${color}` }}/>
: <div style={{ width:18, height:1.5, background:color, borderRadius:1 }}/>
}
<span style={{ fontSize:9, color:'var(--text-muted)', fontFamily:'JetBrains Mono' }}>{label}</span>
</div>
))}
<div style={{ flex:1 }}/>
<span style={{ fontSize:9, color:'var(--text-muted)', fontStyle:'italic' }}>
{interval} · {data.dates?.at(-1)?.slice(0, 10)} · Garman-Kohlhagen
</span>
</div>
)}
</div>
)
}
@@ -0,0 +1,493 @@
import { useState, useEffect, useCallback, Fragment, useMemo } from 'react'
import { fetchCalendar } from '../api/client'
/* ─── constants ────────────────────────────────────────── */
const FLAGS = {
USD:'🇺🇸', EUR:'🇪🇺', GBP:'🇬🇧', JPY:'🇯🇵',
AUD:'🇦🇺', CAD:'🇨🇦', CHF:'🇨🇭', NZD:'🇳🇿',
CNY:'🇨🇳', CNH:'🇨🇳',
}
const IMP = {
High: { color:'var(--red)', bar:'var(--red)', label:'HIGH', badge:'tbadge-high' },
Medium: { color:'var(--amber)', bar:'var(--amber)', label:'MED', badge:'tbadge-medium' },
Low: { color:'var(--text-muted)', bar:'var(--text-faint)', label:'LOW', badge:'' },
}
const ALL_CCY = ['USD','EUR','GBP','JPY','AUD','CAD','CHF','NZD']
const DAY_ABBR = ['SUN','MON','TUE','WED','THU','FRI','SAT']
/* ─── helpers ──────────────────────────────────────────── */
function todayStr() {
return new Date().toISOString().slice(0, 10)
}
function toDateStr(d) {
// local calendar date "YYYY-MM-DD"
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2,'0')
const day = String(d.getDate()).padStart(2,'0')
return `${y}-${m}-${day}`
}
function getMondayOf(d) {
const copy = new Date(d)
const dow = copy.getDay() // 0=Sun
const diff = dow === 0 ? -6 : 1 - dow
copy.setDate(copy.getDate() + diff)
copy.setHours(0, 0, 0, 0)
return copy
}
function fmtUtcTime(dtStr) {
if (!dtStr) return 'All Day'
try {
return new Date(dtStr).toLocaleTimeString('en-GB',
{ hour:'2-digit', minute:'2-digit', timeZone:'UTC' })
} catch { return '--:--' }
}
function fmtFullDay(dateStr) {
if (!dateStr) return ''
try {
return new Date(dateStr + 'T12:00:00Z').toLocaleDateString('en-US',
{ weekday:'long', month:'long', day:'numeric', year:'numeric', timeZone:'UTC' })
} catch { return dateStr }
}
function isPast(dtStr) {
if (!dtStr) return false
return new Date(dtStr) < Date.now()
}
function countdown(dtStr) {
if (!dtStr) return null
const diff = new Date(dtStr) - Date.now()
if (diff <= 0 || diff > 24 * 3600000) return null
const h = Math.floor(diff / 3600000)
const m = Math.floor((diff % 3600000) / 60000)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
/* ─── sub-components ───────────────────────────────────── */
function ImpactDots({ counts }) {
return (
<div style={{ display:'flex', gap:2, marginTop:3, justifyContent:'center' }}>
{counts.high > 0 && [...Array(Math.min(counts.high, 3))].map((_,i) =>
<span key={'h'+i} style={{ width:4, height:4, borderRadius:'50%', background:'#ff3d5a', opacity:0.9 }}/>
)}
{counts.medium > 0 && [...Array(Math.min(counts.medium,3))].map((_,i) =>
<span key={'m'+i} style={{ width:4, height:4, borderRadius:'50%', background:'#f59e0b', opacity:0.7 }}/>
)}
</div>
)
}
function EventRow({ ev }) {
const imp = IMP[ev.impact] || IMP.Low
const flag = FLAGS[ev.country] || '🏳️'
const past = isPast(ev.datetime_utc)
const cd = countdown(ev.datetime_utc)
const actual = ev.actual?.trim()
// beat/miss
let bm = null
if (actual && ev.forecast?.trim()) {
const a = parseFloat(actual), f = parseFloat(ev.forecast)
if (!isNaN(a) && !isNaN(f)) bm = a > f ? 'beat' : a < f ? 'miss' : 'inline'
}
const actualColor = bm === 'beat' ? 'var(--green)' : bm === 'miss' ? 'var(--red)' : 'var(--text-secondary)'
return (
<tr style={{
opacity: past && !actual ? 0.45 : 1,
borderBottom: '1px solid var(--border)',
background: cd ? 'color-mix(in srgb, var(--cyan) 2%, transparent)' : 'transparent',
transition: 'var(--t-fast)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'color-mix(in srgb, var(--cyan) 4%, transparent)'}
onMouseLeave={e => e.currentTarget.style.background = cd ? 'color-mix(in srgb, var(--cyan) 2%, transparent)' : 'transparent'}
>
{/* Impact bar */}
<td style={{ width:3, padding:0 }}>
<div style={{ width:3, height:'100%', minHeight:36, background:imp.bar }}/>
</td>
{/* Time */}
<td style={{ padding:'7px 12px', whiteSpace:'nowrap', width:68 }}>
<div className="font-mono" style={{ fontSize:11, color: past ? 'var(--text-faint)' : 'var(--text-secondary)', fontWeight:600, fontVariantNumeric:'tabular-nums' }}>
{fmtUtcTime(ev.datetime_utc)}
</div>
{cd && (
<div className="font-mono live-dot-blink" style={{ fontSize:8, color:'var(--cyan)', marginTop:2 }}>
in {cd}
</div>
)}
</td>
{/* Currency */}
<td style={{ padding:'7px 8px', width:72, whiteSpace:'nowrap' }}>
<div style={{ display:'flex', alignItems:'center', gap:5 }}>
<span style={{ fontSize:14 }}>{flag}</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, fontWeight:800, color:imp.color, letterSpacing:'0.06em' }}>
{ev.country}
</span>
</div>
</td>
{/* Impact label */}
<td style={{ padding:'7px 6px', width:44 }}>
{imp.badge
? <span className={`tbadge ${imp.badge}`}>{imp.label}</span>
: <span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-faint)', letterSpacing:'0.06em' }}>{imp.label}</span>
}
</td>
{/* Event name */}
<td style={{ padding:'7px 10px' }}>
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, color: cd ? 'var(--text)' : past ? 'var(--text-muted)' : 'var(--text-secondary)',
fontWeight: cd ? 600 : 400 }}>
{ev.title}
</span>
</td>
{/* Actual */}
<td style={{ padding:'7px 12px', textAlign:'right', width:80 }}>
{actual ? (
<span className="font-mono" style={{ fontSize:11, fontWeight:700, color:actualColor, fontVariantNumeric:'tabular-nums' }}>
{bm === 'beat' ? '▲ ' : bm === 'miss' ? '▼ ' : ''}{actual}
</span>
) : (
<span style={{ fontSize:10, color:'var(--text-ghost)' }}></span>
)}
</td>
{/* Forecast */}
<td style={{ padding:'7px 12px', textAlign:'right', width:80 }}>
<span className="font-mono" style={{ fontSize:11, color: ev.forecast?.trim() ? 'var(--text-dim)' : 'var(--text-ghost)', fontVariantNumeric:'tabular-nums' }}>
{ev.forecast?.trim() || '—'}
</span>
</td>
{/* Previous */}
<td style={{ padding:'7px 12px', textAlign:'right', width:80 }}>
<span className="font-mono" style={{ fontSize:11, color: ev.previous?.trim() ? 'var(--text-muted)' : 'var(--text-ghost)', fontVariantNumeric:'tabular-nums' }}>
{ev.previous?.trim() || '—'}
</span>
</td>
</tr>
)
}
/* ─── main component ───────────────────────────────────── */
export default function EconomicCalendar() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [selDate, setSelDate] = useState(todayStr())
const [weekStart, setWeekStart] = useState(() => getMondayOf(new Date()))
const [impFilter, setImpFilter] = useState('All')
const [ccyFilter, setCcyFilter] = useState([])
/* load calendar data */
const load = useCallback(async () => {
setLoading(true); setError(null)
try { setData(await fetchCalendar()) }
catch { setError('Could not load Forex Factory data.') }
finally { setLoading(false) }
}, [])
useEffect(() => { load() }, [])
useEffect(() => {
const id = setInterval(load, 10 * 60 * 1000) // refresh every 10 min
return () => clearInterval(id)
}, [])
/* derived values */
const allEvents = data?.events || []
// dates that have at least one event
const availDates = useMemo(() =>
new Set(allEvents.map(e => e.datetime_utc?.slice(0, 10)).filter(Boolean)),
[allEvents]
)
// week days: Mon-Sun for current weekStart
const weekDays = useMemo(() =>
[...Array(7)].map((_, i) => {
const d = new Date(weekStart)
d.setDate(weekStart.getDate() + i)
return d
}),
[weekStart]
)
// events for selected day
const dayEvents = useMemo(() =>
allEvents.filter(ev => {
if (!ev.datetime_utc?.startsWith(selDate)) return false
if (impFilter !== 'All' && ev.impact !== impFilter) return false
if (ccyFilter.length > 0 && !ccyFilter.includes(ev.country)) return false
return true
}),
[allEvents, selDate, impFilter, ccyFilter]
)
// impact counts per day (for dot indicators)
const impactCounts = useMemo(() => {
const map = {}
for (const ev of allEvents) {
const d = ev.datetime_utc?.slice(0, 10)
if (!d) continue
if (!map[d]) map[d] = { high:0, medium:0, low:0, total:0 }
const k = ev.impact?.toLowerCase()
if (k === 'high') map[d].high++
if (k === 'medium') map[d].medium++
if (k === 'low') map[d].low++
map[d].total++
}
return map
}, [allEvents])
// day event counts for selected date (unfiltered, for header)
const selDayAll = allEvents.filter(e => e.datetime_utc?.startsWith(selDate))
const selImpCounts = impactCounts[selDate] || { high:0, medium:0, low:0 }
/* week navigation */
function prevWeek() {
const next = new Date(weekStart)
next.setDate(weekStart.getDate() - 7)
setWeekStart(next)
}
function nextWeek() {
const next = new Date(weekStart)
next.setDate(weekStart.getDate() + 7)
setWeekStart(next)
}
/* ── render ─────────────────────────────────────────── */
const today = todayStr()
return (
<div style={{ display:'flex', flexDirection:'column', height:'100%' }}>
{/* ── Terminal header + Week navigation ───────── */}
<div className="term-header" style={{ padding:'6px 14px', flexShrink:0, borderRadius:0 }}>
<span className="live-dot-blink"/>
<span className="term-header-cyan">ECONOMIC CALENDAR</span>
<span style={{ opacity:0.4 }}>·</span>
<span className="term-header-title">Forex Factory · UTC</span>
<div style={{ flex:1 }}/>
<button className="btn-ghost" onClick={load} style={{ fontSize:10, padding:'3px 9px' }}>
{loading ? '⟳' : '↻'}
</button>
</div>
{/* ── Week navigation bar ──────────────────────── */}
<div style={{
background:'var(--bg2)', borderBottom:'1px solid var(--border)',
padding:'6px 14px', display:'flex', alignItems:'center', gap:6, flexShrink:0,
}}>
<button onClick={prevWeek} className="btn-ghost" style={{ padding:'3px 8px', fontSize:13, lineHeight:1 }}></button>
<div style={{ display:'flex', gap:3, flex:1, justifyContent:'center' }}>
{weekDays.map(d => {
const ds = toDateStr(d)
const isToday = ds === today
const isSel = ds === selDate
const hasData = availDates.has(ds)
const counts = impactCounts[ds] || { high:0, medium:0, low:0 }
return (
<button key={ds} onClick={() => hasData && setSelDate(ds)}
style={{
minWidth:56, padding:'4px 5px', borderRadius:'var(--r-sm)', cursor: hasData ? 'pointer' : 'default',
border: isSel
? '1px solid color-mix(in srgb, var(--cyan) 50%, transparent)'
: isToday
? '1px solid color-mix(in srgb, var(--cyan) 20%, transparent)'
: '1px solid var(--border-sub)',
background: isSel
? 'color-mix(in srgb, var(--cyan) 12%, transparent)'
: isToday
? 'color-mix(in srgb, var(--cyan) 4%, transparent)'
: 'transparent',
opacity: hasData ? 1 : 0.2,
transition:'var(--t-fast)', textAlign:'center', outline:'none',
}}>
<div style={{ fontFamily:'var(--font-mono)', fontSize:7.5, fontWeight:700, letterSpacing:'0.1em', textTransform:'uppercase',
color: isSel ? 'var(--cyan)' : isToday ? 'color-mix(in srgb, var(--cyan) 70%, transparent)' : 'var(--text-muted)' }}>
{DAY_ABBR[d.getDay()]}
</div>
<div className="font-mono" style={{ fontSize:12, fontWeight:700, marginTop:1, fontVariantNumeric:'tabular-nums',
color: isSel ? 'var(--text)' : hasData ? 'var(--text-secondary)' : 'var(--text-faint)' }}>
{d.getDate()}
</div>
{hasData && <ImpactDots counts={counts} />}
{!hasData && <div style={{ height:7 }}/>}
</button>
)
})}
</div>
<button onClick={nextWeek} className="btn-ghost" style={{ padding:'3px 8px', fontSize:13, lineHeight:1 }}></button>
</div>
{/* ── Day header ───────────────────────────────── */}
<div style={{
padding:'6px 14px', background:'var(--surface)',
borderBottom:'1px solid var(--border)',
display:'flex', alignItems:'center', justifyContent:'space-between',
flexShrink:0, gap:8,
}}>
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, fontWeight:700, color:'var(--text)' }}>
{fmtFullDay(selDate)}
</span>
<div style={{ display:'flex', gap:4, alignItems:'center' }}>
{selImpCounts.high > 0 && (
<span className="tbadge tbadge-high">{selImpCounts.high} High</span>
)}
{selImpCounts.medium > 0 && (
<span className="tbadge tbadge-medium">{selImpCounts.medium} Med</span>
)}
{selImpCounts.low > 0 && (
<span className="tbadge" style={{ fontFamily:'var(--font-mono)', fontSize:8,
background:'var(--bg3)', color:'var(--text-muted)', borderRadius:3, padding:'1px 5px' }}>
{selImpCounts.low} Low
</span>
)}
</div>
</div>
{/* Filters */}
<div style={{ display:'flex', gap:3, alignItems:'center', flexWrap:'wrap' }}>
{['All','High','Medium','Low'].map(v => {
const active = impFilter === v
const accentVar = v === 'High' ? 'var(--red)' : v === 'Medium' ? 'var(--amber)' : 'var(--text-muted)'
return (
<button key={v} onClick={() => setImpFilter(v)} style={{
padding:'2px 8px', borderRadius:'var(--r-sm)', fontFamily:'var(--font-mono)', fontSize:8.5, fontWeight:700,
border: active ? `1px solid color-mix(in srgb, ${v==='All'?'var(--cyan)':accentVar} 40%, transparent)` : '1px solid var(--border-sub)',
background: active ? `color-mix(in srgb, ${v==='All'?'var(--cyan)':accentVar} 10%, transparent)` : 'transparent',
color: active ? (v==='All'?'var(--cyan)':accentVar) : 'var(--text-muted)',
cursor:'pointer', transition:'var(--t-fast)', outline:'none',
}}>{v}</button>
)
})}
<div style={{ width:1, height:12, background:'var(--border-sub)' }}/>
{ALL_CCY.map(ccy => {
const active = ccyFilter.includes(ccy)
return (
<button key={ccy} onClick={() =>
setCcyFilter(p => p.includes(ccy) ? p.filter(c=>c!==ccy) : [...p, ccy])
} style={{
padding:'2px 6px', borderRadius:'var(--r-sm)', fontFamily:'var(--font-mono)', fontSize:8, fontWeight:700,
border: active ? '1px solid color-mix(in srgb, var(--cyan) 35%, transparent)' : '1px solid var(--border-sub)',
background: active ? 'var(--cyan-bg)' : 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)',
cursor:'pointer', transition:'var(--t-fast)', outline:'none',
}}>{FLAGS[ccy]||''} {ccy}</button>
)
})}
{ccyFilter.length > 0 && (
<button onClick={() => setCcyFilter([])} style={{
padding:'2px 5px', borderRadius:3, fontSize:9, border:'none',
background:'transparent', color:'var(--text-muted)', cursor:'pointer',
}}></button>
)}
</div>
</div>
{/* ── Event table ──────────────────────────────── */}
<div style={{ flex:1, overflowY:'auto' }}>
{loading && !data ? (
<div style={{ padding:20 }}>
{[...Array(6)].map((_,i) => (
<div key={i} className="skeleton" style={{ height:44, marginBottom:3, borderRadius:4 }}/>
))}
</div>
) : error ? (
<div style={{ padding:16 }}>
<div style={{ padding:12, borderRadius:6, background:'rgba(255,61,90,0.08)',
border:'1px solid #ff3d5a', color:'#ff3d5a', fontSize:12 }}>{error}</div>
<button className="btn-primary" onClick={load} style={{ marginTop:8 }}>Retry</button>
</div>
) : !availDates.has(selDate) ? (
<div style={{ padding:40, textAlign:'center' }}>
<div style={{ fontSize:28, marginBottom:10 }}>📭</div>
<div style={{ fontSize:13, color:'#475569', fontWeight:600 }}>No data for {fmtFullDay(selDate)}</div>
<div style={{ fontSize:11, color:'#334155', marginTop:6 }}>
Forex Factory publishes the current week's calendar each Sunday.
<br/>Navigate to a date within the available range.
</div>
<div style={{ marginTop:10, display:'flex', gap:6, justifyContent:'center', flexWrap:'wrap' }}>
{[...availDates].sort().map(d => (
<button key={d} onClick={() => setSelDate(d)} style={{
padding:'4px 10px', borderRadius:5, fontSize:10, cursor:'pointer',
background:'rgba(0,212,255,0.07)', border:'1px solid rgba(0,212,255,0.2)',
color:'#00d4ff',
}}>
{new Date(d+'T12:00:00Z').toLocaleDateString('en-US',
{ weekday:'short', month:'short', day:'numeric', timeZone:'UTC' })}
</button>
))}
</div>
</div>
) : dayEvents.length === 0 ? (
<div style={{ padding:30, textAlign:'center', color:'#334155', fontSize:12 }}>
No events match the current filters.
</div>
) : (
<table style={{ width:'100%', borderCollapse:'collapse', tableLayout:'fixed' }}>
<colgroup>
<col style={{ width:3 }}/>
<col style={{ width:68 }}/>
<col style={{ width:78 }}/>
<col style={{ width:44 }}/>
<col/>
<col style={{ width:80 }}/>
<col style={{ width:80 }}/>
<col style={{ width:80 }}/>
</colgroup>
<thead>
<tr style={{ borderBottom:'1px solid var(--border)', position:'sticky', top:0,
background:'var(--bg2)', zIndex:2 }}>
<th style={{ padding:0 }}/>
{['Time (UTC)','Currency','Imp.','Event','Actual','Forecast','Previous'].map((h,i) => (
<th key={h} style={{ padding:'5px '+(i===0||i===1?'12px':'8px'),
fontFamily:'var(--font-mono)', fontSize:8, fontWeight:700, letterSpacing:'0.1em',
textTransform:'uppercase', color:'var(--text-muted)',
textAlign: i >= 4 ? 'right' : 'left' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{dayEvents.map((ev, i) => <EventRow key={i} ev={ev} />)}
</tbody>
</table>
)}
</div>
{/* ── Footer ───────────────────────────────────── */}
<div style={{
padding:'4px 14px', flexShrink:0,
borderTop:'1px solid var(--border)',
background:'var(--bg2)',
display:'flex', alignItems:'center', gap:12,
}}>
<span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-ghost)' }}>
Forex Factory · nfs.faireconomy.media · published weekly · all times UTC
</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-ghost)', marginLeft:'auto' }}>
{data?.count || 0} events
</span>
</div>
</div>
)
}
+164
View File
@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react'
import { usePortfolioStore } from '../store/portfolio'
import { computeGreeks, exportCSV } from '../api/client'
const GREEK_INFO = {
delta: { label:'Δ DELTA', sym:'Δ', color:'var(--cyan)', desc:'Spot price sensitivity', tip:'Change in option value per unit change in spot. Delta 0.8 = option moves 80¢ per $1 of spot.', cls:'cyan' },
gamma: { label:'Γ GAMMA', sym:'Γ', color:'var(--purple)', desc:'Delta convexity', tip:'Rate of change of Delta w.r.t. spot. High Gamma = Delta shifts rapidly near the strike price.', cls:'purple' },
vega: { label:'ν VEGA', sym:'ν', color:'var(--green)', desc:'Volatility sensitivity /1%', tip:'P&L impact per 1% move in implied vol. Long options = positive Vega (vol rising helps you).', cls:'green' },
theta: { label:'Θ THETA', sym:'Θ', color:'var(--red)', desc:'Time decay per year', tip:'Daily P&L erosion due to time passing. Long options pay theta — short options earn it.', cls:'red' },
rho_d: { label:'ρ RHO_D', sym:'ρ', color:'var(--amber)', desc:'Domestic rate sensitivity', tip:'Sensitivity to the domestic (quote currency) interest rate. Usually small on short expirations.', cls:'gold' },
phi: { label:'φ PHI', sym:'φ', color:'#fb923c', desc:'Foreign rate sensitivity', tip:'Sensitivity to the foreign (base currency) rate. This greek is unique to FX options.', cls:'orange' },
}
export default function GreeksDashboard() {
const { legs, S, sigma, T, r_d, r_f, pair } = usePortfolioStore()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const payload = {
options: legs.map(({ type, K, T, qty }) => ({ type, K, T, qty })),
S, sigma, T, r_d, r_f,
}
useEffect(() => {
if (!legs.length) return
setLoading(true); setError(null)
computeGreeks(payload)
.then(setData)
.catch(e => setError(e?.response?.data?.detail || 'Greeks computation failed'))
.finally(() => setLoading(false))
}, [S, sigma, T, r_d, r_f, JSON.stringify(legs)])
const handleExport = async () => {
const blob = await exportCSV(payload)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = `greeks_${pair}.csv`; a.click()
URL.revokeObjectURL(url)
}
if (loading) return (
<div style={{ padding:20 }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:16 }}>
<div className="live-dot"/>
<span style={{ color:'var(--text-muted)', fontSize:12 }}>Computing Garman-Kohlhagen Greeks</span>
</div>
{[...Array(3)].map((_,i) => (
<div key={i} className="skeleton" style={{ height:60, marginBottom:8, borderRadius:8 }}/>
))}
</div>
)
if (error) return (
<div style={{ padding:20 }}>
<div style={{ padding:12, borderRadius:6, background:'rgba(255,61,90,0.1)',
border:'1px solid var(--red)', color:'var(--red)', fontSize:12 }}>{error}</div>
</div>
)
if (!data) return null
return (
<div style={{ padding:16 }}>
{/* Terminal header */}
<div className="term-header" style={{ margin:'-16px -16px 14px', padding:'8px 16px' }}>
<span className="term-header-cyan">PORTFOLIO GREEKS</span>
<span style={{ marginLeft:4, opacity:0.5 }}>·</span>
<span className="term-header-title">{pair}</span>
<span style={{ marginLeft:4, opacity:0.5 }}>·</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9.5, color:'var(--text-muted)', letterSpacing:'0.04em' }}>
S={S} · σ={sigma} · r_d={r_d} · r_f={r_f}
</span>
<div style={{ flex:1 }}/>
<button className="btn-ghost" onClick={handleExport} style={{ fontSize:10, padding:'3px 9px' }}>
CSV
</button>
</div>
{/* Greek cards — left-border Bloomberg style, 3+3 grid */}
<div style={{ display:'grid', gridTemplateColumns:'repeat(3,1fr)', gap:6, marginBottom:14 }}>
{Object.entries(GREEK_INFO).map(([key, info]) => {
const val = data.total[key]
const formatted = val !== undefined ? (val >= 0 ? '+' : '') + val.toFixed(4) : '—'
return (
<div key={key} className={`greek-card ${info.cls}`} title={info.tip}>
<div style={{ display:'flex', alignItems:'flex-start', justifyContent:'space-between', marginBottom:6 }}>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)',
textTransform:'uppercase', letterSpacing:'0.08em' }}>{info.desc}</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:17, fontWeight:800,
color:info.color, lineHeight:1, fontVariantNumeric:'tabular-nums' }}>
{info.sym}
</span>
</div>
<div className="greek-value" style={{ color:info.color, marginBottom:4 }}>{formatted}</div>
<div style={{ fontFamily:'var(--font-mono)', fontSize:9.5, fontWeight:700,
color:info.color, opacity:0.7, letterSpacing:'0.1em' }}>{info.label}</div>
</div>
)
})}
</div>
{/* Quick stats */}
<div style={{ display:'flex', gap:12, marginBottom:14, flexWrap:'wrap' }}>
<div className="glass" style={{ padding:'8px 14px', borderRadius:6, fontSize:11 }}>
<span style={{ color:'var(--text-muted)' }}>Daily Theta: </span>
<span className="font-mono" style={{ color:'var(--red)' }}>
{data.total.theta !== undefined ? (data.total.theta / 365).toFixed(5) : '—'} /day
</span>
</div>
<div className="glass" style={{ padding:'8px 14px', borderRadius:6, fontSize:11 }}>
<span style={{ color:'var(--text-muted)' }}>Vega per 1% vol: </span>
<span className="font-mono" style={{ color:'var(--green)' }}>
{data.total.vega !== undefined ? (data.total.vega / 100).toFixed(5) : '—'}
</span>
</div>
<div className="glass" style={{ padding:'8px 14px', borderRadius:6, fontSize:11 }}>
<span style={{ color:'var(--text-muted)' }}>Net Delta: </span>
<span className="font-mono" style={{ color: data.total.delta >= 0 ? 'var(--green)' : 'var(--red)' }}>
{data.total.delta !== undefined ? (data.total.delta >= 0 ? '+' : '') + data.total.delta.toFixed(4) : '—'}
</span>
</div>
</div>
{/* Per-leg breakdown */}
<div className="section-header" style={{ marginBottom:6 }}>
<span className="section-title">Per-Leg Breakdown</span>
</div>
<div style={{ overflowX:'auto', borderRadius:8, border:'1px solid var(--border)' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:10 }}>
<thead>
<tr style={{ background:'var(--bg3)', color:'var(--text-muted)' }}>
{['Leg','Type','Strike','Delta','Gamma','Vega','Theta','Rho_d','Phi'].map(h => (
<th key={h} style={{ textAlign: h==='Leg'||h==='Type'?'left':'right',
padding:'6px 10px', fontWeight:600, fontSize:9, letterSpacing:'0.05em' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{data.legs.map((leg, i) => (
<tr key={i} style={{ borderTop:'1px solid var(--border)',
background: i%2===0?'transparent':'rgba(0,212,255,0.015)' }}>
<td style={{ padding:'5px 10px', color:'var(--cyan)', fontSize:10 }}>{leg.label}</td>
<td style={{ padding:'5px 10px', color:'var(--text-dim)', fontSize:9 }}>{leg.type?.toUpperCase()}</td>
<td className="font-mono" style={{ padding:'5px 10px', textAlign:'right', color:'var(--text-dim)' }}>
{leg.K?.toFixed(5)}
</td>
<td className="font-mono" style={{ padding:'5px 10px', textAlign:'right',
color: leg.delta >= 0 ? 'var(--green)' : 'var(--red)' }}>
{leg.delta !== undefined ? (leg.delta >= 0 ? '+' : '') + leg.delta : '—'}
</td>
<td className="font-mono" style={{ padding:'5px 10px', textAlign:'right', color:'#a78bfa' }}>{leg.gamma}</td>
<td className="font-mono" style={{ padding:'5px 10px', textAlign:'right', color:'var(--green)' }}>{leg.vega}</td>
<td className="font-mono" style={{ padding:'5px 10px', textAlign:'right', color:'var(--red)' }}>{leg.theta}</td>
<td className="font-mono" style={{ padding:'5px 10px', textAlign:'right', color:'var(--gold)' }}>{leg.rho_d ?? '—'}</td>
<td className="font-mono" style={{ padding:'5px 10px', textAlign:'right', color:'#fb923c' }}>{leg.phi ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { useState } from 'react'
import Plot from 'react-plotly.js'
import { fetchIVSurface } from '../api/client'
import { usePortfolioStore } from '../store/portfolio'
export default function IVSurface() {
const { ticker } = usePortfolioStore()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [optType, setOptType] = useState('call')
const load = async () => {
setLoading(true); setError(null)
try {
const result = await fetchIVSurface(ticker)
setData(result)
} catch (e) {
setError(e?.response?.data?.detail || 'Failed to fetch option chain.')
} finally {
setLoading(false)
}
}
const filtered = data?.data.filter(d => d.type === optType) ?? []
const expiries = [...new Set(filtered.map(d => d.expiry))].sort()
const strikes = [...new Set(filtered.map(d => d.strike))].sort((a, b) => a - b)
// Build IV matrix: rows=strikes, cols=expiries
const Z = strikes.map(k =>
expiries.map(exp => {
const pt = filtered.find(d => d.strike === k && d.expiry === exp)
return pt ? pt.iv * 100 : null
})
)
const plotData = data ? [{
type: 'surface',
x: expiries,
y: strikes,
z: Z,
colorscale: 'RdYlGn_r',
colorbar: { title: 'IV (%)', tickfont: { color: '#e2e8f0' } },
contours: { z: { show: true, usecolormap: true, project: { z: true } } },
}] : []
return (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3">
<h2 className="text-sm font-bold uppercase tracking-widest" style={{ color: 'var(--accent)' }}>
Live IV Surface {ticker}
</h2>
<div className="flex gap-1">
{['call', 'put'].map(t => (
<button key={t} onClick={() => setOptType(t)}
className="text-xs px-2 py-1 rounded capitalize"
style={{ background: optType === t ? 'var(--accent2)' : 'var(--border)', color: optType === t ? '#fff' : 'var(--text)' }}>
{t}s
</button>
))}
</div>
<button onClick={load} disabled={loading}
className="px-4 py-1.5 text-sm rounded font-medium"
style={{ background: 'var(--accent)', color: '#0a0e1a' }}>
{loading ? 'Loading...' : '📡 Fetch Live Data'}
</button>
{data && (
<span className="text-xs" style={{ color: 'var(--muted)' }}>
Spot: <span className="font-mono" style={{ color: 'var(--text)' }}>${data.spot?.toFixed(2)}</span>
</span>
)}
</div>
{error && (
<div className="text-xs p-3 rounded" style={{ background: 'rgba(248,113,113,0.1)', color: 'var(--red)', border: '1px solid var(--red)' }}>
{error}
</div>
)}
{data ? (
<Plot data={plotData}
layout={{
paper_bgcolor: 'transparent', plot_bgcolor: '#111827',
scene: {
xaxis: { title: 'Expiry', color: '#64748b', gridcolor: '#1f2937' },
yaxis: { title: 'Strike', color: '#64748b', gridcolor: '#1f2937' },
zaxis: { title: 'IV (%)', color: '#64748b', gridcolor: '#1f2937' },
bgcolor: '#0a0e1a',
},
font: { color: '#e2e8f0' },
margin: { l: 0, r: 0, t: 20, b: 0 },
}}
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
style={{ width: '100%', height: 440 }} />
) : (
<div className="flex flex-col items-center justify-center h-48 rounded gap-2"
style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}>
<span className="text-2xl">📡</span>
<span className="text-sm" style={{ color: 'var(--muted)' }}>
Fetch live option chain data from Yahoo Finance
</span>
<span className="text-xs" style={{ color: 'var(--muted)' }}>
Works with any US equity ticker (SPY, AAPL, TSLA, QQQ...)
</span>
</div>
)}
</div>
)
}
@@ -0,0 +1,439 @@
import { useState, useEffect } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore } from '../store/portfolio'
import { fetchInstitutional } from '../api/client'
const C = {
am: '#00ff88', // asset managers real money green
lm: '#a78bfa', // leveraged money (hedge funds) purple
dealer: '#ff3d5a', // dealers red
oi: '#00d4ff', // open interest cyan
poc: '#ffd700', // POC gold
vah: 'rgba(0,212,255,0.6)',
val: 'rgba(0,212,255,0.6)',
now: '#00ff88',
}
const BIAS_BG = { BULLISH:'rgba(0,255,136,0.1)', BEARISH:'rgba(255,61,90,0.1)', NEUTRAL:'rgba(255,215,0,0.1)' }
const BIAS_C = { BULLISH:'#00ff88', BEARISH:'#ff3d5a', NEUTRAL:'#ffd700' }
const BIAS_BD = { BULLISH:'rgba(0,255,136,0.3)', BEARISH:'rgba(255,61,90,0.3)', NEUTRAL:'rgba(255,215,0,0.3)' }
const Badge = ({ v }) => (
<span style={{ padding:'2px 9px', borderRadius:20, fontSize:9, fontWeight:700,
background: BIAS_BG[v]||'rgba(148,163,184,0.1)',
color: BIAS_C[v]||'#94a3b8',
border:`1px solid ${BIAS_BD[v]||'rgba(148,163,184,0.2)'}`,
textTransform:'uppercase', letterSpacing:'0.07em' }}>{v}</span>
)
const StatCard = ({ label, value, sub, color='var(--cyan)', border }) => (
<div className="glass" style={{ padding:'10px 14px', borderRadius:8, flex:1,
borderLeft: border ? `3px solid ${border}` : undefined }}>
<div style={{ fontSize:9, color:'var(--text-muted)', textTransform:'uppercase',
letterSpacing:'0.08em', marginBottom:3 }}>{label}</div>
<div className="font-mono" style={{ fontSize:17, fontWeight:700, color }}>{value}</div>
{sub && <div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>{sub}</div>}
</div>
)
const bgLayout = {
paper_bgcolor:'transparent', plot_bgcolor:'rgba(4,10,28,0.7)',
font:{ color:'#475569', family:'JetBrains Mono, monospace', size:9 },
margin:{ l:56, r:12, t:20, b:40 },
xaxis:{ gridcolor:'rgba(0,212,255,0.05)', color:'#475569', zeroline:false, tickangle:-30 },
yaxis:{ gridcolor:'rgba(0,212,255,0.05)', color:'#475569', zeroline:false },
hovermode:'x unified',
hoverlabel:{ bgcolor:'rgba(4,10,28,0.95)', bordercolor:'rgba(0,212,255,0.3)',
font:{ color:'#e0f2fe', family:'JetBrains Mono', size:10 } },
legend:{ bgcolor:'transparent', font:{ size:9, color:'#64748b' },
orientation:'h', x:0, y:1.04 },
}
export default function InstitutionalFlow() {
const { pair } = usePortfolioStore()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [weeks, setWeeks] = useState(26)
const load = async () => {
setLoading(true); setError(null)
try { setData(await fetchInstitutional(pair, weeks)) }
catch (e) { setError(e?.response?.data?.detail || 'Data unavailable.') }
finally { setLoading(false) }
}
useEffect(() => { load() }, [pair, weeks])
if (loading) return (
<div style={{ padding:16 }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:16 }}>
<div className="live-dot"/>
<span style={{ fontSize:12, color:'var(--text-muted)' }}>
Fetching CFTC TFF data + computing volume profile
</span>
</div>
{[...Array(6)].map((_,i) => (
<div key={i} className="skeleton" style={{ height:50, marginBottom:6, borderRadius:6 }}/>
))}
</div>
)
if (error) return (
<div style={{ padding:16 }}>
<div style={{ padding:12, borderRadius:6, background:'rgba(255,61,90,0.1)',
border:'1px solid #ff3d5a', color:'#ff3d5a', fontSize:12, marginBottom:10 }}>{error}</div>
<button className="btn-primary" onClick={load}>Retry</button>
</div>
)
if (!data) return null
const { cot: c, volume_profile: vp } = data
// Build Plotly traces
// 1. Asset Manager net bars
const amColors = (c.am_net||[]).map(v => v >= 0 ? 'rgba(0,255,136,0.75)' : 'rgba(255,61,90,0.55)')
const amBar = {
type:'bar', name:'Asset Mgr Net',
x:c.dates, y:c.am_net,
marker:{ color:amColors, line:{ width:0 } },
hovertemplate:'<b>%{x}</b><br>AM Net: <b>%{y:,}</b><extra></extra>',
}
// 2. Leveraged money net line
const lmLine = {
type:'scatter', mode:'lines+markers', name:'Hedge Fund Net',
x:c.dates, y:c.lm_net,
line:{ color:C.lm, width:2 },
marker:{ size:4, color:C.lm },
hovertemplate:'HF Net: %{y:,}<extra></extra>',
}
// 3. Dealer net line
const dealerLine = {
type:'scatter', mode:'lines', name:'Dealer Net',
x:c.dates, y:c.dealer_net,
line:{ color:C.dealer, width:1.5, dash:'dot' },
hovertemplate:'Dealer Net: %{y:,}<extra></extra>',
}
// 4. Open interest
const oiTrace = {
type:'scatter', mode:'lines+markers', name:'Open Interest',
x:c.dates, y:c.open_interest,
line:{ color:C.oi, width:2 },
marker:{ size:4, color:C.oi },
fill:'tozeroy', fillcolor:'rgba(0,212,255,0.05)',
hovertemplate:'OI: %{y:,.0f}<extra></extra>',
}
// 5. COT Index lines (rolling percentile)
const amIdx = (c.am_net||[]).map((_, i, a) => {
const sl = a.slice(0, i + 1), lo = Math.min(...sl), hi = Math.max(...sl)
return hi > lo ? Math.round((a[i] - lo) / (hi - lo) * 100) : 50
})
const lmIdx = (c.lm_net||[]).map((_, i, a) => {
const sl = a.slice(0, i + 1), lo = Math.min(...sl), hi = Math.max(...sl)
return hi > lo ? Math.round((a[i] - lo) / (hi - lo) * 100) : 50
})
const amIdxTrace = {
type:'scatter', mode:'lines', name:'AM Index',
x:c.dates, y:amIdx,
line:{ color:C.am, width:2 },
hovertemplate:'AM Index: %{y}<extra></extra>',
}
const lmIdxTrace = {
type:'scatter', mode:'lines', name:'HF Index',
x:c.dates, y:lmIdx,
line:{ color:C.lm, width:1.5, dash:'dot' },
hovertemplate:'HF Index: %{y}<extra></extra>',
}
// 6. Long/short stacked bars
const amLongBar = { type:'bar', name:'AM Long', x:c.dates, y:c.am_long,
marker:{ color:'rgba(0,255,136,0.65)' },
hovertemplate:'AM Long: %{y:,}<extra></extra>' }
const amShortBar = { type:'bar', name:'AM Short', x:c.dates, y:c.am_short.map(v => -v),
marker:{ color:'rgba(255,61,90,0.55)' },
hovertemplate:'AM Short: %{y:,}<extra></extra>' }
const lmLongBar = { type:'bar', name:'HF Long', x:c.dates, y:c.lm_long,
marker:{ color:'rgba(167,139,250,0.6)' },
hovertemplate:'HF Long: %{y:,}<extra></extra>' }
const lmShortBar = { type:'bar', name:'HF Short', x:c.dates, y:c.lm_short.map(v => -v),
marker:{ color:'rgba(167,139,250,0.35)' },
hovertemplate:'HF Short: %{y:,}<extra></extra>' }
// 7. Volume Profile
const vpColors = (vp.volumes||[]).map((_, i) => {
const p = (vp.prices||[])[i]
if (!p) return 'rgba(0,212,255,0.22)'
const bktSize = (vp.global_hi - vp.global_lo) / (vp.prices?.length || 40)
if (Math.abs(p - vp.poc) < bktSize * 0.6) return 'rgba(255,215,0,0.9)'
if (p >= vp.val && p <= vp.vah) return 'rgba(0,212,255,0.55)'
return 'rgba(0,212,255,0.22)'
})
const vpTrace = {
type:'bar', orientation:'h',
x:vp.volumes, y:vp.prices,
name:'Volume',
marker:{ color:vpColors, line:{ width:0 } },
hovertemplate:'Price: %{y:.5f}<br>Vol: %{x:,.0f}<extra></extra>',
}
const vpShapes = [
{ type:'line', x0:0, x1:1, xref:'paper', y0:vp.current_price, y1:vp.current_price,
line:{ color:'#00ff88', width:1.5, dash:'dash' } },
{ type:'line', x0:0, x1:1, xref:'paper', y0:vp.poc, y1:vp.poc,
line:{ color:'#ffd700', width:1.5, dash:'solid' } },
{ type:'line', x0:0, x1:1, xref:'paper', y0:vp.vah, y1:vp.vah,
line:{ color:'rgba(0,212,255,0.5)', width:1, dash:'dash' } },
{ type:'line', x0:0, x1:1, xref:'paper', y0:vp.val, y1:vp.val,
line:{ color:'rgba(0,212,255,0.5)', width:1, dash:'dash' } },
].filter(s => s.line && s.y0)
const indexShapes = [
{ type:'rect', x0:0, x1:1, xref:'paper', y0:65, y1:100,
fillcolor:'rgba(0,255,136,0.04)', line:{ width:0 } },
{ type:'rect', x0:0, x1:1, xref:'paper', y0:0, y1:35,
fillcolor:'rgba(255,61,90,0.04)', line:{ width:0 } },
{ type:'line', x0:0, x1:1, xref:'paper', y0:65, y1:65,
line:{ color:'rgba(0,255,136,0.3)', width:1, dash:'dot' } },
{ type:'line', x0:0, x1:1, xref:'paper', y0:35, y1:35,
line:{ color:'rgba(255,61,90,0.3)', width:1, dash:'dot' } },
]
const fmtNum = n => n >= 0 ? `+${n.toLocaleString()}` : n.toLocaleString()
return (
<div style={{ padding:16 }}>
{/* ── Header ──────────────────────────────────────── */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:14 }}>
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
<span className="section-title">Institutional Flow {pair}</span>
<Badge v={c.composite_bias}/>
<span style={{ fontSize:9, color:'var(--text-muted)' }}>
{c.weeks}W · CFTC TFF · {c.latest_date}
</span>
</div>
<div style={{ display:'flex', gap:4, alignItems:'center',
background:'rgba(0,0,0,0.2)', borderRadius:8, padding:3,
border:'1px solid rgba(0,212,255,0.08)' }}>
{[13, 26, 52].map(w => (
<button key={w} onClick={() => setWeeks(w)} style={{
padding:'4px 11px', fontSize:10, borderRadius:6, border:'none',
cursor:'pointer', fontFamily:'JetBrains Mono',
background: weeks===w ? 'rgba(0,212,255,0.14)' : 'transparent',
color: weeks===w ? '#00d4ff' : '#475569',
boxShadow: weeks===w ? '0 0 8px rgba(0,212,255,0.2)' : 'none',
transition:'all 0.2s',
}}>{w}W</button>
))}
<button className="btn-ghost" onClick={load} style={{ fontSize:10, marginLeft:2 }}></button>
</div>
</div>
{/* ── Composite Index gauge ───────────────────────── */}
<div className="glass" style={{ padding:'12px 16px', borderRadius:8, marginBottom:12,
display:'flex', alignItems:'center', justifyContent:'space-between',
borderLeft:`3px solid ${BIAS_C[c.composite_bias]||'#94a3b8'}` }}>
<div>
<div style={{ fontSize:9, color:'var(--text-muted)', textTransform:'uppercase',
letterSpacing:'0.08em', marginBottom:4 }}>Composite Institutional Index</div>
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
<span className="font-mono" style={{ fontSize:28, fontWeight:800,
color: BIAS_C[c.composite_bias]||'#94a3b8',
textShadow:`0 0 18px ${BIAS_C[c.composite_bias]||'#94a3b8'}66` }}>
{c.composite_index}
</span>
<div>
<Badge v={c.composite_bias}/>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:3 }}>
0 = max institutional short · 100 = max institutional long
</div>
</div>
</div>
</div>
{/* Progress bar */}
<div style={{ width:220 }}>
<div style={{ display:'flex', justifyContent:'space-between', fontSize:8,
color:'var(--text-muted)', marginBottom:4 }}>
<span>BEAR</span><span>NEUTRAL</span><span>BULL</span>
</div>
<div style={{ height:10, background:'rgba(255,255,255,0.05)',
borderRadius:5, border:'1px solid rgba(255,255,255,0.08)', position:'relative' }}>
<div style={{ position:'absolute', left:0, top:0, height:'100%', borderRadius:5,
width:`${c.composite_index}%`,
background:`linear-gradient(90deg, #ff3d5a, #ffd700 50%, #00ff88)`,
transition:'width 0.5s ease' }}/>
<div style={{ position:'absolute', top:-2, left:`${c.composite_index}%`,
transform:'translateX(-50%)',
width:14, height:14, borderRadius:'50%',
background: BIAS_C[c.composite_bias]||'#94a3b8',
border:'2px solid rgba(4,10,28,0.8)',
boxShadow:`0 0 8px ${BIAS_C[c.composite_bias]||'#94a3b8'}` }}/>
</div>
</div>
</div>
{/* ── Trader category cards ──────────────────────── */}
<div style={{ display:'flex', gap:8, marginBottom:14, flexWrap:'wrap' }}>
<StatCard label="Asset Managers" border={C.am}
value={fmtNum(c.am_current_net)}
sub={`Index: ${c.am_index} · ${c.am_bias} · Δ${fmtNum(c.am_wk_change)} wk`}
color={c.am_current_net >= 0 ? C.am : '#ff3d5a'} />
<StatCard label="Hedge Funds (Lev. Money)" border={C.lm}
value={fmtNum(c.lm_current_net)}
sub={`Index: ${c.lm_index} · ${c.lm_bias} · Δ${fmtNum(c.lm_wk_change)} wk`}
color={c.lm_current_net >= 0 ? C.am : '#ff3d5a'} />
<StatCard label="Open Interest" border={C.oi}
value={(c.open_interest?.at(-1)||0).toLocaleString()}
sub="Total outstanding futures contracts"
color={C.oi} />
{vp.poc && (
<StatCard label="Vol. Profile POC" border={C.poc}
value={vp.poc?.toFixed(5)}
sub={`VAH: ${vp.vah?.toFixed(5)} · VAL: ${vp.val?.toFixed(5)}`}
color={C.poc} />
)}
</div>
{/* ── Charts ─────────────────────────────────────── */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 320px', gap:10 }}>
{/* Left column */}
<div style={{ display:'flex', flexDirection:'column', gap:10 }}>
{/* Asset Manager + Hedge Fund net positioning */}
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ paddingLeft:4, marginBottom:4 }}>
<span style={{ fontSize:9, color:C.am, fontWeight:700,
textTransform:'uppercase', letterSpacing:'0.1em' }}>
Asset Manager & Hedge Fund Net Positioning
</span>
<span style={{ fontSize:8, color:'var(--text-muted)', marginLeft:8 }}>
Bars = Asset Mgr · Line = Hedge Funds
</span>
</div>
<Plot data={[amBar, lmLine, dealerLine]}
layout={{ ...bgLayout, margin:{...bgLayout.margin, t:8},
barmode:'overlay',
shapes:[{ type:'line', x0:0, x1:1, xref:'paper', y0:0, y1:0,
line:{ color:'rgba(255,255,255,0.12)', width:1 } }] }}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:210 }} />
</div>
{/* COT Index (rolling percentile) */}
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ paddingLeft:4, marginBottom:4 }}>
<span style={{ fontSize:9, color:C.oi, fontWeight:700,
textTransform:'uppercase', letterSpacing:'0.1em' }}>
Institutional Positioning Index (0100 Percentile)
</span>
</div>
<Plot data={[amIdxTrace, lmIdxTrace]}
layout={{ ...bgLayout, margin:{...bgLayout.margin, t:8},
yaxis:{ ...bgLayout.yaxis, range:[0,100],
tickvals:[0,35,50,65,100],
ticktext:['0','Bear','50','Bull','100'] },
shapes:indexShapes }}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:175 }} />
</div>
{/* Long/short breakdown stacked */}
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ paddingLeft:4, marginBottom:4 }}>
<span style={{ fontSize:9, color:'var(--text-dim)', fontWeight:700,
textTransform:'uppercase', letterSpacing:'0.1em' }}>
Long / Short Breakdown (above = long, below = short)
</span>
</div>
<Plot data={[amLongBar, amShortBar, lmLongBar, lmShortBar]}
layout={{ ...bgLayout, margin:{...bgLayout.margin, t:8},
barmode:'group',
shapes:[{ type:'line', x0:0, x1:1, xref:'paper', y0:0, y1:0,
line:{ color:'rgba(255,255,255,0.12)', width:1 } }] }}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:175 }} />
</div>
{/* Open interest */}
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ paddingLeft:4, marginBottom:4 }}>
<span style={{ fontSize:9, color:C.oi, fontWeight:700,
textTransform:'uppercase', letterSpacing:'0.1em' }}>
Open Interest
</span>
</div>
<Plot data={[oiTrace]}
layout={{ ...bgLayout, margin:{...bgLayout.margin, t:8} }}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:140 }} />
</div>
</div>
{/* Right: Volume Profile */}
{vp.prices && (
<div className="glass" style={{ borderRadius:8, padding:8 }}>
<div style={{ fontSize:9, color:C.poc, fontWeight:700,
textTransform:'uppercase', letterSpacing:'0.1em', marginBottom:8, paddingLeft:4 }}>
Volume Profile 3 Months
</div>
{/* Legend */}
<div style={{ display:'flex', flexDirection:'column', gap:4, marginBottom:10, paddingLeft:4 }}>
{[
{ color:C.poc, label:`POC ${vp.poc?.toFixed(5)}`, dash:false },
{ color:'rgba(0,212,255,0.7)', label:`VAH ${vp.vah?.toFixed(5)}`, dash:true },
{ color:'rgba(0,212,255,0.7)', label:`VAL ${vp.val?.toFixed(5)}`, dash:true },
{ color:C.now, label:`Now ${vp.current_price?.toFixed(5)}`, dash:true },
].map(({ color, label, dash }) => (
<div key={label} style={{ display:'flex', alignItems:'center', gap:6 }}>
<div style={{ width:20, height:1.5, background:color, borderRadius:1,
borderTop: dash ? `1.5px dashed ${color}` : undefined,
background: dash ? 'none' : color }}/>
<span className="font-mono" style={{ fontSize:9, color }}>{label}</span>
</div>
))}
<div style={{ marginTop:4, fontSize:8, color:'var(--text-muted)', lineHeight:1.4 }}>
<span style={{ color:'rgba(0,212,255,0.55)' }}></span> Value Area (70% vol)<br/>
<span style={{ color:C.poc }}></span> POC max volume
</div>
</div>
<Plot data={[vpTrace]}
layout={{
paper_bgcolor:'transparent', plot_bgcolor:'rgba(4,10,28,0.7)',
font:{ color:'#475569', family:'JetBrains Mono', size:8 },
margin:{ l:58, r:10, t:8, b:28 },
xaxis:{ gridcolor:'rgba(0,212,255,0.04)', color:'#475569', zeroline:false,
title:{ text:'Volume', font:{ size:8 } } },
yaxis:{ gridcolor:'rgba(0,212,255,0.04)', color:'#475569', zeroline:false,
tickformat:'.4f', tickfont:{ size:7.5 } },
shapes:vpShapes,
showlegend:false,
hovermode:'y unified',
}}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:620 }} />
</div>
)}
</div>
{/* ── Attribution ─────────────────────────────────── */}
<div style={{ display:'flex', gap:8, marginTop:10, flexWrap:'wrap' }}>
{(data.data_sources||[]).map(src => (
<div key={src} style={{ padding:'4px 10px', borderRadius:6, fontSize:9,
background:'rgba(0,212,255,0.04)', border:'1px solid rgba(0,212,255,0.1)',
color:'var(--text-muted)' }}>
📡 {src}
</div>
))}
</div>
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import { useState, useEffect, useCallback } from 'react'
const CITIES = [
{ name:'New York', sub:'NYSE · Wall St', lat:40.71, lon:-74.00 },
{ name:'London', sub:'LSE · Canary Wharf', lat:51.51, lon:-0.09 },
{ name:'Tokyo', sub:'TSE · Marunouchi', lat:35.68, lon:139.76 },
{ name:'Frankfurt', sub:'XETRA · ECB', lat:50.11, lon:8.68 },
{ name:'Singapore', sub:'SGX · Marina Bay', lat:1.28, lon:103.85 },
{ name:'Dubai', sub:'DFM · DIFC', lat:25.21, lon:55.28 },
]
function windyUrl(lat, lon) {
return `https://embed.windy.com/embed2.html?type=map&location=coordinates&zoom=12&overlay=webcams&product=ecmwf&level=surface&lat=${lat}&lon=${lon}&menu=&message=&marker=&pressure=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
}
function CryptoCard({ coin }) {
const up = coin.price_change_percentage_24h >= 0
const color = up ? 'var(--green)' : 'var(--red)'
const fmt = (n, d=2) => n == null ? '—' : n.toLocaleString('en-US', { maximumFractionDigits: d })
return (
<div style={{
background:'var(--surface)', border:'1px solid var(--border)',
borderLeft:`3px solid ${color}`,
borderRadius:'var(--r-md)', padding:'10px 14px',
display:'flex', flexDirection:'column', gap:6,
transition:'var(--t-fast)',
cursor:'default',
}}
onMouseEnter={e => e.currentTarget.style.background='var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background='var(--surface)'}
>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between' }}>
<div style={{ display:'flex', alignItems:'center', gap:7 }}>
<img src={coin.image} alt={coin.symbol} width={20} height={20} style={{ borderRadius:'50%' }}/>
<div>
<div style={{ fontFamily:'var(--font-mono)', fontSize:10, fontWeight:700, color:'var(--text)', textTransform:'uppercase' }}>
{coin.symbol}
</div>
<div style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-muted)' }}>{coin.name}</div>
</div>
</div>
<span style={{
fontFamily:'var(--font-mono)', fontSize:8, fontWeight:700, padding:'1px 6px',
borderRadius:3, color, background:`color-mix(in srgb, ${color} 12%, transparent)`,
}}>
{up ? '▲' : '▼'} {Math.abs(coin.price_change_percentage_24h).toFixed(2)}%
</span>
</div>
<div style={{ fontFamily:'var(--font-mono)', fontSize:16, fontWeight:800, color:'var(--text)', fontVariantNumeric:'tabular-nums' }}>
${fmt(coin.current_price)}
</div>
<div style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-muted)', display:'flex', gap:10 }}>
<span>MCap ${coin.market_cap > 1e9 ? (coin.market_cap/1e9).toFixed(1)+'B' : (coin.market_cap/1e6).toFixed(0)+'M'}</span>
<span>Vol ${coin.total_volume > 1e9 ? (coin.total_volume/1e9).toFixed(1)+'B' : (coin.total_volume/1e6).toFixed(0)+'M'}</span>
</div>
</div>
)
}
export default function LiveFeeds() {
const [crypto, setCrypto] = useState([])
const [cryptoErr, setCryptoErr] = useState(null)
const [loadCrypto, setLoadCrypto] = useState(false)
const [activeCity, setActiveCity] = useState(0)
const [lastCrypto, setLastCrypto] = useState(null)
const fetchCrypto = useCallback(async () => {
setLoadCrypto(true); setCryptoErr(null)
try {
const r = await fetch(
'https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=12&page=1&sparkline=false&price_change_percentage=24h'
)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const d = await r.json()
setCrypto(d)
setLastCrypto(new Date().toLocaleTimeString())
} catch (e) {
setCryptoErr('CoinGecko rate limited — retrying in 60s')
} finally { setLoadCrypto(false) }
}, [])
useEffect(() => {
fetchCrypto()
const iv = setInterval(fetchCrypto, 60000)
return () => clearInterval(iv)
}, [fetchCrypto])
return (
<div style={{ display:'flex', flexDirection:'column', height:'100%', overflowY:'auto' }} className="term-scroll">
{/* ── Crypto section ───────────────── */}
<div className="term-header" style={{ padding:'6px 14px', borderRadius:0, flexShrink:0 }}>
<span className="live-dot-blink"/>
<span className="term-header-cyan">CRYPTO MARKETS</span>
<span style={{ opacity:0.4 }}>·</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)' }}>CoinGecko · top 12 by market cap</span>
{lastCrypto && <>
<span style={{ opacity:0.4 }}>·</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)' }}>{lastCrypto}</span>
</>}
<div style={{ flex:1 }}/>
{loadCrypto && <span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--cyan)' }}> loading</span>}
<button className="btn-ghost" onClick={fetchCrypto} style={{ fontSize:10, padding:'3px 9px' }}></button>
</div>
<div style={{ padding:'10px 14px', background:'var(--bg)', borderBottom:'1px solid var(--border)' }}>
{cryptoErr ? (
<div style={{
padding:'10px 14px', borderRadius:'var(--r-md)',
background:'var(--amber-bg)', border:'1px solid var(--amber)',
fontFamily:'var(--font-mono)', fontSize:10, color:'var(--amber)',
display:'flex', alignItems:'center', justifyContent:'space-between',
}}>
{cryptoErr}
<button className="btn-ghost" onClick={fetchCrypto} style={{ fontSize:9 }}>Retry</button>
</div>
) : crypto.length === 0 ? (
<div style={{ display:'grid', gridTemplateColumns:'repeat(6,1fr)', gap:8 }}>
{[...Array(12)].map((_,i) => (
<div key={i} className="skeleton" style={{ height:90, borderRadius:'var(--r-md)' }}/>
))}
</div>
) : (
<div style={{ display:'grid', gridTemplateColumns:'repeat(6,1fr)', gap:8 }}>
{crypto.map(c => <CryptoCard key={c.id} coin={c}/>)}
</div>
)}
</div>
{/* ── Live Webcams section ──────────── */}
<div className="term-header" style={{ padding:'6px 14px', borderRadius:0, flexShrink:0 }}>
<span className="live-dot-blink"/>
<span className="term-header-cyan">LIVE GLOBAL CAMERAS</span>
<span style={{ opacity:0.4 }}>·</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)' }}>
Windy.com · click camera pins on map for live feeds
</span>
<div style={{ flex:1 }}/>
</div>
{/* City tabs */}
<div style={{
display:'flex', gap:3, padding:'6px 12px',
background:'var(--bg2)', borderBottom:'1px solid var(--border)',
overflowX:'auto', flexShrink:0,
}}>
{CITIES.map((c, i) => {
const active = activeCity === i
return (
<button key={i} onClick={() => setActiveCity(i)} style={{
fontFamily:'var(--font-mono)', fontSize:9, fontWeight:700, letterSpacing:'0.06em',
padding:'4px 12px', borderRadius:'var(--r-sm)', cursor:'pointer', outline:'none',
whiteSpace:'nowrap',
border: active ? '1px solid color-mix(in srgb, var(--cyan) 30%, transparent)' : '1px solid var(--border-sub)',
background: active ? 'color-mix(in srgb, var(--cyan) 10%, transparent)' : 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)',
transition:'var(--t-fast)',
}}>
{c.name}
</button>
)
})}
</div>
{/* Active city info bar */}
<div style={{
padding:'5px 14px', background:'var(--surface)',
borderBottom:'1px solid var(--border)', flexShrink:0,
display:'flex', alignItems:'center', gap:12,
}}>
<div>
<span style={{ fontFamily:'var(--font-mono)', fontSize:11, fontWeight:700, color:'var(--text)' }}>
{CITIES[activeCity].name}
</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)', marginLeft:8 }}>
{CITIES[activeCity].sub}
</span>
</div>
<span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-faint)' }}>
{CITIES[activeCity].lat.toFixed(2)}°N, {CITIES[activeCity].lon.toFixed(2)}°E
</span>
<div style={{ flex:1 }}/>
<span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-muted)' }}>
Click camera pins on the map to open live feeds
</span>
</div>
{/* Windy webcam embed */}
<div style={{ flex:1, minHeight:480, position:'relative' }}>
<iframe
key={activeCity}
src={windyUrl(CITIES[activeCity].lat, CITIES[activeCity].lon)}
style={{ width:'100%', height:'100%', minHeight:480, border:'none', display:'block' }}
title={`${CITIES[activeCity].name} webcams`}
loading="lazy"
allow="geolocation"
/>
</div>
{/* Quick city grid for fast switching */}
<div style={{
display:'grid', gridTemplateColumns:'repeat(6,1fr)', gap:0,
borderTop:'1px solid var(--border)', flexShrink:0,
}}>
{CITIES.map((c, i) => {
const active = activeCity === i
return (
<button key={i} onClick={() => setActiveCity(i)} style={{
padding:'8px 6px', cursor:'pointer', outline:'none',
borderRight: i < 5 ? '1px solid var(--border)' : 'none',
background: active ? 'color-mix(in srgb, var(--cyan) 8%, transparent)' : 'var(--bg2)',
border:'none', borderRight: i < 5 ? '1px solid var(--border)' : 'none',
textAlign:'center', transition:'var(--t-fast)',
}}
onMouseEnter={e => !active && (e.currentTarget.style.background='var(--bg3)')}
onMouseLeave={e => !active && (e.currentTarget.style.background='var(--bg2)')}
>
<div style={{ fontFamily:'var(--font-mono)', fontSize:9, fontWeight:700, color: active ? 'var(--cyan)' : 'var(--text-secondary)' }}>
{c.name}
</div>
<div style={{ fontFamily:'var(--font-mono)', fontSize:7.5, color:'var(--text-muted)', marginTop:2 }}>
{c.sub.split(' · ')[0]}
</div>
</button>
)
})}
</div>
{/* Attribution footer */}
<div style={{
padding:'4px 14px', borderTop:'1px solid var(--border)',
background:'var(--bg2)', display:'flex', gap:12, flexShrink:0,
}}>
<span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-ghost)' }}>
Crypto: CoinGecko free API · 60s refresh · no API key required
</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-ghost)' }}>
Webcams: Windy.com · click camera pins for live streams
</span>
</div>
</div>
)
}
+152
View File
@@ -0,0 +1,152 @@
import { useState } from 'react'
import _Plot from 'react-plotly.js'
const Plot = _Plot?.default ?? _Plot
import { usePortfolioStore } from '../store/portfolio'
import { computeMonteCarlo } from '../api/client'
export default function MonteCarloChart() {
const { legs, S, sigma, r_d, r_f, T, pair } = usePortfolioStore()
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [nPaths, setNPaths] = useState(1000)
const [view, setView] = useState('paths')
const run = async () => {
if (!legs.length) return
setLoading(true)
try {
const data = await computeMonteCarlo({
options: legs.map(({ type, K, T, qty }) => ({ type, K, T, qty })),
S0: S, sigma, r_d, r_f, T, n_paths: nPaths, n_steps: 100,
})
setResult(data)
} catch (e) {
alert('Simulation failed: ' + (e?.response?.data?.detail || e.message))
} finally { setLoading(false) }
}
const cyanBase = [0,212,255]
const pathPlotData = result ? result.sample_paths.slice(0, 60).map((path, i) => ({
type:'scatter', mode:'lines',
x: result.time_axis, y: path,
line:{ width:0.7, color:`rgba(${cyanBase.join(',')},${0.06 + (i%10)*0.02})` },
showlegend:false, hoverinfo:'skip',
})) : []
const histData = result ? [{
type:'histogram', x: result.pnl, nbinsx:60,
marker:{ color:'rgba(0,212,255,0.5)', line:{ color:'var(--cyan)', width:0.5 } },
name:'P&L Distribution',
}] : []
const bgLayout = {
paper_bgcolor:'transparent', plot_bgcolor:'#080d18',
font:{ color:'#94a3b8', size:11 },
margin:{ l:52, r:16, t:32, b:50 },
xaxis:{ gridcolor:'#1e293b', color:'#475569', zeroline:false },
yaxis:{ gridcolor:'#1e293b', color:'#475569' },
showlegend:false,
}
return (
<div style={{ padding:16 }}>
{/* Controls */}
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:14 }}>
<div className="section-title">Monte Carlo GBM (Forex)</div>
<div style={{ flex:1 }}/>
<span style={{ fontSize:10, color:'var(--text-muted)' }}>Paths:</span>
<select className="input-field" style={{ width:80, fontSize:11, padding:'4px 8px' }}
value={nPaths} onChange={e => setNPaths(+e.target.value)}>
{[500, 1000, 2000, 5000].map(n => <option key={n} value={n}>{n.toLocaleString()}</option>)}
</select>
<button className="btn-primary" onClick={run} disabled={loading}
style={{ padding:'6px 18px', fontSize:12 }}>
{loading ? '⟳ Simulating…' : '▶ Run'}
</button>
</div>
{/* Model info */}
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}>
{[
{ label:'Model', value:'GBM — Geometric Brownian Motion' },
{ label:'Drift', value:`r_d r_f = ${((r_d-r_f)*100).toFixed(2)}%` },
{ label:'Pair', value:pair },
{ label:'σ', value:`${(sigma*100).toFixed(1)}%` },
].map(({ label, value }) => (
<div key={label} className="glass" style={{ padding:'4px 10px', borderRadius:6, fontSize:9 }}>
<span style={{ color:'var(--text-muted)' }}>{label}: </span>
<span className="font-mono" style={{ color:'var(--cyan)' }}>{value}</span>
</div>
))}
</div>
{loading && (
<div style={{ display:'flex', alignItems:'center', gap:8, padding:16 }}>
<div className="live-dot"/>
<span style={{ fontSize:12, color:'var(--text-muted)' }}>
Running {nPaths.toLocaleString()} paths
</span>
</div>
)}
{result && (
<>
{/* Stats strip */}
<div style={{ display:'grid', gridTemplateColumns:'repeat(5,1fr)', gap:8, marginBottom:12 }}>
{[
{ label:'Mean P&L', value: result.pnl_mean, pos: result.pnl_mean >= 0 },
{ label:'Std Dev', value: result.pnl_std, pos: true },
{ label:'5th Pct', value: result.pnl_5pct, pos: result.pnl_5pct >= 0 },
{ label:'95th Pct', value: result.pnl_95pct, pos: result.pnl_95pct >= 0 },
{ label:'P(Profit)', value: `${(result.prob_profit*100).toFixed(1)}%`, pos: result.prob_profit>=0.5 },
].map(({ label, value, pos }) => (
<div key={label} className="glass" style={{ borderRadius:8, padding:'8px 10px', textAlign:'center' }}>
<div style={{ fontSize:9, color:'var(--text-muted)', marginBottom:3 }}>{label}</div>
<div className="font-mono" style={{ fontSize:14, fontWeight:700,
color: pos ? 'var(--green)' : 'var(--red)' }}>
{typeof value === 'string' ? value : (value>=0?'+':'')+value.toFixed(4)}
</div>
</div>
))}
</div>
{/* View toggle */}
<div style={{ display:'flex', gap:6, marginBottom:10 }}>
{[['paths','📈 Price Paths'],['histogram','📊 P&L Distribution']].map(([v,lbl]) => (
<button key={v} className="btn-tab" onClick={() => setView(v)}
style={{ background: view===v?'var(--cyan)':'var(--bg3)',
color: view===v?'var(--bg)':'var(--text-muted)' }}>{lbl}</button>
))}
</div>
<div className="glass" style={{ borderRadius:8, padding:8 }}>
{view === 'paths' ? (
<Plot data={pathPlotData}
layout={{ ...bgLayout,
title:{ text:'Simulated FX Rate Paths (GBM)', font:{ color:'var(--cyan)', size:12 } },
xaxis:{ ...bgLayout.xaxis, title:'Time (years)' },
yaxis:{ ...bgLayout.yaxis, title:`${pair} Rate` } }}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:380 }} />
) : (
<Plot data={histData}
layout={{ ...bgLayout,
title:{ text:'P&L Distribution at Expiry', font:{ color:'var(--cyan)', size:12 } },
xaxis:{ ...bgLayout.xaxis, title:'P&L' },
yaxis:{ ...bgLayout.yaxis, title:'Frequency' } }}
config={{ responsive:true, displayModeBar:false }}
style={{ width:'100%', height:380 }} />
)}
</div>
</>
)}
{!result && !loading && (
<div className="glass" style={{ display:'flex', alignItems:'center', justifyContent:'center',
height:200, borderRadius:8, color:'var(--text-muted)', fontSize:13 }}>
Click Run to simulate GBM price paths for {pair}
</div>
)}
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
import { usePortfolioStore, PAIR_META } from '../store/portfolio'
import { fetchForexRates } from '../api/client'
import NewsBanner from './NewsBanner'
import { NexusIcon } from './NexusLogo'
const TickerItem = ({ item }) => {
const up = item.change_pct >= 0
return (
<div className="ticker-item">
<span className="ticker-pair">{item.pair}</span>
<span className="ticker-rate font-mono">{item.spot?.toFixed(item.pair==='USDJPY'||item.pair.includes('JPY')?3:5)}</span>
<span className="font-mono" style={{ fontSize:10, color: up?'var(--green)':'var(--red)', fontWeight:600 }}>
{up?'▲':'▼'}{Math.abs(item.change_pct).toFixed(2)}%
</span>
</div>
)
}
export default function Navbar() {
const { pair, setPair, setS, toShareURL } = usePortfolioStore()
const [rates, setRates] = useState([])
const [time, setTime] = useState(new Date())
const [loading, setLoading] = useState(false)
const loadRates = async () => {
setLoading(true)
try {
const data = await fetchForexRates()
setRates(data)
// Auto-update spot for current pair
const current = data.find(d => d.pair === pair)
if (current?.spot) setS(current.spot)
} catch {}
finally { setLoading(false) }
}
useEffect(() => { loadRates() }, [])
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000)
return () => clearInterval(id)
}, [])
// Auto-refresh every 5 minutes
useEffect(() => {
const id = setInterval(loadRates, 5 * 60 * 1000)
return () => clearInterval(id)
}, [pair])
const doubled = [...rates, ...rates] // seamless loop
const [toast, setToast] = useState(null)
const handleShare = () => {
navigator.clipboard.writeText(toShareURL()).then(() => {
setToast('Portfolio URL copied!')
setTimeout(() => setToast(null), 3000)
})
}
return (
<nav style={{ background:'rgba(2,8,23,0.95)', borderBottom:'1px solid var(--border)',
backdropFilter:'blur(20px)', position:'sticky', top:0, zIndex:100 }}>
{/* Toast */}
{toast && (
<div style={{
position:'fixed', bottom:24, right:24, zIndex:999,
background:'rgba(0,255,136,0.12)', border:'1px solid rgba(0,255,136,0.35)',
borderRadius:8, padding:'10px 16px',
fontSize:12, color:'#00ff88', fontWeight:600,
backdropFilter:'blur(12px)',
boxShadow:'0 4px 20px rgba(0,0,0,0.5)',
display:'flex', alignItems:'center', gap:8,
animation:'lp-reveal 0.25s ease',
}}>
<span style={{fontSize:14}}></span> {toast}
</div>
)}
{/* Top bar */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'0 20px', height:48 }}>
{/* Logo */}
<div style={{ display:'flex', alignItems:'center', gap:12 }}>
<NexusIcon size={38} />
<div>
<div style={{
fontFamily:'var(--font-mono)', fontSize:15, fontWeight:800,
letterSpacing:'0.2em', color:'var(--cyan)', lineHeight:1,
textShadow:'0 0 16px rgba(0,200,240,0.55)',
animation:'nx-flicker 9s ease-in-out infinite',
}}>
NEXUS
</div>
<div style={{
fontFamily:'var(--font-mono)', fontSize:7.5, fontWeight:500,
letterSpacing:'0.22em', color:'var(--text-muted)', lineHeight:1.5,
textTransform:'uppercase',
}}>
TERMINAL · FX OPTIONS
</div>
</div>
<div style={{ width:1, height:24, background:'var(--border)', margin:'0 8px' }}/>
{/* Pair selector in nav */}
<select
className="input-field"
style={{ width:100, fontSize:12, fontWeight:600, padding:'4px 8px' }}
value={pair}
onChange={e => {
setPair(e.target.value)
const r = rates.find(d => d.pair === e.target.value)
if (r?.spot) setS(r.spot)
}}>
{Object.keys(PAIR_META).map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
{/* Right controls */}
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
<div className="live-dot"/>
<span className="font-mono" style={{ fontSize:10, color:'var(--text-muted)' }}>LIVE</span>
</div>
<button className="btn-ghost" onClick={loadRates} disabled={loading}
style={{ fontSize:10 }}>{loading ? '⟳' : '↻'} Refresh</button>
<button className="btn-ghost" onClick={handleShare} style={{ fontSize:10 }}>
🔗 Share
</button>
<div style={{ width:1, height:20, background:'var(--border)' }}/>
<div className="font-mono" style={{ fontSize:11, color:'var(--text-muted)', minWidth:56 }}>
{time.toLocaleTimeString('en-GB', { hour12:false })}
</div>
<div style={{ fontSize:9, color:'var(--text-muted)' }}>UTC</div>
</div>
</div>
{/* Rate ticker tape */}
{rates.length > 0 && (
<div className="ticker-wrap" style={{ height:26 }}>
<div className="ticker-inner">
{doubled.map((item, i) => <TickerItem key={i} item={item} />)}
</div>
</div>
)}
{/* Economic events strip */}
<NewsBanner />
</nav>
)
}
+108
View File
@@ -0,0 +1,108 @@
import { useState, useEffect } from 'react'
import { fetchCalendar } from '../api/client'
const FLAGS = {
USD:'🇺🇸', EUR:'🇪🇺', GBP:'🇬🇧', JPY:'🇯🇵',
AUD:'🇦🇺', CAD:'🇨🇦', CHF:'🇨🇭', NZD:'🇳🇿',
}
const IMP_COLOR = { High:'#ff3d5a', Medium:'#f59e0b', Low:'#475569' }
function fmtUtcTime(dtStr) {
if (!dtStr) return '--:--'
try {
return new Date(dtStr).toLocaleTimeString('en-GB',
{ hour:'2-digit', minute:'2-digit', timeZone:'UTC' })
} catch { return '--:--' }
}
function todayStr() {
return new Date().toISOString().slice(0, 10)
}
function EventChip({ ev }) {
const color = IMP_COLOR[ev.impact] || '#475569'
const flag = FLAGS[ev.country] || '🏳️'
const actual = ev.actual?.trim()
const isPast = ev.datetime_utc ? new Date(ev.datetime_utc) < Date.now() : false
return (
<div style={{
display:'inline-flex', alignItems:'center', gap:5,
padding:'0 14px', height:22,
borderRight:'1px solid rgba(255,255,255,0.03)',
flexShrink:0, opacity: isPast && !actual ? 0.45 : 1,
}}>
<span style={{ fontSize:6, color, lineHeight:1 }}></span>
<span className="font-mono" style={{ fontSize:9, color:'#334155' }}>
{fmtUtcTime(ev.datetime_utc)}
</span>
<span style={{ fontSize:10 }}>{flag}</span>
<span style={{ fontSize:9, fontWeight:700, color, letterSpacing:'0.04em' }}>{ev.country}</span>
<span style={{ fontSize:9, color:'#64748b', whiteSpace:'nowrap', maxWidth:170,
overflow:'hidden', textOverflow:'ellipsis' }}>{ev.title}</span>
{actual ? (
<span className="font-mono" style={{ fontSize:8, color:'#00ff88', fontWeight:700 }}>
{actual}
</span>
) : ev.forecast?.trim() ? (
<span className="font-mono" style={{ fontSize:8, color:'#334155' }}>
Exp <span style={{ color:'#475569' }}>{ev.forecast}</span>
</span>
) : null}
</div>
)
}
export default function NewsBanner() {
const [events, setEvents] = useState([])
useEffect(() => {
fetchCalendar()
.then(d => {
const today = todayStr()
// Only show today's HIGH + MEDIUM events in the strip
const filtered = (d.events || []).filter(ev =>
(ev.impact === 'High' || ev.impact === 'Medium') &&
ev.datetime_utc?.startsWith(today)
)
// If today has no events, fall back to next available day's high/medium
if (filtered.length === 0) {
const upcoming = (d.events || []).filter(ev =>
(ev.impact === 'High' || ev.impact === 'Medium') &&
(ev.datetime_utc || '') > new Date().toISOString()
).slice(0, 12)
setEvents(upcoming)
} else {
setEvents(filtered)
}
})
.catch(() => {})
}, [])
if (!events.length) return null
const doubled = [...events, ...events]
return (
<div className="news-wrap" style={{ height:22 }}>
{/* Label */}
<div style={{
position:'absolute', left:0, top:0, height:'100%', zIndex:3,
display:'flex', alignItems:'center', gap:6, padding:'0 10px',
background:'linear-gradient(90deg, rgba(2,4,12,1) 65%, transparent)',
pointerEvents:'none',
}}>
<span style={{ fontSize:7, color:'#ff3d5a', fontWeight:800,
textTransform:'uppercase', letterSpacing:'0.14em', whiteSpace:'nowrap' }}>
📅 Today
</span>
<div style={{ width:1, height:10, background:'rgba(255,61,90,0.2)' }}/>
</div>
<div className="news-inner" style={{ paddingLeft:68 }}>
{doubled.map((ev, i) => <EventChip key={i} ev={ev} />)}
</div>
</div>
)
}
+183
View File
@@ -0,0 +1,183 @@
/* NEXUS — animated orbital logo. Three rings, three colors, three speeds. */
const LOGO_CSS = `
@keyframes nx-o1 { from{transform:rotate(0deg)} to{transform:rotate(360deg)} }
@keyframes nx-o2 { from{transform:rotate(60deg)} to{transform:rotate(420deg)} }
@keyframes nx-o3 { from{transform:rotate(-60deg)} to{transform:rotate(-420deg)} }
@keyframes nx-core-pulse { 0%,100%{opacity:0.9} 50%{opacity:0.45} }
@keyframes nx-outer-spin { from{transform:rotate(0deg)} to{transform:rotate(-360deg)} }
@keyframes nx-flicker {
0%,89%,91%,93%,100%{ opacity:1 }
90%{ opacity:0.6 }
92%{ opacity:0.85 }
}
@keyframes nx-scan {
0% { background-position: 0 -200%; }
100% { background-position: 0 400%; }
}
@keyframes nx-letter {
0%,100%{ text-shadow:0 0 8px var(--cyan),0 0 20px var(--cyan) }
50% { text-shadow:0 0 2px var(--cyan),0 0 6px var(--cyan) }
}
`
let cssInjected = false
function injectCSS() {
if (cssInjected) return
const el = document.createElement('style')
el.textContent = LOGO_CSS
document.head.appendChild(el)
cssInjected = true
}
/* ─── Small icon (navbar) ─────────────────────────── */
export function NexusIcon({ size = 36 }) {
injectCSS()
return (
<svg viewBox="0 0 44 44" width={size} height={size}
style={{ overflow:'visible', display:'block', flexShrink:0 }}>
<defs>
<filter id="nx-g1" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="1.2" in="SourceGraphic" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="nx-g2" x="-150%" y="-150%" width="400%" height="400%">
<feGaussianBlur stdDeviation="3.5" in="SourceGraphic" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Orbit 1 — cyan — 7s CW */}
<g style={{ transformOrigin:'22px 22px', animation:'nx-o1 7s linear infinite' }}>
<ellipse cx="22" cy="22" rx="20" ry="5.5"
fill="none" stroke="#00c8f0" strokeWidth="0.75" opacity="0.55"/>
<circle cx="42" cy="22" r="2.4" fill="#00c8f0" filter="url(#nx-g1)"/>
</g>
{/* Orbit 2 — purple — 11s CW offset */}
<g style={{ transformOrigin:'22px 22px', animation:'nx-o2 11s linear infinite' }}>
<ellipse cx="22" cy="22" rx="20" ry="5.5"
fill="none" stroke="#8b5cf6" strokeWidth="0.75" opacity="0.5"/>
<circle cx="42" cy="22" r="2" fill="#8b5cf6" filter="url(#nx-g1)"/>
</g>
{/* Orbit 3 — green — 5s CCW */}
<g style={{ transformOrigin:'22px 22px', animation:'nx-o3 5s linear infinite' }}>
<ellipse cx="22" cy="22" rx="20" ry="5.5"
fill="none" stroke="#3ddc84" strokeWidth="0.75" opacity="0.4"/>
<circle cx="42" cy="22" r="1.8" fill="#3ddc84" filter="url(#nx-g1)"/>
</g>
{/* Core glow halo */}
<circle cx="22" cy="22" r="6" fill="rgba(0,200,240,0.12)"
filter="url(#nx-g2)"
style={{ animation:'nx-core-pulse 1.8s ease-in-out infinite' }}/>
{/* Core */}
<circle cx="22" cy="22" r="3.2" fill="#00c8f0" filter="url(#nx-g1)" opacity="0.9"/>
<circle cx="22" cy="22" r="1.3" fill="white"/>
</svg>
)
}
/* ─── Large logo (landing page) ───────────────────── */
export function NexusLogoLarge({ size = 90 }) {
injectCSS()
const s = size / 44
return (
<svg viewBox="0 0 44 44" width={size} height={size}
style={{ overflow:'visible', display:'block', flexShrink:0 }}>
<defs>
<filter id="nxl-g1" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="0.8" in="SourceGraphic" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="nxl-g2" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="4" in="SourceGraphic" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="nxl-g3" x="-300%" y="-300%" width="700%" height="700%">
<feGaussianBlur stdDeviation="6" in="SourceGraphic" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
{/* Outer decorative tick ring */}
<g style={{ transformOrigin:'22px 22px', animation:'nx-outer-spin 40s linear infinite' }}>
{Array.from({length:24}, (_,i) => {
const a = (i * 15) * Math.PI / 180
const r1 = 21, r2 = i%4===0 ? 21.8 : i%2===0 ? 21.5 : 21.2
return (
<line key={i}
x1={22 + r1*Math.cos(a)} y1={22 + r1*Math.sin(a)}
x2={22 + r2*Math.cos(a)} y2={22 + r2*Math.sin(a)}
stroke="#00c8f0" strokeWidth={i%4===0?0.8:0.4} opacity={i%4===0?0.7:0.35}
/>
)
})}
<circle cx="22" cy="22" r="21.2" fill="none" stroke="#00c8f0" strokeWidth="0.3" opacity="0.25"/>
</g>
{/* Orbit 1 — cyan — 7s */}
<g style={{ transformOrigin:'22px 22px', animation:'nx-o1 7s linear infinite' }}>
<ellipse cx="22" cy="22" rx="19.5" ry="5.5"
fill="none" stroke="#00c8f0" strokeWidth="0.8" opacity="0.6"/>
<circle cx="41.5" cy="22" r="2.5" fill="#00c8f0" filter="url(#nxl-g1)"/>
<circle cx="41.5" cy="22" r="1" fill="white" opacity="0.8"/>
</g>
{/* Orbit 2 — purple — 11s */}
<g style={{ transformOrigin:'22px 22px', animation:'nx-o2 11s linear infinite' }}>
<ellipse cx="22" cy="22" rx="19.5" ry="5.5"
fill="none" stroke="#8b5cf6" strokeWidth="0.8" opacity="0.55"/>
<circle cx="41.5" cy="22" r="2.2" fill="#8b5cf6" filter="url(#nxl-g1)"/>
<circle cx="41.5" cy="22" r="0.9" fill="white" opacity="0.7"/>
</g>
{/* Orbit 3 — green — 5s CCW */}
<g style={{ transformOrigin:'22px 22px', animation:'nx-o3 5s linear infinite' }}>
<ellipse cx="22" cy="22" rx="19.5" ry="5.5"
fill="none" stroke="#3ddc84" strokeWidth="0.8" opacity="0.45"/>
<circle cx="41.5" cy="22" r="2" fill="#3ddc84" filter="url(#nxl-g1)"/>
<circle cx="41.5" cy="22" r="0.8" fill="white" opacity="0.6"/>
</g>
{/* Core outer glow */}
<circle cx="22" cy="22" r="8" fill="rgba(0,200,240,0.08)" filter="url(#nxl-g3)"
style={{ animation:'nx-core-pulse 2s ease-in-out infinite' }}/>
<circle cx="22" cy="22" r="5.5" fill="rgba(0,200,240,0.18)" filter="url(#nxl-g2)"
style={{ animation:'nx-core-pulse 2s ease-in-out 0.3s infinite' }}/>
{/* Core body */}
<circle cx="22" cy="22" r="3.5" fill="#00c8f0" filter="url(#nxl-g1)" opacity="0.92"/>
<circle cx="22" cy="22" r="1.5" fill="white"/>
</svg>
)
}
/* ─── Full wordmark (icon + NEXUS text) ───────────── */
export function NexusWordmark({ iconSize = 36, textSize = 16 }) {
injectCSS()
return (
<div style={{ display:'flex', alignItems:'center', gap:10 }}>
<NexusIcon size={iconSize} />
<div>
<div style={{
fontFamily:'var(--font-mono)', fontSize:textSize, fontWeight:800,
letterSpacing:'0.18em', color:'var(--cyan)',
textTransform:'uppercase', lineHeight:1,
textShadow:'0 0 20px rgba(0,200,240,0.5)',
style:'animation:nx-flicker 8s ease-in-out infinite',
}}>
NEXUS
</div>
<div style={{
fontFamily:'var(--font-mono)', fontSize:textSize * 0.52,
fontWeight:500, letterSpacing:'0.25em',
color:'var(--text-muted)', textTransform:'uppercase',
lineHeight:1.4, marginTop:1,
}}>
TERMINAL
</div>
</div>
</div>
)
}
@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { usePortfolioStore, PAIR_META } from '../store/portfolio'
import { fetchStrategies } from '../api/client'
const LegRow = ({ leg }) => {
const { updateLeg, removeLeg, S } = usePortfolioStore()
const moneyness = leg.K < S*0.999 ? 'ITM' : leg.K > S*1.001 ? 'OTM' : 'ATM'
const mColor = moneyness==='ITM'?'var(--green)':moneyness==='ATM'?'var(--gold)':'var(--text-muted)'
return (
<tr>
<td style={{ padding:'5px 4px' }}>
<select className="input-field" style={{ width:58, fontSize:10, padding:'3px 4px' }}
value={leg.type} onChange={e=>updateLeg(leg.id,'type',e.target.value)}>
<option value="call">CALL</option>
<option value="put">PUT</option>
</select>
</td>
<td style={{ padding:'5px 4px' }}>
<input type="number" className="input-field" style={{ width:68, fontSize:10, padding:'3px 4px' }}
value={leg.K} step={0.0001} onChange={e=>updateLeg(leg.id,'K',parseFloat(e.target.value))} />
</td>
<td style={{ padding:'5px 4px' }}>
<input type="number" className="input-field" style={{ width:46, fontSize:10, padding:'3px 4px' }}
value={leg.T} step={0.05} min={0.01} onChange={e=>updateLeg(leg.id,'T',parseFloat(e.target.value))} />
</td>
<td style={{ padding:'5px 4px' }}>
<input type="number" className="input-field" style={{ width:40, fontSize:10, padding:'3px 4px' }}
value={leg.qty} step={1} onChange={e=>updateLeg(leg.id,'qty',parseInt(e.target.value))} />
</td>
<td style={{ padding:'5px 4px', textAlign:'center' }}>
<span style={{ fontSize:9.5, fontWeight:700, color:mColor }}>{moneyness}</span>
</td>
<td style={{ padding:'5px 2px' }}>
<button onClick={()=>removeLeg(leg.id)} style={{ background:'transparent', border:'none',
color:'var(--text-muted)', cursor:'pointer', fontSize:12, padding:'0 4px' }}></button>
</td>
</tr>
)
}
const ParamRow = ({ label, value, setter, step=0.001, min=0, max, disabled, suffix='' }) => (
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'5px 0', borderBottom:'1px solid rgba(0,212,255,0.05)' }}>
<span style={{ fontSize:11.5, color:'var(--text-muted)' }}>{label}</span>
<div style={{ display:'flex', alignItems:'center', gap:4 }}>
<input type="number" className="input-field"
style={{ width:72, textAlign:'right', fontSize:11, padding:'3px 6px' }}
value={value} step={step} min={min} max={max} disabled={disabled}
onChange={e => setter && setter(e.target.value)} />
{suffix && <span style={{ fontSize:9, color:'var(--text-muted)', minWidth:12 }}>{suffix}</span>}
</div>
</div>
)
export default function PortfolioBuilder() {
const { legs, addLeg, loadStrategy, S, setS, sigma, setSigma,
T, setT, r_d, setRd, r_f, setRf, pair } = usePortfolioStore()
const [strategies, setStrategies] = useState([])
const [showStrategies, setShowStrategies] = useState(false)
const meta = PAIR_META[pair] || {}
useEffect(() => { fetchStrategies().then(setStrategies).catch(()=>{}) }, [])
return (
<div style={{ display:'flex', flexDirection:'column', height:'100%', overflow:'hidden' }}>
{/* Pair info header */}
<div style={{ padding:'12px 14px', borderBottom:'1px solid var(--border)',
background:'rgba(0,212,255,0.03)' }}>
<div className="label-upper" style={{ marginBottom:4 }}>Spot Rate</div>
<div className="font-mono neon-cyan" style={{ fontSize:22, fontWeight:700 }}>
{S.toFixed(meta.pip === 0.01 ? 3 : 5)}
</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
{pair.slice(0,3)} / {pair.slice(3)} · pip={meta.pip}
</div>
</div>
<div style={{ flex:1, overflowY:'auto', padding:'12px 14px' }}>
{/* Market params */}
<div className="section-header">
<span className="section-title">Market Params</span>
</div>
<div style={{ marginBottom:14 }}>
<ParamRow label="Spot (S)" value={S} setter={setS} step={meta.pip||0.0001} />
<ParamRow label="Vol σ (ann.)" value={sigma} setter={setSigma} step={0.001} min={0.001} max={3} />
<ParamRow label="Expiry T (yr)"value={T} setter={setT} step={0.05} min={0.01} />
<ParamRow label="Rate r_d" value={r_d} setter={setRd} step={0.001} suffix="%" />
<ParamRow label="Rate r_f" value={r_f} setter={setRf} step={0.001} suffix="%" />
</div>
{/* Option legs */}
<div className="section-header">
<span className="section-title" style={{ flex:1 }}>Option Legs</span>
<button className="btn-primary" onClick={addLeg} style={{ padding:'4px 10px', fontSize:10 }}>
+ Add
</button>
</div>
<div style={{ overflowX:'auto', marginBottom:14 }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:10 }}>
<thead>
<tr style={{ color:'var(--text-muted)' }}>
<th style={{ textAlign:'left', paddingBottom:5, fontSize:10, color:'var(--text-muted)', letterSpacing:'0.05em' }}>Type</th>
<th style={{ textAlign:'left', paddingBottom:5, fontSize:10, color:'var(--text-muted)', letterSpacing:'0.05em' }}>Strike</th>
<th style={{ textAlign:'left', paddingBottom:5, fontSize:10, color:'var(--text-muted)', letterSpacing:'0.05em' }}>T (yr)</th>
<th style={{ textAlign:'left', paddingBottom:5, fontSize:10, color:'var(--text-muted)', letterSpacing:'0.05em' }}>Qty</th>
<th style={{ paddingBottom:5, fontSize:10 }}></th>
<th></th>
</tr>
</thead>
<tbody>
{legs.map(leg => <LegRow key={leg.id} leg={leg} />)}
{legs.length === 0 && (
<tr>
<td colSpan={6} style={{ padding:'16px 0', textAlign:'center',
color:'var(--text-muted)', fontSize:11, fontStyle:'italic' }}>
No legs yet click + Add or pick a strategy below
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Strategy Library */}
<div className="section-header" style={{ cursor:'pointer' }}
onClick={() => setShowStrategies(!showStrategies)}>
<span className="section-title" style={{ flex:1 }}>Strategy Library</span>
<span style={{ color:'var(--text-muted)', fontSize:10 }}>{showStrategies?'▲':'▼'}</span>
</div>
{showStrategies && (
<div style={{ display:'flex', flexDirection:'column', gap:4 }}>
{strategies.map(s => (
<button key={s.name} onClick={() => { loadStrategy(s); setShowStrategies(false) }}
className="glass-hover"
style={{ textAlign:'left', padding:'8px 10px', borderRadius:6, border:'1px solid var(--border)',
background:'var(--bg3)', cursor:'pointer', transition:'all 0.15s' }}>
<div style={{ fontSize:11, fontWeight:600, color:'var(--cyan)' }}>{s.name}</div>
<div style={{ fontSize:10.5, color:'var(--text-muted)', marginTop:3, lineHeight:1.45 }}>
{s.description.slice(0,60)}{s.description.length > 60 ? '…' : ''}
</div>
</button>
))}
</div>
)}
</div>
</div>
)
}
+280
View File
@@ -0,0 +1,280 @@
import { useState, useEffect } from 'react'
import { usePortfolioStore } from '../store/portfolio'
import { fetchSignals } from '../api/client'
const SIGNAL_COLOR = {
BULLISH:'var(--green)', BEARISH:'var(--red)', NEUTRAL:'var(--amber)',
BUY:'var(--green)', SELL:'var(--red)',
'BUY BASE':'var(--green)', 'SELL BASE':'var(--red)',
'MEAN-REVERTING':'var(--purple)', TRENDING:'var(--cyan)', 'RANDOM WALK':'var(--text-muted)',
HIGH:'var(--red)', NORMAL:'var(--cyan)', LOW:'var(--green)', MIXED:'var(--amber)',
}
const color = (v) => SIGNAL_COLOR[v] || 'var(--text-secondary)'
/* Terminal signal panel — worldmonitor style */
const SPanel = ({ title, accentColor, lbClass, badge, badgeClass, children }) => (
<div className="signal-panel" style={{ borderLeft:`3px solid ${accentColor}` }}>
<div className="signal-panel-header">
<span className="signal-panel-title" style={{ color: accentColor }}>{title}</span>
{badge && (
<span className={`tbadge ${badgeClass || 'tbadge-cyan'}`} style={{ animation:'none' }}>
{badge}
</span>
)}
</div>
{children}
</div>
)
const Row = ({ label, value, desc, method, bar, barColor, barMax = 100 }) => (
<div style={{ padding:'10px 14px', borderBottom:'1px solid var(--border)',
display:'flex', flexDirection:'column', gap:5 }}>
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', gap:8 }}>
<div style={{ flex:1, minWidth:0 }}>
<span style={{ fontSize:12, color:'var(--text-dim)', fontWeight:500 }}>{label}</span>
{desc && <div style={{ fontSize:10, color:'var(--text-muted)', marginTop:1 }}>{desc}</div>}
</div>
<span className="font-mono" style={{ fontSize:13, fontWeight:700, color: color(value), flexShrink:0 }}>
{value}
</span>
</div>
{bar !== undefined && (
<div style={{ display:'flex', alignItems:'center', gap:8 }}>
<div className="conf-bar-bg">
<div className="conf-bar-fill" style={{ width:`${Math.min(100,(bar/barMax)*100)}%`,
background: barColor || 'linear-gradient(90deg, var(--cyan), var(--purple))' }}/>
</div>
<span className="font-mono" style={{ fontSize:10, color:'var(--text-muted)', minWidth:32 }}>
{typeof bar === 'number' ? bar.toFixed(2) : bar}
</span>
</div>
)}
{method && (
<span style={{ fontSize:10, color:'var(--text-muted)', fontStyle:'italic' }}>
{method}
</span>
)}
</div>
)
export default function QuantSignals() {
const { pair, r_d, r_f } = usePortfolioStore()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const load = async () => {
setLoading(true); setError(null)
try { setData(await fetchSignals(pair)) }
catch (e) { setError(e?.response?.data?.detail || 'Signal computation failed.') }
finally { setLoading(false) }
}
useEffect(() => { load() }, [pair])
if (loading) return (
<div style={{ padding:16 }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:16 }}>
<div className="live-dot"/>
<span style={{ color:'var(--text-muted)', fontSize:12 }}>
Fetching 90 days of data & computing signals
</span>
</div>
{[...Array(6)].map((_, i) => (
<div key={i} className="skeleton" style={{ height:52, marginBottom:4, borderRadius:4 }}/>
))}
</div>
)
if (error) return (
<div style={{ padding:16 }}>
<div style={{ padding:12, borderRadius:6, background:'var(--red-dim)',
border:'1px solid var(--red)', color:'var(--red)', fontSize:12, marginBottom:12 }}>{error}</div>
<button className="btn-primary" onClick={load}>Retry</button>
</div>
)
if (!data) return null
const { volatility: vol, risk, hurst, mean_reversion: mr,
rsi, macd, bollinger: bb, momentum, carry } = data
return (
<div style={{ padding:16 }}>
{/* Terminal header */}
<div className="term-header" style={{ margin:'-16px -16px 14px', padding:'8px 16px' }}>
<span className="live-dot-blink"/>
<span className="term-header-cyan">AI QUANT SIGNALS</span>
<span style={{ opacity:0.4 }}>·</span>
<span className="term-header-title">{pair}</span>
<span style={{ opacity:0.4 }}>·</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9.5, color:'var(--text-muted)' }}>
{data.n_observations} obs · gs-quant · Garman-Kohlhagen
</span>
<div style={{ flex:1 }}/>
<button className="btn-ghost" onClick={load} style={{ fontSize:10, padding:'3px 9px' }}> Refresh</button>
</div>
{/* Composite signal — bloomberg bar style */}
<div style={{
display:'flex', alignItems:'center', justifyContent:'space-between',
padding:'12px 16px', marginBottom:12,
background:'var(--surface)', border:'1px solid var(--border)',
borderLeft:`4px solid ${data.composite==='BULLISH'?'var(--green)':data.composite==='BEARISH'?'var(--red)':'var(--amber)'}`,
borderRadius:'var(--r-md)',
}}>
<div>
<div style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)',
textTransform:'uppercase', letterSpacing:'0.1em', marginBottom:6 }}>Composite Signal</div>
<div className={`signal-badge ${data.composite.toLowerCase()}`}>
{data.composite === 'BULLISH' ? '▲' : data.composite === 'BEARISH' ? '▼' : '◆'}{' '}
{data.composite}
</div>
</div>
<div style={{ textAlign:'right' }}>
<div style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)',
textTransform:'uppercase', letterSpacing:'0.1em', marginBottom:4 }}>Confidence</div>
<div style={{ fontFamily:'var(--font-mono)', fontSize:32, fontWeight:800,
color:'var(--cyan)', lineHeight:1, fontVariantNumeric:'tabular-nums' }}>
{data.confidence}%
</div>
<div style={{ width:130, marginTop:6 }}>
<div className="conf-bar-bg">
<div className="conf-bar-fill" style={{ width:`${data.confidence}%` }}/>
</div>
</div>
</div>
</div>
{/* Signal grid — worldmonitor terminal panels */}
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:8 }}>
<SPanel title="Volatility" accentColor="var(--cyan)"
badge={vol.regime}
badgeClass={vol.regime==='HIGH'?'tbadge-high':vol.regime==='LOW'?'tbadge-green':'tbadge-medium'}>
<Row label="20-day HV (GS)" value={`${vol.hv_20_pct}%`} bar={vol.hv_20_pct} barMax={50} />
<Row label="60-day HV (GS)" value={`${vol.hv_60_pct}%`} bar={vol.hv_60_pct} barMax={50} />
<Row label="EWMA Forecast" value={`${vol.ewma_forecast_pct}%`}
bar={vol.ewma_forecast_pct} barMax={50}
barColor="linear-gradient(90deg,var(--purple),var(--cyan))" />
<Row label="Max Drawdown" value={`${vol.max_drawdown_pct}%`} method={vol.method}
bar={Math.abs(vol.max_drawdown_pct)} barMax={5}
barColor="linear-gradient(90deg,var(--red),#c00)" />
</SPanel>
<SPanel title="Risk — 1-Day VaR" accentColor="var(--red)">
<Row label="VaR 95%" value={`${risk.var_95_pct}%`} desc="Max loss · 95% conf."
bar={risk.var_95_pct} barMax={3}
barColor="linear-gradient(90deg,var(--amber),var(--red))" />
<Row label="VaR 99%" value={`${risk.var_99_pct}%`} desc="Max loss · 99% conf."
bar={risk.var_99_pct} barMax={3}
barColor="linear-gradient(90deg,var(--red),#c00)" />
<Row label="CVaR 95%" value={`${risk.cvar_95_pct}%`} desc="Expected shortfall"
method={risk.method}
bar={risk.cvar_95_pct} barMax={3}
barColor="linear-gradient(90deg,#c00,var(--purple))" />
</SPanel>
<SPanel title="Hurst Exponent" accentColor="var(--purple)">
<Row label="H value" value={hurst.exponent} desc="<0.45 = MR · >0.55 = trend"
bar={hurst.exponent} barMax={1}
barColor={hurst.exponent < 0.45 ? 'linear-gradient(90deg,var(--purple),#a78bfa)' :
hurst.exponent > 0.55 ? 'linear-gradient(90deg,var(--cyan),var(--green))' :
'linear-gradient(90deg,var(--amber),var(--text-muted))'} />
<Row label="Regime" value={hurst.regime} />
<Row label="Action" value={hurst.action} method={hurst.method} />
</SPanel>
<SPanel title="Mean Reversion (OU)" accentColor="var(--green)"
badge={mr.signal}
badgeClass={mr.signal==='BUY'?'tbadge-green':mr.signal==='SELL'?'tbadge-high':'tbadge-medium'}>
<Row label="κ Speed" value={mr.kappa} desc="Mean-reversion speed (ann.)" />
<Row label="θ Mean" value={mr.theta} desc="Long-run equilibrium" />
<Row label="Half-life" value={`${mr.half_life_days}d`} desc="Days to revert 50%" />
<Row label="Z-score" value={mr.zscore}
bar={Math.min(Math.abs(mr.zscore), 4)} barMax={4}
barColor={mr.zscore > 0 ? 'linear-gradient(90deg,var(--amber),var(--red))' :
'linear-gradient(90deg,var(--amber),var(--green))'}
method={mr.method} />
</SPanel>
<SPanel title="Momentum" accentColor="var(--cyan)"
badge={momentum.signal}
badgeClass={momentum.signal==='BULLISH'?'tbadge-green':momentum.signal==='BEARISH'?'tbadge-high':'tbadge-medium'}>
<Row label="5d Return" value={`${momentum.return_5d_pct>0?'+':''}${momentum.return_5d_pct}%`}
bar={Math.abs(momentum.return_5d_pct)} barMax={3}
barColor={momentum.return_5d_pct>=0?'linear-gradient(90deg,var(--green),var(--cyan))':
'linear-gradient(90deg,var(--red),#c00)'} />
<Row label="20d Return" value={`${momentum.return_20d_pct>0?'+':''}${momentum.return_20d_pct}%`}
bar={Math.abs(momentum.return_20d_pct)} barMax={5}
barColor={momentum.return_20d_pct>=0?'linear-gradient(90deg,var(--green),var(--cyan))':
'linear-gradient(90deg,var(--red),#c00)'}
method={momentum.method} />
</SPanel>
<SPanel title="Carry Trade" accentColor="var(--amber)"
badge={carry.signal}
badgeClass={carry.signal==='BUY BASE'?'tbadge-green':carry.signal==='SELL BASE'?'tbadge-high':'tbadge-medium'}>
<Row label="r_d (domestic)" value={`${carry.r_d}%`} />
<Row label="r_f (foreign)" value={`${carry.r_f}%`} />
<Row label="Differential" value={`${carry.differential_pct>0?'+':''}${carry.differential_pct}%`}
bar={Math.abs(carry.differential_pct)} barMax={5}
barColor="linear-gradient(90deg,var(--amber),var(--cyan))"
method={carry.method} />
</SPanel>
{rsi && (
<SPanel title="RSI · gs-quant" accentColor="var(--cyan)"
badge={rsi.signal}
badgeClass={rsi.bias==='BULLISH'?'tbadge-green':rsi.bias==='BEARISH'?'tbadge-high':'tbadge-medium'}>
<Row label="RSI(14)" value={rsi.value}
bar={rsi.value} barMax={100}
barColor={rsi.value>70?'linear-gradient(90deg,var(--red),#c00)':
rsi.value<30?'linear-gradient(90deg,var(--green),var(--cyan))':
'linear-gradient(90deg,var(--cyan),var(--purple))'} />
<Row label="Bias" value={rsi.bias} method={rsi.method} />
</SPanel>
)}
{macd && (
<SPanel title="MACD · gs-quant" accentColor="var(--purple)"
badge={macd.signal}
badgeClass={macd.signal==='BULLISH'?'tbadge-green':'tbadge-high'}>
<Row label="MACD" value={macd.value}
bar={Math.min(Math.abs(macd.value)*10000,100)} barMax={100}
barColor={macd.value>=0?'linear-gradient(90deg,var(--green),var(--cyan))':
'linear-gradient(90deg,var(--red),#c00)'} />
<Row label="Signal" value={macd.signal} method={macd.method} />
</SPanel>
)}
{bb && (
<SPanel title="Bollinger Bands · gs-quant" accentColor="var(--green)"
badge={bb.bias}
badgeClass={bb.bias==='BULLISH'?'tbadge-green':bb.bias==='BEARISH'?'tbadge-high':'tbadge-medium'}>
<Row label="Upper Band" value={bb.upper} />
<Row label="Mid (SMA)" value={bb.sma} />
<Row label="Lower Band" value={bb.lower} />
<Row label="%B Position" value={`${(bb.pct_b*100).toFixed(1)}%`}
bar={bb.pct_b*100} barMax={100}
barColor={bb.pct_b>0.8?'linear-gradient(90deg,var(--red),#c00)':
bb.pct_b<0.2?'linear-gradient(90deg,var(--green),var(--cyan))':
'linear-gradient(90deg,var(--cyan),var(--purple))'}
method={bb.method} />
</SPanel>
)}
</div>
{/* GS-Quant attribution */}
<div style={{ marginTop:12, padding:'6px 10px', borderRadius:6,
background:'rgba(0,212,255,0.04)', border:'1px solid rgba(0,212,255,0.1)',
display:'flex', alignItems:'center', gap:8 }}>
<span style={{ fontSize:9, color:'var(--cyan)', fontWeight:600 }}>POWERED BY</span>
<span style={{ fontSize:9, color:'var(--text-muted)' }}>
gs-quant v2.0.0 (Goldman Sachs) RSI, MACD, Bollinger, volatility, z-scores, max drawdown
</span>
</div>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { useState, useEffect } from 'react'
import { usePortfolioStore } from '../store/portfolio'
import { computeScenarios } from '../api/client'
export default function ScenarioTable() {
const { legs, S, sigma, T, r_d, r_f, pair } = usePortfolioStore()
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!legs.length) return
setLoading(true)
computeScenarios({
options: legs.map(({ type, K, T, qty }) => ({ type, K, T, qty })),
S0: S, sigma0: sigma, T, r_d, r_f,
})
.then(setResult)
.catch(() => {})
.finally(() => setLoading(false))
}, [S, sigma, T, r_d, r_f, JSON.stringify(legs)])
if (loading) return (
<div style={{ padding:20 }}>
<div style={{ display:'flex', alignItems:'center', gap:8, marginBottom:14 }}>
<div className="live-dot"/>
<span style={{ color:'var(--text-muted)', fontSize:12 }}>Computing scenario shocks</span>
</div>
{[...Array(5)].map((_,i) => (
<div key={i} className="skeleton" style={{ height:36, marginBottom:4, borderRadius:4 }}/>
))}
</div>
)
if (!result) return null
const maxAbs = Math.max(...result.scenarios.map(s => Math.abs(s.pnl)), 1)
return (
<div style={{ padding:16 }}>
{/* Header */}
<div style={{ display:'flex', alignItems:'center', justifyContent:'space-between', marginBottom:12 }}>
<div>
<div className="section-title">Scenario Shock Analysis</div>
<div style={{ fontSize:9, color:'var(--text-muted)', marginTop:2 }}>
Garman-Kohlhagen repricing · {pair} · r_d={r_d} r_f={r_f}
</div>
</div>
<div className="glass" style={{ padding:'6px 12px', borderRadius:6, fontSize:11 }}>
<span style={{ color:'var(--text-muted)' }}>Base value: </span>
<span className="font-mono neon-cyan">{result.base_value.toFixed(5)}</span>
</div>
</div>
<div style={{ overflowX:'auto', borderRadius:8, border:'1px solid var(--border)' }}>
<table style={{ width:'100%', borderCollapse:'collapse', fontSize:10 }}>
<thead>
<tr style={{ background:'var(--bg3)' }}>
{['Scenario','ΔSpot','ΔVol','New Spot','New Vol','P&L','P&L %','Bar'].map(h => (
<th key={h} style={{ padding:'8px 10px', fontWeight:600, fontSize:9,
color:'var(--text-muted)', letterSpacing:'0.05em',
textAlign: h==='Scenario'?'left':'right', whiteSpace:'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{result.scenarios.map((s, i) => {
const isBase = s.label === 'Base Case'
const isProfit = s.pnl >= 0
return (
<tr key={i} style={{
borderTop:'1px solid var(--border)',
background: isBase
? 'rgba(0,212,255,0.06)'
: i%2===0?'transparent':'rgba(255,255,255,0.01)',
}}>
<td style={{ padding:'7px 10px',
color: isBase ? 'var(--cyan)' : 'var(--text-dim)', fontWeight: isBase?600:400 }}>
{isBase && <span style={{ marginRight:4 }}></span>}{s.label}
</td>
<td className="font-mono" style={{ padding:'7px 10px', textAlign:'right',
color: s.dS_pct<0?'var(--red)':s.dS_pct>0?'var(--green)':'var(--text-muted)' }}>
{s.dS_pct>=0?'+':''}{(s.dS_pct*100).toFixed(0)}%
</td>
<td className="font-mono" style={{ padding:'7px 10px', textAlign:'right',
color: s.dVol>0?'var(--red)':s.dVol<0?'var(--green)':'var(--text-muted)' }}>
{s.dVol>=0?'+':''}{(s.dVol*100).toFixed(0)}pp
</td>
<td className="font-mono" style={{ padding:'7px 10px', textAlign:'right', color:'var(--text-dim)' }}>
{s.S_shocked}
</td>
<td className="font-mono" style={{ padding:'7px 10px', textAlign:'right', color:'var(--text-dim)' }}>
{(s.vol_shocked*100).toFixed(0)}%
</td>
<td className="font-mono" style={{ padding:'7px 10px', textAlign:'right', fontWeight:700,
color: isProfit?'var(--green)':'var(--red)' }}>
{isProfit?'+':''}{s.pnl.toFixed(4)}
</td>
<td className="font-mono" style={{ padding:'7px 10px', textAlign:'right',
color: isProfit?'var(--green)':'var(--red)' }}>
{isProfit?'+':''}{s.pnl_pct.toFixed(1)}%
</td>
<td style={{ padding:'7px 10px', width:80 }}>
<div style={{ height:6, borderRadius:3, background:'var(--bg2)', overflow:'hidden' }}>
<div style={{
height:'100%', borderRadius:3,
width:`${(Math.abs(s.pnl)/maxAbs)*100}%`,
background: isProfit
? 'linear-gradient(90deg,var(--green),var(--cyan))'
: 'linear-gradient(90deg,var(--red),#ff0040)',
transition:'width 0.4s ease',
}}/>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react'
import _Plot from 'react-plotly.js'
import { usePortfolioStore } from '../store/portfolio'
import { computeSurface } from '../api/client'
// Vite 8 CJS interop: react-plotly.js exports { default: Component }
const Plot = _Plot?.default ?? _Plot
const SURFACE_CONFIGS = {
delta: { title: 'Delta Surface', colorscale: 'Plasma' },
gamma: { title: 'Gamma Surface', colorscale: 'Cividis' },
vega: { title: 'Vega Surface', colorscale: 'Viridis' },
theta: { title: 'Theta Surface', colorscale: 'Inferno' },
pnl: { title: 'P&L Surface', colorscale: 'RdYlGn' },
}
export default function SurfacePlot() {
const { legs, S, sigma, T, r_d, r_f } = usePortfolioStore()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
const [active, setActive] = useState('delta')
useEffect(() => {
if (!legs.length) return
setLoading(true)
computeSurface({
options: legs.map(({ type, K, T, qty }) => ({ type, K, T, qty })),
S_low: S * 0.75, S_high: S * 1.25, S_steps: 35,
vol_low: Math.max(0.01, sigma - 0.12), vol_high: sigma + 0.18, vol_steps: 35,
T, r_d, r_f,
})
.then(setData)
.catch(() => {})
.finally(() => setLoading(false))
}, [S, sigma, T, r_d, r_f, JSON.stringify(legs)])
if (loading) return (
<div style={{ padding: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div className="live-dot" />
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Computing 3D Risk Surfaces...</span>
</div>
<div className="skeleton" style={{ height: 460, borderRadius: 8 }} />
</div>
)
if (!data) return (
<div style={{ padding: 20, height: 460, display: 'flex', alignItems: 'center',
justifyContent: 'center', color: '#475569', fontSize: 13 }}>
Add option legs to compute surfaces
</div>
)
const cfg = SURFACE_CONFIGS[active]
const Z = data[active]
const plotData = [{
type: 'surface',
x: data.sigma_range,
y: data.S_range,
z: Z,
colorscale: cfg.colorscale,
opacity: 0.9,
showscale: true,
}]
const layout = {
title: { text: cfg.title, font: { color: '#00d4ff', size: 13 } },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
scene: {
xaxis: { title: 'Implied Vol', color: '#64748b', gridcolor: '#1e293b' },
yaxis: { title: 'Spot Rate', color: '#64748b', gridcolor: '#1e293b' },
zaxis: { title: active, color: '#64748b', gridcolor: '#1e293b' },
bgcolor: '#0a0e1a',
camera: { eye: { x: 1.5, y: 1.5, z: 0.9 } },
},
margin: { l: 0, r: 0, t: 40, b: 0 },
font: { color: '#e2e8f0', size: 11 },
}
return (
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
{Object.entries(SURFACE_CONFIGS).map(([key, { title }]) => (
<button key={key} onClick={() => setActive(key)}
style={{
padding: '4px 12px', borderRadius: 6, border: 'none',
fontSize: 11, fontWeight: 600, cursor: 'pointer',
background: active === key ? '#00d4ff' : 'rgba(255,255,255,0.05)',
color: active === key ? '#020817' : '#64748b',
transition: 'all 0.15s',
}}>
{title.split(' ')[0]}
</button>
))}
</div>
<div style={{ borderRadius: 8, overflow: 'hidden', background: 'rgba(10,14,26,0.6)',
border: '1px solid rgba(0,212,255,0.08)' }}>
<Plot
data={plotData}
layout={layout}
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
style={{ width: '100%', height: 460 }}
/>
</div>
</div>
)
}
+346
View File
@@ -0,0 +1,346 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { MapContainer, TileLayer, Marker, Popup, CircleMarker, useMap } from 'react-leaflet'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
const CCY_POS = {
USD:[38,-97], EUR:[51,10], GBP:[52.5,-1.5], JPY:[36,138],
AUD:[-25,134], CAD:[56,-106], CHF:[46.8,8.2], NZD:[-41,174],
CNY:[35,105], CNH:[22.3,114.2], SEK:[60,18], NOK:[60,8],
DKK:[56,10], SGD:[1.35,103.8], HKD:[22.3,114.2], MXN:[23,-102],
BRL:[-14,-51], ZAR:[-30,25], INR:[20,77], KRW:[36,128],
}
function planeIcon(heading) {
const deg = (heading || 0) - 45
return L.divIcon({
html: `<svg viewBox="0 0 20 20" width="13" height="13" style="transform:rotate(${deg}deg);overflow:visible;display:block">
<polygon points="10,0 13,8 20,10 13,12 10,20 7,12 0,10 7,8" fill="#00c8f0" opacity="0.85"/>
</svg>`,
iconSize: [13,13], iconAnchor: [6,6], className:'',
})
}
function eventIcon(impact) {
const c = impact === 'High' ? '#ff4c5e' : impact === 'Medium' ? '#f5a623' : '#3ddc84'
return L.divIcon({
html: `<span style="display:block;width:8px;height:8px;border-radius:50%;background:${c};border:1.5px solid rgba(255,255,255,0.3);box-shadow:0 0 7px ${c}99"></span>`,
iconSize: [8,8], iconAnchor: [4,4], className:'',
})
}
function quakeColor(m) {
if (m >= 7) return '#ff0040'
if (m >= 6) return '#ff4c5e'
if (m >= 5.5) return '#f5a623'
return '#f5a62355'
}
function quakeR(m) { return Math.max(5, (m - 4) * 7) }
function RadarLayer({ active }) {
const map = useMap()
const ref = useRef(null)
useEffect(() => {
if (!active) {
if (ref.current) { map.removeLayer(ref.current); ref.current = null }
return
}
let cancelled = false
fetch('https://api.rainviewer.com/public/weather-maps.json')
.then(r => r.json())
.then(d => {
if (cancelled) return
const past = d.radar?.past || []
if (!past.length) return
const t = past[past.length - 1].time
if (ref.current) map.removeLayer(ref.current)
ref.current = L.tileLayer(
`${d.host}/v2/radar/${t}/512/{z}/{x}/{y}/4/1_1.png`,
{ opacity: 0.52, zIndex: 5, attribution: 'RainViewer' }
).addTo(map)
}).catch(() => {})
return () => {
cancelled = true
if (ref.current) { map.removeLayer(ref.current); ref.current = null }
}
}, [active, map])
return null
}
const LAYER_CFG = [
{ key:'aircraft', label:'AIRCRAFT', color:'var(--cyan)' },
{ key:'quakes', label:'EARTHQUAKES', color:'var(--red)' },
{ key:'weather', label:'RADAR', color:'var(--purple)'},
{ key:'events', label:'FOREX EVENTS', color:'var(--amber)' },
]
export default function WorldMap() {
const [layers, setLayers] = useState({ aircraft:true, quakes:true, weather:false, events:true })
const [aircraft,setAircraft]= useState([])
const [quakes, setQuakes] = useState([])
const [events, setEvents] = useState([])
const [stats, setStats] = useState({ planes:0, quakes:0, events:0 })
const [updated, setUpdated] = useState(null)
const [loadingAc, setLoadingAc] = useState(false)
const toggle = k => setLayers(p => ({ ...p, [k]: !p[k] }))
const loadAircraft = useCallback(async () => {
setLoadingAc(true)
try {
const r = await fetch('http://localhost:8000/api/live/aircraft')
const d = await r.json()
const planes = (d.states || [])
.filter(s => s[5] != null && s[6] != null && !s[8])
.slice(0, 500)
.map(s => ({
id: s[0],
call: (s[1]||'').trim() || s[0],
country: s[2],
lon: s[5], lat: s[6],
alt: s[7] ? Math.round(s[7] / 0.3048).toLocaleString() : '?',
spd: s[9] ? Math.round(s[9] * 1.944) : '?',
hdg: s[10],
}))
setAircraft(planes)
setStats(p => ({ ...p, planes: planes.length }))
setUpdated(new Date().toLocaleTimeString())
} catch {}
finally { setLoadingAc(false) }
}, [])
const loadQuakes = useCallback(async () => {
try {
const r = await fetch('http://localhost:8000/api/live/earthquakes')
const d = await r.json()
const qs = d.features || []
setQuakes(qs)
setStats(p => ({ ...p, quakes: qs.length }))
} catch {}
}, [])
const loadEvents = useCallback(async () => {
try {
const r = await fetch('http://localhost:8000/api/news/calendar')
const d = await r.json()
const evs = (d.events || []).filter(e => CCY_POS[e.country])
setEvents(evs)
setStats(p => ({ ...p, events: evs.length }))
} catch {}
}, [])
useEffect(() => {
loadAircraft(); loadQuakes(); loadEvents()
const iv = setInterval(loadAircraft, 30000)
return () => clearInterval(iv)
}, [loadAircraft, loadQuakes, loadEvents])
function refresh() { loadAircraft(); loadQuakes(); loadEvents() }
return (
<div style={{ display:'flex', flexDirection:'column', height:'100%' }}>
{/* Terminal header */}
<div className="term-header" style={{ padding:'6px 14px', flexShrink:0, borderRadius:0 }}>
<span className="live-dot-blink"/>
<span className="term-header-cyan">LIVE WORLD MAP</span>
<span style={{ opacity:0.4 }}>·</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)' }}>
OpenSky · USGS · RainViewer · Forex Factory
</span>
{updated && <>
<span style={{ opacity:0.4 }}>·</span>
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--text-muted)' }}>updated {updated}</span>
</>}
<div style={{ flex:1 }}/>
{loadingAc && (
<span style={{ fontFamily:'var(--font-mono)', fontSize:9, color:'var(--cyan)' }}> aircraft</span>
)}
<button className="btn-ghost" onClick={refresh} style={{ fontSize:10, padding:'3px 9px' }}> Refresh</button>
</div>
{/* Layer toggles */}
<div style={{
display:'flex', gap:4, padding:'5px 12px', alignItems:'center',
background:'var(--bg2)', borderBottom:'1px solid var(--border)', flexShrink:0, flexWrap:'wrap',
}}>
<span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-muted)', letterSpacing:'0.1em', marginRight:4 }}>LAYERS</span>
{LAYER_CFG.map(({ key, label, color }) => {
const on = layers[key]
const count = key === 'aircraft' ? stats.planes : key === 'quakes' ? stats.quakes : key === 'events' ? stats.events : null
return (
<button key={key} onClick={() => toggle(key)} style={{
fontFamily:'var(--font-mono)', fontSize:8.5, fontWeight:700, letterSpacing:'0.06em',
padding:'3px 10px', borderRadius:'var(--r-sm)', cursor:'pointer', outline:'none',
border:`1px solid ${on ? color : 'var(--border-sub)'}`,
background: on ? `color-mix(in srgb, ${color} 12%, transparent)` : 'transparent',
color: on ? color : 'var(--text-muted)',
transition:'var(--t-fast)', display:'flex', alignItems:'center', gap:5,
}}>
<span style={{ width:6, height:6, borderRadius:'50%', background: on ? color : 'var(--text-faint)', display:'inline-block', transition:'var(--t-fast)' }}/>
{label}
{count != null && on && (
<span style={{ opacity:0.65, fontSize:8 }}>({count})</span>
)}
</button>
)
})}
<div style={{ flex:1 }}/>
<span style={{ fontFamily:'var(--font-mono)', fontSize:8, color:'var(--text-faint)' }}>
aircraft refresh 30s · data: anonymous free APIs
</span>
</div>
{/* Map container */}
<div style={{ flex:1, position:'relative', minHeight:'400px' }}>
<style>{`
.leaflet-container { background:#080e1a !important; font-family:var(--font-mono) !important; }
.leaflet-popup-content-wrapper { background:var(--bg3) !important; border:1px solid var(--border-md) !important; color:var(--text) !important; border-radius:var(--r-md) !important; box-shadow:0 8px 32px rgba(0,0,0,0.5) !important; }
.leaflet-popup-tip { background:var(--bg3) !important; }
.leaflet-popup-content { margin:10px 14px !important; }
.leaflet-control-attribution { background:rgba(8,14,26,0.82) !important; color:var(--text-muted) !important; font-size:8px !important; }
.leaflet-control-zoom a { background:var(--bg3) !important; color:var(--cyan) !important; border-color:var(--border) !important; }
.leaflet-control-zoom a:hover { background:var(--bg4) !important; }
.leaflet-bar { border:1px solid var(--border) !important; box-shadow:none !important; }
`}</style>
<MapContainer
center={[25, 10]} zoom={2} minZoom={2} maxZoom={14}
style={{ width:'100%', height:'calc(100vh - 155px)', minHeight:'400px', background:'#080e1a' }}
worldCopyJump
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
subdomains="abcd" maxZoom={19}
/>
<RadarLayer active={layers.weather} />
{/* Earthquakes */}
{layers.quakes && quakes.map(q => {
const [lon, lat] = q.geometry.coordinates
const mag = q.properties.mag
if (!lat || !lon) return null
return (
<CircleMarker key={q.id} center={[lat, lon]}
radius={quakeR(mag)} weight={1.5}
color={quakeColor(mag)} fillColor={quakeColor(mag)} fillOpacity={0.3}
>
<Popup>
<div>
<div style={{ color:quakeColor(mag), fontWeight:700, fontSize:13, marginBottom:4 }}>M {mag?.toFixed(1)}</div>
<div style={{ fontSize:11 }}>{q.properties.place}</div>
<div style={{ color:'var(--text-muted)', fontSize:9, marginTop:4 }}>
{new Date(q.properties.time).toUTCString()}
</div>
<div style={{ color:'var(--text-muted)', fontSize:9 }}>Depth: {q.geometry.coordinates[2]?.toFixed(1)} km</div>
<a href={q.properties.url} target="_blank" rel="noreferrer"
style={{ color:'var(--cyan)', fontSize:9, marginTop:5, display:'block' }}>
USGS details
</a>
</div>
</Popup>
</CircleMarker>
)
})}
{/* Forex events */}
{layers.events && events.map((ev, i) => {
const pos = CCY_POS[ev.country]
if (!pos) return null
return (
<Marker key={i} position={pos} icon={eventIcon(ev.impact)}>
<Popup>
<div>
<div style={{ fontWeight:700, fontSize:12, marginBottom:4 }}>{ev.title}</div>
<div style={{ fontSize:10 }}>
<span style={{ color: ev.impact === 'High' ? 'var(--red)' : ev.impact === 'Medium' ? 'var(--amber)' : 'var(--green)' }}>
{ev.impact} impact
</span>
{' · '}{ev.country}
</div>
{ev.actual?.trim() && (
<div style={{ color:'var(--green)', fontSize:10, marginTop:4 }}>Actual: {ev.actual}</div>
)}
{ev.forecast?.trim() && (
<div style={{ color:'var(--text-muted)', fontSize:10 }}>Forecast: {ev.forecast}</div>
)}
{ev.previous?.trim() && (
<div style={{ color:'var(--text-muted)', fontSize:10 }}>Previous: {ev.previous}</div>
)}
</div>
</Popup>
</Marker>
)
})}
{/* Aircraft */}
{layers.aircraft && aircraft.map(ac => (
<Marker key={ac.id} position={[ac.lat, ac.lon]} icon={planeIcon(ac.hdg)}>
<Popup>
<div>
<div style={{ color:'var(--cyan)', fontWeight:700, fontSize:13, marginBottom:4 }}>
{ac.call}
</div>
<div style={{ fontSize:10, color:'var(--text-secondary)' }}>{ac.country}</div>
<div style={{ fontSize:10, color:'var(--text-muted)', marginTop:4 }}>
Alt: {ac.alt} ft
</div>
<div style={{ fontSize:10, color:'var(--text-muted)' }}>
Speed: {ac.spd} kts
</div>
{ac.hdg != null && (
<div style={{ fontSize:10, color:'var(--text-muted)' }}>
Heading: {Math.round(ac.hdg)}°
</div>
)}
</div>
</Popup>
</Marker>
))}
</MapContainer>
{/* Legend overlay */}
<div style={{
position:'absolute', bottom:24, right:12, zIndex:1000,
background:'rgba(8,14,26,0.9)', border:'1px solid var(--border)',
borderRadius:'var(--r-md)', padding:'8px 12px',
fontFamily:'var(--font-mono)', fontSize:8.5,
}}>
{layers.aircraft && (
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:4 }}>
<span style={{ color:'#00c8f0' }}></span>
<span style={{ color:'var(--text-muted)' }}>{stats.planes} aircraft · OpenSky</span>
</div>
)}
{layers.quakes && (
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:4 }}>
<span style={{ color:'var(--red)' }}></span>
<span style={{ color:'var(--text-muted)' }}>{stats.quakes} quakes M4.5+ · USGS 7d</span>
</div>
)}
{layers.weather && (
<div style={{ display:'flex', alignItems:'center', gap:6, marginBottom:4 }}>
<span style={{ color:'var(--purple)' }}></span>
<span style={{ color:'var(--text-muted)' }}>Radar overlay · RainViewer</span>
</div>
)}
{layers.events && (
<div style={{ display:'flex', alignItems:'center', gap:6 }}>
<span style={{ color:'var(--amber)' }}></span>
<span style={{ color:'var(--text-muted)' }}>{stats.events} forex events · Forex Factory</span>
</div>
)}
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+114
View File
@@ -0,0 +1,114 @@
import { useState, Suspense, lazy, Component } from 'react'
import GreeksDashboard from '../components/GreeksDashboard'
import SurfacePlot from '../components/SurfacePlot'
import MonteCarloChart from '../components/MonteCarloChart'
import ScenarioTable from '../components/ScenarioTable'
import BreakevenChart from '../components/BreakevenChart'
import CandlestickChart from '../components/CandlestickChart'
import QuantSignals from '../components/QuantSignals'
import InstitutionalFlow from '../components/InstitutionalFlow'
import EconomicCalendar from '../components/EconomicCalendar'
const IVSurface = lazy(() => import('../components/IVSurface'))
const WorldMap = lazy(() => import('../components/WorldMap'))
const LiveFeeds = lazy(() => import('../components/LiveFeeds'))
class TabErrorBoundary extends Component {
state = { error: null }
static getDerivedStateFromError(e) { return { error: e } }
render() {
if (this.state.error) return (
<div style={{ padding: 24 }}>
<div style={{ padding: 14, borderRadius: 8, background: 'rgba(255,61,90,0.1)',
border: '1px solid #ff3d5a', color: '#ff3d5a', fontSize: 12, marginBottom: 10 }}>
Render error: {this.state.error.message}
</div>
<button onClick={() => this.setState({ error: null })}
style={{ padding: '6px 14px', borderRadius: 6, background: 'rgba(0,212,255,0.1)',
border: '1px solid #00d4ff', color: '#00d4ff', fontSize: 11, cursor: 'pointer' }}>
Retry
</button>
</div>
)
return this.props.children
}
}
const TABS = [
{ id:'greeks', icon:'Δ', label:'Greeks', component: GreeksDashboard },
{ id:'chart', icon:'📈', label:'Chart', component: CandlestickChart },
{ id:'signals', icon:'⚡', label:'AI Signals', component: QuantSignals },
{ id:'surfaces', icon:'🗻', label:'3D Surfaces', component: SurfacePlot },
{ id:'breakeven', icon:'🎯', label:'Breakeven', component: BreakevenChart },
{ id:'scenarios', icon:'⚠', label:'Scenarios', component: ScenarioTable },
{ id:'montecarlo', icon:'🎲', label:'Monte Carlo', component: MonteCarloChart },
{ id:'institutional', icon:'🏦', label:'Institutional', component: InstitutionalFlow },
{ id:'calendar', icon:'📅', label:'Calendar', component: EconomicCalendar },
{ id:'worldmap', icon:'🌍', label:'Live Map', component: WorldMap },
{ id:'livefeeds', icon:'📡', label:'Live Feeds', component: LiveFeeds },
]
const LoadingFallback = () => (
<div style={{ padding:20 }}>
{[...Array(4)].map((_,i) => (
<div key={i} className="skeleton" style={{ height:48, marginBottom:8, borderRadius:8 }}/>
))}
</div>
)
export default function Dashboard() {
const [activeTab, setActiveTab] = useState('greeks')
const ActiveTab = TABS.find(t => t.id === activeTab) ?? TABS[0]
const ActiveComponent = ActiveTab.component
return (
<div className="dash-content" style={{ display:'flex', flexDirection:'column', height:'100%', overflow:'hidden' }}>
{/* Tab bar — worldmonitor variant-switcher style */}
<div style={{
display:'flex', alignItems:'center', gap:2, padding:'6px 10px',
borderBottom:'1px solid var(--border)',
background:'var(--bg2)',
overflowX:'auto', flexShrink:0,
}}>
{TABS.map(tab => {
const active = activeTab === tab.id
return (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
style={{
display:'flex', alignItems:'center', gap:5,
padding:'5px 11px', borderRadius:'var(--r-sm)',
border: active
? '1px solid color-mix(in srgb, var(--cyan) 30%, transparent)'
: '1px solid transparent',
cursor:'pointer', whiteSpace:'nowrap',
fontFamily:'var(--font-mono)',
fontSize:10, fontWeight:700, letterSpacing:'0.06em',
textTransform:'uppercase',
background: active
? 'color-mix(in srgb, var(--cyan) 10%, transparent)'
: 'transparent',
color: active ? 'var(--cyan)' : 'var(--text-muted)',
transition:'var(--t-fast)',
outline:'none',
minHeight:32,
}}>
<span style={{ fontSize:11, opacity: active ? 1 : 0.7 }}>{tab.icon}</span>
{tab.label}
</button>
)
})}
</div>
{/* Content area */}
<div style={{ flex:1, overflowY:'auto' }} className="term-scroll">
<TabErrorBoundary key={activeTab}>
<Suspense fallback={<LoadingFallback />}>
<div style={{ animation:'lp-reveal 0.2s ease both' }}>
<ActiveComponent />
</div>
</Suspense>
</TabErrorBoundary>
</div>
</div>
)
}
+392
View File
@@ -0,0 +1,392 @@
import { useState, useEffect, useRef } from 'react'
import { NexusLogoLarge, NexusIcon } from '../components/NexusLogo'
/* ── Inline SVG icons ── */
const TriangleIcon = () => (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<path d="M13 3L23 21H3L13 3z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
<line x1="7" y1="15" x2="19" y2="15" stroke="currentColor" strokeWidth="1.2" opacity="0.55"/>
</svg>
)
const LineChartIcon = () => (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<polyline points="2,20 7,13 12,16 17,7 22,11" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
<circle cx="22" cy="11" r="2" fill="currentColor"/>
</svg>
)
const CubeIcon = () => (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<path d="M13 2L23 8V18L13 24L3 18V8L13 2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
<path d="M3 8L13 14L23 8" stroke="currentColor" strokeWidth="1.2" opacity="0.55"/>
<line x1="13" y1="14" x2="13" y2="24" stroke="currentColor" strokeWidth="1.2" opacity="0.55"/>
</svg>
)
const BuildingIcon = () => (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<rect x="2" y="14" width="5" height="9" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="10" y="9" width="5" height="14" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<rect x="18" y="4" width="5" height="19" rx="1" stroke="currentColor" strokeWidth="1.5"/>
<polyline points="4.5,12 12.5,7 20.5,2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" opacity="0.45"/>
</svg>
)
const ScatterIcon = () => (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<path d="M2 21C5 16 8 12 11 14C13 16 15 8 18 10C20 12 22 6 24 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.45"/>
<path d="M2 18C6 14 9 18 12 12C14 7 17 15 20 11C22 9 23 13 24 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<path d="M2 23C7 19 10 16 13 18C16 20 19 14 24 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.7"/>
</svg>
)
const CalIcon = () => (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<rect x="2" y="5" width="22" height="19" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<line x1="2" y1="11" x2="24" y2="11" stroke="currentColor" strokeWidth="1.5"/>
<line x1="8" y1="2" x2="8" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<line x1="18" y1="2" x2="18" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<rect x="6" y="14" width="4" height="4" rx="0.5" fill="currentColor" opacity="0.65"/>
<rect x="13" y="14" width="4" height="4" rx="0.5" stroke="currentColor" strokeWidth="1" opacity="0.35"/>
</svg>
)
const TableIcon = () => (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<rect x="2" y="2" width="22" height="22" rx="2" stroke="currentColor" strokeWidth="1.5"/>
<line x1="2" y1="10" x2="24" y2="10" stroke="currentColor" strokeWidth="1" opacity="0.4"/>
<line x1="2" y1="18" x2="24" y2="18" stroke="currentColor" strokeWidth="1" opacity="0.4"/>
<line x1="10" y1="2" x2="10" y2="24" stroke="currentColor" strokeWidth="1" opacity="0.4"/>
<path d="M6 14L9 11L12 13L16 8L20 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
const BreakIcon = () => (
<svg width="26" height="26" viewBox="0 0 26 26" fill="none">
<line x1="2" y1="13" x2="24" y2="13" stroke="currentColor" strokeWidth="1" opacity="0.4" strokeDasharray="3 2"/>
<path d="M3 21C5 21 6 13 10 13C14 13 16 5 19 5C21 5 22 13 23 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
<circle cx="10" cy="13" r="2.5" fill="currentColor" opacity="0.85"/>
<circle cx="19" cy="13" r="2.5" fill="currentColor" opacity="0.85"/>
</svg>
)
const FEATURES = [
{ Icon: TriangleIcon, title: 'Greek Risk Engine', desc: 'Δ Delta · Γ Gamma · ν Vega · Θ Theta per leg and portfolio via Garman-Kohlhagen (1983) with live spot rates.', color: '#00d4ff', tag: 'Greeks' },
{ Icon: LineChartIcon,title: 'AI Market Signals', desc: '9 quantitative signals — RSI, MACD, Bollinger Bands, Hurst exponent, volatility regime and carry — via Goldman Sachs gs-quant.', color: '#a78bfa', tag: 'AI Signals' },
{ Icon: CubeIcon, title: '3D Volatility Surfaces',desc: 'Interactive 3D plots of Delta, Gamma, Vega, Theta, and P&L across the full spot × volatility surface with Plotly.', color: '#00d4ff', tag: '3D Surfaces' },
{ Icon: BuildingIcon, title: 'Institutional Flow', desc: 'CFTC COT positioning — Asset Managers, Hedge Funds, Dealers — with Volume Profile, POC, VAH, and VAL visualization.', color: '#ffd700', tag: 'Institutional' },
{ Icon: ScatterIcon, title: 'Monte Carlo Sim', desc: 'GBM path simulation with up to 5,000 paths. Terminal P&L distribution, percentile bands, and probability of profit.', color: '#a78bfa', tag: 'Monte Carlo' },
{ Icon: CalIcon, title: 'Economic Calendar', desc: 'Forex Factory weekly events — 120+ per week — with impact levels, forecasts, previous values and real-time countdowns.', color: '#00d4ff', tag: 'Calendar' },
{ Icon: TableIcon, title: 'Scenario Analysis', desc: 'Stress tests across spot ±5%/±10% and vol ±1pp/±2pp shocks with full P&L matrix and per-leg breakdown.', color: '#ff3d5a', tag: 'Scenarios' },
{ Icon: BreakIcon, title: 'Breakeven Profile', desc: 'Payoff curve at expiry with breakeven strikes, max profit, max loss, current spot, and net premium marked.', color: '#00ff88', tag: 'Breakeven' },
]
/* Animated counter hook */
function useCounter(target, ms, start) {
const [val, setVal] = useState(0)
useEffect(() => {
if (!start) return
const t0 = Date.now()
const tick = () => {
const p = Math.min((Date.now() - t0) / ms, 1)
const e = 1 - Math.pow(1 - p, 3)
setVal(Math.round(e * target))
if (p < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}, [target, ms, start])
return val
}
/* Mini price chart SVG */
function PreviewChart() {
const d = "M0,55 C8,52 14,47 20,43 C26,39 30,46 36,39 C42,32 48,28 54,24 C60,20 66,27 72,21 C78,15 84,19 90,13 C96,7 102,11 108,7 C114,3 118,6 124,4"
const area = d + " L124,72 L0,72 Z"
return (
<svg viewBox="0 0 124 72" fill="none" style={{ width:'100%', height:68 }}>
<defs>
<linearGradient id="cg" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="#00d4ff" stopOpacity="0.25"/>
<stop offset="100%" stopColor="#00d4ff" stopOpacity="0"/>
</linearGradient>
</defs>
<path d={area} fill="url(#cg)"/>
<path d={d} stroke="#00d4ff" strokeWidth="1.5" fill="none"
strokeDasharray="300" strokeDashoffset="300"
style={{ animation:'lp-draw-line 2.2s ease-out 0.8s forwards' }}/>
<circle cx="124" cy="4" r="3" fill="#00d4ff" opacity="0"
style={{ animation:'lp-fade 0.4s ease 3s forwards' }}/>
</svg>
)
}
/* Arrow right icon */
const ArrowRight = ({ size = 15 }) => (
<svg width={size} height={size} viewBox="0 0 15 15" fill="none">
<path d="M3 7.5H12M8.5 3.5L12 7.5L8.5 11.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
/* Chevron down */
const ChevDown = () => (
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<path d="M6.5 3V10M3 6.5L6.5 10L10 6.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)
export default function LandingPage({ onEnter }) {
const statsRef = useRef(null)
const [counting, setCounting] = useState(false)
const pairs = useCounter(11, 1500, counting)
const mods = useCounter(9, 1700, counting)
const evts = useCounter(120, 2000, counting)
const paths = useCounter(5000, 2400, counting)
useEffect(() => {
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) setCounting(true) }, { threshold: 0.4 })
if (statsRef.current) io.observe(statsRef.current)
return () => io.disconnect()
}, [])
return (
<div className="lp">
{/* Floating orbs */}
<div className="lp-orbs" aria-hidden>
<div className="lp-orb lp-orb1"/>
<div className="lp-orb lp-orb2"/>
<div className="lp-orb lp-orb3"/>
</div>
{/* ── Nav ─────────────────────────────────── */}
<nav className="lp-nav">
<div className="lp-logo">
<NexusIcon size={32} />
<div>
<div className="lp-logo-name" style={{
fontFamily:'var(--font-mono)', letterSpacing:'0.2em',
textShadow:'0 0 18px rgba(0,200,240,0.6)',
animation:'nx-flicker 9s ease-in-out infinite',
}}>NEXUS</div>
<div className="lp-logo-tagline" style={{ letterSpacing:'0.18em' }}>TERMINAL · FX OPTIONS</div>
</div>
</div>
<button className="lp-nav-cta" onClick={onEnter} aria-label="Launch platform">
Launch Platform <ArrowRight size={14}/>
</button>
</nav>
{/* ── Hero ────────────────────────────────── */}
<section className="lp-hero">
<div className="lp-hero-text">
<div className="lp-eyebrow lp-reveal" style={{'--d':'0s'}}>
<span className="lp-eyebrow-dot" aria-hidden/>
Garman-Kohlhagen 1983 · Goldman Sachs gs-quant · CFTC · Forex Factory
</div>
<div className="lp-reveal" style={{'--d':'0.05s', display:'flex', alignItems:'center', gap:20, marginBottom:8}}>
<NexusLogoLarge size={76}/>
<div>
<h1 className="lp-h1" style={{ margin:0, fontSize:'clamp(38px,5vw,64px)' }}>
<span className="lp-grad-text" style={{
letterSpacing:'0.12em',
textShadow:'0 0 40px rgba(0,200,240,0.35)',
animation:'nx-flicker 11s ease-in-out infinite',
}}>NEXUS</span>
<br/>
<span style={{ fontSize:'0.52em', letterSpacing:'0.22em', color:'var(--text-secondary)', fontWeight:500 }}>TERMINAL</span>
</h1>
</div>
</div>
<p className="lp-hero-sub lp-reveal" style={{'--d':'0.20s'}}>
Institutional-grade FX options analytics. Greeks, AI signals,
live world map, real-time crypto, webcams from 6 financial centers.
Everything. Free. No account.
</p>
<div className="lp-ctas lp-reveal" style={{'--d':'0.30s'}}>
<button className="lp-btn-primary" onClick={onEnter}>
<ArrowRight size={16}/> Launch Platform
</button>
<a href="#features" className="lp-btn-outline">
Explore Features <ChevDown/>
</a>
</div>
{/* Stats */}
<div className="lp-stats lp-reveal" ref={statsRef} style={{'--d':'0.42s'}}>
{[
{ n: pairs, s: 'Currency Pairs' },
{ n: mods, s: 'Analytics Modules' },
{ n: `${evts}+`, s: 'Events / Week' },
{ n: paths.toLocaleString(), s: 'MC Paths' },
].map(({ n, s }, i, arr) => (
<div key={s} className="lp-stat-group">
<div className="lp-stat-num">{n}</div>
<div className="lp-stat-lbl">{s}</div>
{i < arr.length - 1 && <div className="lp-stat-sep" aria-hidden/>}
</div>
))}
</div>
</div>
{/* Terminal card */}
<div className="lp-terminal lp-reveal" style={{'--d':'0.50s'}} aria-label="Platform preview">
<div className="lp-term-bar">
<span className="lp-dot" style={{background:'#ff5f57'}}/>
<span className="lp-dot" style={{background:'#febc2e'}}/>
<span className="lp-dot" style={{background:'#28c840'}}/>
<span className="lp-term-title">Risk Analytics Terminal</span>
<span className="lp-live-badge"><span className="live-dot"/>LIVE</span>
</div>
<div className="lp-term-body">
{/* Pair row */}
<div className="lp-term-pair">
<span className="lp-tlabel">PAIR</span>
<span className="lp-tval" style={{color:'#00d4ff'}}>EURUSD</span>
<span className="lp-tlabel">SPOT</span>
<span className="lp-tval lp-mono">1.15274</span>
<span style={{marginLeft:'auto',fontSize:10,color:'#00ff88',fontFamily:'var(--font-mono)'}}> +0.04%</span>
</div>
{/* Chart */}
<div style={{margin:'10px 0 6px'}}>
<PreviewChart/>
</div>
{/* Greeks grid */}
<div className="lp-greeks">
{[
{l:'Δ Delta', v:'+0.8767', c:'#00d4ff'},
{l:'Γ Gamma', v:'+0.0284', c:'#a78bfa'},
{l:'ν Vega', v:'+0.2633', c:'#00ff88'},
{l:'Θ Theta', v:'0.0134', c:'#ff3d5a'},
].map(g => (
<div key={g.l} className="lp-greek-card">
<div className="lp-tlabel">{g.l}</div>
<div className="lp-mono lp-tval" style={{color:g.c,fontSize:13,fontWeight:700}}>{g.v}</div>
</div>
))}
</div>
{/* Signal */}
<div className="lp-signal-row">
<div className="signal-badge bullish" style={{fontSize:10,padding:'3px 10px',animation:'none'}}>BULLISH</div>
<span className="lp-tlabel" style={{marginLeft:8}}>Composite · 67% Confidence</span>
</div>
</div>
</div>
</section>
{/* ── Features ────────────────────────────── */}
<section className="lp-section" id="features">
<div className="lp-inner">
<div className="lp-sec-head">
<div className="lp-eyebrow lp-eyebrow-c">
<span className="lp-eyebrow-dot" aria-hidden/> 9 Analytics Modules
</div>
<h2 className="lp-h2">
Everything You Need for<br/>
<span className="lp-grad-text">Forex Options Risk</span>
</h2>
<p className="lp-sec-sub">
From individual option greeks to institutional positioning data
every tool in one professional platform.
</p>
</div>
<div className="lp-feat-grid">
{FEATURES.map(({ Icon, title, desc, color, tag }) => (
<div key={title} className="lp-feat-card">
<div className="lp-feat-top">
<div className="lp-feat-icon" style={{color}}>
<Icon/>
</div>
<span className="lp-feat-tag" style={{color, borderColor:`${color}40`}}>{tag}</span>
</div>
<h3 className="lp-feat-title">{title}</h3>
<p className="lp-feat-desc">{desc}</p>
</div>
))}
</div>
</div>
</section>
{/* ── How it works ────────────────────────── */}
<section className="lp-section lp-hiw">
<div className="lp-inner">
<div className="lp-sec-head">
<div className="lp-eyebrow lp-eyebrow-c">
<span className="lp-eyebrow-dot" aria-hidden/> Simple by Design
</div>
<h2 className="lp-h2">How It Works</h2>
</div>
<div className="lp-steps">
{[
{ n:'01', title:'Select your pair', desc:'Choose from 11 forex pairs. Live spot rates and market parameters populate automatically from Yahoo Finance.' },
{ n:'02', title:'Build your portfolio', desc:'Add option legs or pick from 16 pre-built strategies — long straddle, bull spread, iron condor, risk reversal, and more.' },
{ n:'03', title:'Analyze risk instantly', desc:'All 9 modules update in real-time. Greeks, signals, surfaces, scenarios — everything reacts to your portfolio instantly.' },
].map((s, i) => (
<div key={s.n} className="lp-step">
<div className="lp-step-n">{s.n}</div>
{i < 2 && <div className="lp-step-conn" aria-hidden/>}
<h3 className="lp-step-title">{s.title}</h3>
<p className="lp-step-desc">{s.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* ── Data Sources ────────────────────────── */}
<section className="lp-section">
<div className="lp-inner">
<div className="lp-sources">
<p className="lp-sources-label">Powered by trusted data sources</p>
<div className="lp-sources-row">
{[
{ name:'gs-quant', sub:'Goldman Sachs Quant Library', c:'#00d4ff' },
{ name:'CFTC', sub:'US Commodity Futures Trading Commission', c:'#a78bfa' },
{ name:'Forex Factory',sub:'Economic Calendar', c:'#ffd700' },
{ name:'yfinance', sub:'Yahoo Finance OHLCV Data', c:'#00ff88' },
].map(src => (
<div key={src.name} className="lp-source">
<div className="lp-source-name" style={{color:src.c}}>{src.name}</div>
<div className="lp-source-sub">{src.sub}</div>
</div>
))}
</div>
<p className="lp-sources-note">Free · No API keys · No registration</p>
</div>
</div>
</section>
{/* ── Final CTA ───────────────────────────── */}
<section className="lp-cta-wrap">
<div className="lp-cta-glow" aria-hidden/>
<div className="lp-inner lp-cta-inner">
<h2 className="lp-h2">
Start Analyzing<br/>
<span className="lp-grad-text">Forex Risk Today</span>
</h2>
<p className="lp-cta-sub">
Institutional-grade analytics, completely free.
No account, no API keys, no credit card.
</p>
<button className="lp-btn-primary lp-btn-lg" onClick={onEnter}>
<ArrowRight size={18}/> Launch Platform Free
</button>
<div className="lp-cta-checks">
<span> Garman-Kohlhagen model</span>
<span>·</span>
<span> Goldman Sachs gs-quant</span>
<span>·</span>
<span> Live CFTC data</span>
</div>
</div>
</section>
{/* ── Footer ──────────────────────────────── */}
<footer className="lp-footer">
<span className="lp-grad-text" style={{fontWeight:800,fontSize:12,letterSpacing:'0.06em'}}>QUANTRISK FX</span>
<span className="lp-footer-sep">·</span>
<span style={{fontSize:11,color:'#334155'}}>Professional Forex Options Analytics · Garman-Kohlhagen (1983)</span>
</footer>
</div>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { create } from 'zustand'
// Default interest rates per pair (r_d = domestic/quote, r_f = foreign/base)
export const PAIR_META = {
EURUSD: { r_d:0.0525, r_f:0.0400, pip:0.0001, defaultS:1.0850 },
GBPUSD: { r_d:0.0525, r_f:0.0525, pip:0.0001, defaultS:1.2700 },
USDJPY: { r_d:0.0010, r_f:0.0525, pip:0.01, defaultS:155.00 },
USDCHF: { r_d:0.0175, r_f:0.0525, pip:0.0001, defaultS:0.9050 },
AUDUSD: { r_d:0.0525, r_f:0.0435, pip:0.0001, defaultS:0.6550 },
USDCAD: { r_d:0.0500, r_f:0.0525, pip:0.0001, defaultS:1.3650 },
NZDUSD: { r_d:0.0525, r_f:0.0550, pip:0.0001, defaultS:0.6100 },
EURJPY: { r_d:0.0010, r_f:0.0400, pip:0.01, defaultS:168.00 },
GBPJPY: { r_d:0.0010, r_f:0.0525, pip:0.01, defaultS:197.00 },
EURGBP: { r_d:0.0525, r_f:0.0400, pip:0.0001, defaultS:0.8550 },
XAUUSD: { r_d:0.0525, r_f:0.0000, pip:0.01, defaultS:2350.0 },
}
const DEFAULT_PAIR = 'EURUSD'
const meta = PAIR_META[DEFAULT_PAIR]
const DEFAULT_LEGS = [
{ id:1, type:'call', K:1.09, T:0.25, qty: 1 },
{ id:2, type:'put', K:1.08, T:0.25, qty:-1 },
]
export const usePortfolioStore = create((set, get) => ({
pair: DEFAULT_PAIR,
S: meta.defaultS,
sigma: 0.07,
T: 0.25,
r_d: meta.r_d,
r_f: meta.r_f,
legs: DEFAULT_LEGS,
nextId: 3,
setPair: (pair) => {
const m = PAIR_META[pair] || meta
set({ pair, S: m.defaultS, r_d: m.r_d, r_f: m.r_f })
},
setS: (S) => set({ S: parseFloat(S) }),
setSigma: (sigma) => set({ sigma: parseFloat(sigma) }),
setT: (T) => set({ T: parseFloat(T) }),
setRd: (r_d) => set({ r_d: parseFloat(r_d) }),
setRf: (r_f) => set({ r_f: parseFloat(r_f) }),
addLeg: () => set(s => ({
legs: [...s.legs, { id:s.nextId, type:'call', K:parseFloat(s.S.toFixed(4)), T:s.T, qty:1 }],
nextId: s.nextId + 1,
})),
removeLeg: (id) => set(s => ({ legs: s.legs.filter(l => l.id !== id) })),
updateLeg: (id, field, value) => set(s => ({
legs: s.legs.map(l => l.id === id ? { ...l, [field]: value } : l),
})),
loadStrategy: (strategy) => set(s => ({
legs: strategy.legs.map((leg, i) => ({
id: i+1, type: leg.type,
K: parseFloat((s.S + (leg.K_offset || 0) * PAIR_META[s.pair]?.pip * 100 || 0).toFixed(5)),
T: s.T, qty: leg.qty,
})),
nextId: strategy.legs.length + 1,
})),
toShareURL: () => {
const s = get()
const params = new URLSearchParams({
pair: s.pair, S: s.S, sigma: s.sigma, T: s.T, r_d: s.r_d, r_f: s.r_f,
legs: JSON.stringify(s.legs.map(({ type,K,T,qty }) => ({ type,K,T,qty }))),
})
return `${window.location.origin}?${params}`
},
fromURL: () => {
const p = new URLSearchParams(window.location.search)
if (!p.has('legs')) return
try {
const pair = p.get('pair') || DEFAULT_PAIR
const legs = JSON.parse(p.get('legs')).map((l,i) => ({ ...l, id:i+1 }))
set({
pair, S: parseFloat(p.get('S') || PAIR_META[pair]?.defaultS || 1.0850),
sigma: parseFloat(p.get('sigma') || 0.07),
T: parseFloat(p.get('T') || 0.25),
r_d: parseFloat(p.get('r_d') || PAIR_META[pair]?.r_d || 0.0525),
r_f: parseFloat(p.get('r_f') || PAIR_META[pair]?.r_f || 0.04),
legs, nextId: legs.length + 1,
})
} catch {}
},
}))
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:8000',
},
},
})
+4
View File
@@ -0,0 +1,4 @@
numpy>=1.24,<3.0
scipy>=1.10,<2.0
matplotlib>=3.7,<4.0
pandas>=2.0,<4.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

+185
View File
@@ -0,0 +1,185 @@
"""
black_scholes.py Core Black-Scholes pricing and Greek calculations.
The Black-Scholes model prices European options under the assumptions of:
- Constant volatility and risk-free rate
- Log-normally distributed asset prices
- No dividends, no transaction costs
References: Black & Scholes (1973), Merton (1973)
"""
import numpy as np
from scipy.stats import norm
def _d1_d2(
S: float | np.ndarray,
K: float,
T: float,
r: float,
sigma: float,
) -> tuple[np.ndarray, np.ndarray]:
"""
Compute the d1 and d2 intermediate terms used throughout Black-Scholes.
d1 = [ln(S/K) + (r + σ²/2)·T] / (σ·T)
d2 = d1 σ·T
Args:
S: Spot price of the underlying asset.
K: Strike price of the option.
T: Time to expiry in years (must be > 0).
r: Annualised risk-free interest rate (decimal, e.g. 0.05 = 5 %).
sigma: Implied volatility (decimal, e.g. 0.20 = 20 %).
Returns:
Tuple (d1, d2) as numpy arrays.
Raises:
ValueError: If T <= 0 or sigma <= 0.
"""
S = np.asarray(S, dtype=float)
if np.any(T <= 0):
raise ValueError(f"Time to expiry T must be positive, got {T}.")
if np.any(sigma <= 0):
raise ValueError(f"Volatility sigma must be positive, got {sigma}.")
if np.any(S <= 0):
raise ValueError(f"Spot price S must be positive, got {S}.")
sqrt_T = np.sqrt(T)
d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrt_T)
d2 = d1 - sigma * sqrt_T
return d1, d2
def black_scholes_price(
S: float | np.ndarray,
K: float,
T: float,
r: float,
sigma: float,
option_type: str = "call",
) -> np.ndarray:
"""
Calculate the theoretical price of a European option via Black-Scholes.
Args:
S: Spot price of the underlying asset.
K: Strike price.
T: Time to expiry in years.
r: Risk-free rate (annualised, decimal).
sigma: Implied volatility (annualised, decimal).
option_type: "call" or "put".
Returns:
Option price as a numpy scalar or array matching the shape of S.
Raises:
ValueError: If option_type is not "call" or "put".
"""
option_type = option_type.lower()
if option_type not in ("call", "put"):
raise ValueError(f"option_type must be 'call' or 'put', got '{option_type}'.")
d1, d2 = _d1_d2(S, K, T, r, sigma)
if option_type == "call":
# C = S·N(d1) K·e^(rT)·N(d2)
return np.asarray(S) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
else:
# P = K·e^(rT)·N(d2) S·N(d1)
return K * np.exp(-r * T) * norm.cdf(-d2) - np.asarray(S) * norm.cdf(-d1)
def bs_delta(
S: float | np.ndarray,
K: float,
T: float,
r: float,
sigma: float,
option_type: str = "call",
) -> np.ndarray:
"""
Delta rate of change of option price with respect to the spot price.
Intuition: a delta of 0.6 means the option gains ~$0.60 for every $1 rise
in the underlying.
Call delta: N(d1) Range: [0, 1]
Put delta: N(d1) 1 Range: [1, 0]
"""
d1, _ = _d1_d2(S, K, T, r, sigma)
if option_type.lower() == "call":
return norm.cdf(d1)
return norm.cdf(d1) - 1.0
def bs_gamma(
S: float | np.ndarray,
K: float,
T: float,
r: float,
sigma: float,
) -> np.ndarray:
"""
Gamma rate of change of delta with respect to the spot price.
Same for calls and puts (by put-call parity).
Γ = N'(d1) / (S·σ·√T)
Intuition: high gamma means delta is unstable the option is most sensitive
near the strike as expiry approaches.
"""
d1, _ = _d1_d2(S, K, T, r, sigma)
return norm.pdf(d1) / (np.asarray(S) * sigma * np.sqrt(T))
def bs_vega(
S: float | np.ndarray,
K: float,
T: float,
r: float,
sigma: float,
) -> np.ndarray:
"""
Vega sensitivity of option price to a change in implied volatility.
ν = S·N'(d1)·√T
Result is in price-per-unit-vol; divide by 100 for price-per-1%-vol-move.
Intuition: a vega of 0.25 means the option gains $0.25 for every 1-point
rise in implied volatility. Same for calls and puts.
"""
d1, _ = _d1_d2(S, K, T, r, sigma)
return np.asarray(S) * norm.pdf(d1) * np.sqrt(T)
def bs_theta(
S: float | np.ndarray,
K: float,
T: float,
r: float,
sigma: float,
option_type: str = "call",
) -> np.ndarray:
"""
Theta rate of change of option price with respect to time (time decay).
Returned as price change per year; divide by 365 for per-day decay.
Call Θ = [S·N'(d1)·σ / (2√T)] r·K·e^(rT)·N(d2)
Put Θ = [S·N'(d1)·σ / (2√T)] + r·K·e^(rT)·N(d2)
Intuition: theta is almost always negative options lose value as time
passes, all else equal.
"""
d1, d2 = _d1_d2(S, K, T, r, sigma)
S = np.asarray(S)
decay = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T))
if option_type.lower() == "call":
return decay - r * K * np.exp(-r * T) * norm.cdf(d2)
return decay + r * K * np.exp(-r * T) * norm.cdf(-d2)
+41
View File
@@ -0,0 +1,41 @@
"""
config.py Central configuration and default parameters.
Change values here instead of hunting through the codebase.
"""
# ---------------------------------------------------------------------------
# Default market parameters
# ---------------------------------------------------------------------------
DEFAULT_SPOT: float = 100.0 # Starting underlying price (S₀)
DEFAULT_VOLATILITY: float = 0.20 # Implied volatility (σ), 20 %
DEFAULT_RISK_FREE_RATE: float = 0.01 # Annualised risk-free rate (r), 1 %
DEFAULT_TIME_TO_EXPIRY: float = 0.5 # Time to expiry in years (T)
# ---------------------------------------------------------------------------
# Surface scan ranges
# ---------------------------------------------------------------------------
SPOT_LOW: float = 80.0
SPOT_HIGH: float = 120.0
SPOT_STEPS: int = 50
VOL_LOW: float = 0.10
VOL_HIGH: float = 0.40
VOL_STEPS: int = 50
# ---------------------------------------------------------------------------
# Plot settings
# ---------------------------------------------------------------------------
FIGURE_SIZE: tuple = (8, 7)
SAVE_PLOTS: bool = True # Set False to skip saving PNG files
PLOT_OUTPUT_DIR: str = "plots" # Relative to the directory main.py is run from
# ---------------------------------------------------------------------------
# Example portfolio (list of option legs)
# Each leg: type ('call'|'put'), K (strike), T (expiry in years), qty (signed)
# ---------------------------------------------------------------------------
EXAMPLE_PORTFOLIO: list = [
{"type": "call", "K": 100.0, "T": 0.5, "qty": 2}, # Long 2 ATM calls
{"type": "put", "K": 95.0, "T": 0.5, "qty": -1}, # Short 1 OTM put
{"type": "call", "K": 110.0, "T": 0.5, "qty": -1}, # Short 1 OTM call (spread)
]
+56
View File
@@ -0,0 +1,56 @@
"""
greeks.py Portfolio-level Greek aggregation.
Wraps the single-option Greeks from black_scholes.py and aggregates them
across a multi-leg portfolio, respecting position quantities (signed).
"""
import numpy as np
from black_scholes import bs_delta, bs_gamma, bs_vega, bs_theta
def portfolio_greeks(
options: list[dict],
S: float | np.ndarray,
sigma: float,
T: float,
r: float,
) -> tuple[float, float, float, float]:
"""
Compute the net Delta, Gamma, Vega, and Theta for a portfolio of options.
Each option in the portfolio is a dict with keys:
type (str): "call" or "put"
K (float): strike price
T (float): time to expiry in years [overrides the shared T arg]
qty (int): signed quantity (positive = long, negative = short)
Args:
options: List of option leg dicts (see above).
S: Current spot price.
sigma: Implied volatility (shared across all legs).
T: Fallback time to expiry if a leg doesn't define its own.
r: Risk-free rate.
Returns:
Tuple (total_delta, total_gamma, total_vega, total_theta).
"""
total_delta = 0.0
total_gamma = 0.0
total_vega = 0.0
total_theta = 0.0
for opt in options:
opt_type: str = opt["type"]
K: float = float(opt["K"])
qty: float = float(opt["qty"])
# Allow per-leg expiry; fall back to the shared T
leg_T: float = float(opt.get("T", T))
total_delta += float(bs_delta(S, K, leg_T, r, sigma, opt_type)) * qty
total_gamma += float(bs_gamma(S, K, leg_T, r, sigma)) * qty
total_vega += float(bs_vega(S, K, leg_T, r, sigma)) * qty
total_theta += float(bs_theta(S, K, leg_T, r, sigma, opt_type)) * qty
return total_delta, total_gamma, total_vega, total_theta
+115
View File
@@ -0,0 +1,115 @@
"""
main.py Entry point for the Delta-Gamma Risk Surface Bot.
Usage (from the repo root):
cd src
python main.py
What it does:
1. Defines an example multi-leg option portfolio.
2. Computes a summary table of Greeks at the current spot price.
3. Scans across a spot-price × volatility grid and builds five 3-D surfaces:
Delta, Gamma, Vega, Theta, and P&L.
4. Displays all surfaces interactively and saves them as timestamped PNGs.
"""
import sys
import os
import numpy as np
# Make sure sibling modules are importable when run from inside src/
sys.path.insert(0, os.path.dirname(__file__))
from config import (
DEFAULT_SPOT,
DEFAULT_VOLATILITY,
DEFAULT_RISK_FREE_RATE,
DEFAULT_TIME_TO_EXPIRY,
SPOT_LOW, SPOT_HIGH, SPOT_STEPS,
VOL_LOW, VOL_HIGH, VOL_STEPS,
FIGURE_SIZE,
SAVE_PLOTS,
PLOT_OUTPUT_DIR,
EXAMPLE_PORTFOLIO,
)
from greeks import portfolio_greeks
from surface import risk_surface, pnl_surface
from visualization import plot_all_surfaces
def print_summary_table(
options: list[dict],
S0: float,
sigma0: float,
T: float,
r: float,
) -> None:
"""
Print a formatted table of portfolio Greeks at the current spot price.
Args:
options: List of option legs.
S0: Current spot price.
sigma0: Current implied volatility.
T: Time to expiry in years.
r: Risk-free rate.
"""
delta, gamma, vega, theta = portfolio_greeks(options, S0, sigma0, T, r)
print("\n" + "=" * 52)
print(" PORTFOLIO GREEK SUMMARY (at current spot)")
print("=" * 52)
print(f" Spot price : ${S0:.2f}")
print(f" Implied vol : {sigma0 * 100:.1f}%")
print(f" Time to expiry : {T:.2f} years")
print(f" Risk-free rate : {r * 100:.1f}%")
print("-" * 52)
print(f" Delta : {delta:+.4f}")
print(f" Gamma : {gamma:+.6f}")
print(f" Vega : {vega:+.4f} (per unit vol)")
print(f" Theta : {theta:+.4f} (per year)")
print(f" Theta (daily) : {theta / 365:+.4f} (per calendar day)")
print("=" * 52 + "\n")
def main() -> None:
"""
Orchestrate the full analysis pipeline.
"""
portfolio = EXAMPLE_PORTFOLIO
S0 = DEFAULT_SPOT
sigma0 = DEFAULT_VOLATILITY
T = DEFAULT_TIME_TO_EXPIRY
r = DEFAULT_RISK_FREE_RATE
# --- Step 1: Console summary at current market params -----------------------
print_summary_table(portfolio, S0, sigma0, T, r)
# --- Step 2: Build scan grids -----------------------------------------------
S_range = np.linspace(SPOT_LOW, SPOT_HIGH, SPOT_STEPS)
sigma_range = np.linspace(VOL_LOW, VOL_HIGH, VOL_STEPS)
# Meshgrid for plotting (X = spot, Y = vol)
X, Y = np.meshgrid(S_range, sigma_range, indexing="ij")
# --- Step 3: Compute surfaces -----------------------------------------------
print("Computing risk surfaces ... ", end="", flush=True)
Delta, Gamma, Vega, Theta = risk_surface(portfolio, S_range, sigma_range, T, r)
PnL = pnl_surface(portfolio, S_range, sigma_range, T, r, S0, sigma0)
print("done.")
# --- Step 4: Plot -----------------------------------------------------------
save_dir = PLOT_OUTPUT_DIR if SAVE_PLOTS else None
if save_dir:
print(f"Saving plots to: {os.path.abspath(save_dir)}/")
plot_all_surfaces(
X, Y, Delta, Gamma, Vega, Theta, PnL,
fig_size=FIGURE_SIZE,
save_dir=save_dir,
)
if __name__ == "__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
"""
surface.py Build 2-D risk surfaces across spot-price × volatility space.
Each surface is a 2-D numpy array indexed by [spot_index, vol_index].
The caller creates the meshgrid axes (S_range, sigma_range) and passes them in.
"""
import numpy as np
from black_scholes import black_scholes_price
from greeks import portfolio_greeks
def risk_surface(
options: list[dict],
S_range: np.ndarray,
sigma_range: np.ndarray,
T: float,
r: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
Compute Delta, Gamma, Vega, and Theta surfaces over a grid of S and σ.
Args:
options: Portfolio legs (see greeks.portfolio_greeks for schema).
S_range: 1-D array of spot prices to scan.
sigma_range: 1-D array of implied volatilities to scan.
T: Shared time to expiry in years.
r: Risk-free rate.
Returns:
Four 2-D arrays (Delta, Gamma, Vega, Theta), each shaped
(len(S_range), len(sigma_range)).
"""
n_s = len(S_range)
n_v = len(sigma_range)
Delta_surface = np.zeros((n_s, n_v))
Gamma_surface = np.zeros((n_s, n_v))
Vega_surface = np.zeros((n_s, n_v))
Theta_surface = np.zeros((n_s, n_v))
for i, S in enumerate(S_range):
for j, sigma in enumerate(sigma_range):
d, g, v, th = portfolio_greeks(options, S, sigma, T, r)
Delta_surface[i, j] = d
Gamma_surface[i, j] = g
Vega_surface[i, j] = v
Theta_surface[i, j] = th
return Delta_surface, Gamma_surface, Vega_surface, Theta_surface
def pnl_surface(
options: list[dict],
S_range: np.ndarray,
sigma_range: np.ndarray,
T: float,
r: float,
S0: float,
sigma0: float,
) -> np.ndarray:
"""
Approximate P&L surface using a second-order Taylor expansion.
P&L Δ·ΔS + 0.5·Γ·(ΔS)²
where Δ and Γ are evaluated at the base point (S0, sigma0).
This is the standard "Delta-Gamma" approximation used in risk management.
Args:
options: Portfolio legs.
S_range: 1-D array of spot prices to scan.
sigma_range: 1-D array of implied volatilities to scan.
T: Time to expiry in years.
r: Risk-free rate.
S0: Current / reference spot price.
sigma0: Current / reference volatility.
Returns:
2-D P&L array shaped (len(S_range), len(sigma_range)).
"""
base_delta, base_gamma, _, _ = portfolio_greeks(options, S0, sigma0, T, r)
PnL = np.zeros((len(S_range), len(sigma_range)))
for i, S in enumerate(S_range):
dS = S - S0
# Taylor: P&L ≈ Δ·dS + 0.5·Γ·dS²
PnL[i, :] = base_delta * dS + 0.5 * base_gamma * dS ** 2
return PnL
+111
View File
@@ -0,0 +1,111 @@
"""
visualization.py 3-D surface plots for all five risk surfaces.
Each plot uses a dark background and a distinct colormap so the surfaces
are visually distinguishable at a glance. Optionally saves PNG files
with ISO-8601 timestamps so successive runs never overwrite each other.
"""
import os
from datetime import datetime
import matplotlib.pyplot as plt
import numpy as np
from mpl_toolkits.mplot3d import Axes3D # noqa: F401 — registers 3-D projection
# Colormap and label config for each surface type
_SURFACE_CONFIG: dict[str, dict] = {
"Delta": {"cmap": "plasma", "zlabel": "Delta", "title": "Delta Surface"},
"Gamma": {"cmap": "cividis", "zlabel": "Gamma", "title": "Gamma Surface"},
"Vega": {"cmap": "viridis", "zlabel": "Vega", "title": "Vega Surface"},
"Theta": {"cmap": "inferno", "zlabel": "Theta / yr", "title": "Theta Surface"},
"PnL": {"cmap": "magma", "zlabel": "P&L ($)", "title": "Risk (P&L) Surface"},
}
def _plot_single_surface(
X: np.ndarray,
Y: np.ndarray,
Z: np.ndarray,
name: str,
fig_size: tuple[int, int] = (8, 7),
save_dir: str | None = None,
) -> None:
"""
Render one 3-D surface plot and optionally save it as a PNG.
Args:
X: Meshgrid of spot prices (shape: n×m).
Y: Meshgrid of implied volatilities (shape: n×m).
Z: Surface values (shape: n×m).
name: Key into _SURFACE_CONFIG (e.g. "Delta").
fig_size: Figure size in inches.
save_dir: If provided, save the PNG to this directory.
"""
cfg = _SURFACE_CONFIG[name]
fig = plt.figure(figsize=fig_size)
ax = fig.add_subplot(111, projection="3d")
surf = ax.plot_surface(X, Y, Z, cmap=cfg["cmap"], edgecolor="none", alpha=0.95)
# Floor contour projection for depth perception
ax.contourf(X, Y, Z, zdir="z", offset=float(Z.min()), cmap=cfg["cmap"], alpha=0.45)
ax.set_xlabel("Underlying Price ($)", fontsize=11)
ax.set_ylabel("Implied Volatility", fontsize=11)
ax.set_zlabel(cfg["zlabel"], fontsize=11)
ax.set_title(cfg["title"], fontsize=13, pad=12)
fig.colorbar(surf, ax=ax, shrink=0.6, aspect=10)
fig.tight_layout()
if save_dir is not None:
os.makedirs(save_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = os.path.join(save_dir, f"{name}_{timestamp}.png")
fig.savefig(filename, dpi=150, bbox_inches="tight")
print(f" [saved] {filename}")
def plot_all_surfaces(
X: np.ndarray,
Y: np.ndarray,
Delta: np.ndarray,
Gamma: np.ndarray,
Vega: np.ndarray,
Theta: np.ndarray,
PnL: np.ndarray,
fig_size: tuple[int, int] = (8, 7),
save_dir: str | None = None,
) -> None:
"""
Plot all five risk surfaces: Delta, Gamma, Vega, Theta, and P&L.
Args:
X: Meshgrid of spot prices.
Y: Meshgrid of implied volatilities.
Delta: Delta surface array.
Gamma: Gamma surface array.
Vega: Vega surface array.
Theta: Theta surface array.
PnL: P&L surface array.
fig_size: (width, height) in inches for each figure.
save_dir: Directory to save PNGs; None = display only.
"""
plt.style.use("dark_background")
surfaces = {
"Delta": Delta,
"Gamma": Gamma,
"Vega": Vega,
"Theta": Theta,
"PnL": PnL,
}
for name, Z in surfaces.items():
_plot_single_surface(X, Y, Z, name, fig_size=fig_size, save_dir=save_dir)
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
plt.show()