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>
@@ -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.')
|
||||
"
|
||||
@@ -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
|
||||
@@ -0,0 +1,202 @@
|
||||
<div align="center">
|
||||
|
||||
# ⚛ NEXUS TERMINAL
|
||||
|
||||
### Institutional-Grade FX Options Analytics Platform
|
||||
|
||||
[](https://python.org)
|
||||
[](https://fastapi.tiangolo.com)
|
||||
[](https://react.dev)
|
||||
[](https://vitejs.dev)
|
||||
[](LICENSE)
|
||||
|
||||
*Three orbital rings. One terminal. Every market.*
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -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))
|
||||
@@ -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), 231–237.
|
||||
|
||||
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))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)",
|
||||
},
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
],
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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."}
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
])
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (0–100 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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='© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 {}
|
||||
},
|
||||
}))
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
numpy>=1.24,<3.0
|
||||
scipy>=1.10,<2.0
|
||||
matplotlib>=3.7,<4.0
|
||||
pandas>=2.0,<4.0
|
||||
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 222 KiB |
@@ -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)
|
||||
@@ -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)
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||