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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Kansaram
2026-06-07 18:19:23 +05:30
commit 61e145a442
75 changed files with 13323 additions and 0 deletions
View File
View File
+49
View File
@@ -0,0 +1,49 @@
import numpy as np
from scipy.stats import norm
def _d1_d2(S, K, T, r, sigma):
S = np.asarray(S, dtype=float)
sqrt_T = np.sqrt(T)
d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * sqrt_T)
d2 = d1 - sigma * sqrt_T
return d1, d2
def bs_price(S, K, T, r, sigma, option_type="call"):
d1, d2 = _d1_d2(S, K, T, r, sigma)
if option_type == "call":
return float(np.asarray(S) * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2))
return float(K * np.exp(-r * T) * norm.cdf(-d2) - np.asarray(S) * norm.cdf(-d1))
def bs_delta(S, K, T, r, sigma, option_type="call"):
d1, _ = _d1_d2(S, K, T, r, sigma)
return float(norm.cdf(d1) if option_type == "call" else norm.cdf(d1) - 1.0)
def bs_gamma(S, K, T, r, sigma):
d1, _ = _d1_d2(S, K, T, r, sigma)
return float(norm.pdf(d1) / (np.asarray(S) * sigma * np.sqrt(T)))
def bs_vega(S, K, T, r, sigma):
d1, _ = _d1_d2(S, K, T, r, sigma)
return float(np.asarray(S) * norm.pdf(d1) * np.sqrt(T))
def bs_theta(S, K, T, r, sigma, option_type="call"):
d1, d2 = _d1_d2(S, K, T, r, sigma)
S = np.asarray(S)
decay = -(S * norm.pdf(d1) * sigma) / (2 * np.sqrt(T))
if option_type == "call":
return float(decay - r * K * np.exp(-r * T) * norm.cdf(d2))
return float(decay + r * K * np.exp(-r * T) * norm.cdf(-d2))
def bs_rho(S, K, T, r, sigma, option_type="call"):
"""Rho — sensitivity to interest rate changes."""
_, d2 = _d1_d2(S, K, T, r, sigma)
if option_type == "call":
return float(K * T * np.exp(-r * T) * norm.cdf(d2))
return float(-K * T * np.exp(-r * T) * norm.cdf(-d2))
+105
View File
@@ -0,0 +1,105 @@
"""
garman_kohlhagen.py — Garman-Kohlhagen (1983) model for European forex options.
Extension of Black-Scholes that accounts for BOTH the domestic and foreign
risk-free interest rates — essential for currency options pricing.
Reference: Garman, M.B. & Kohlhagen, S.W. (1983). "Foreign currency option values."
Journal of International Money and Finance, 2(3), 231237.
Model:
d1 = [ln(S/K) + (r_d r_f + σ²/2)·T] / (σ·√T)
d2 = d1 σ·√T
C = S·e^(r_f·T)·N(d1) K·e^(r_d·T)·N(d2)
P = K·e^(r_d·T)·N(d2) S·e^(r_f·T)·N(d1)
"""
import numpy as np
from scipy.stats import norm
def _d1_d2(S: float, K: float, T: float, r_d: float, r_f: float, sigma: float):
"""Compute GK d1 and d2 intermediate values."""
S = np.asarray(S, dtype=float)
sqrt_T = np.sqrt(T)
d1 = (np.log(S / K) + (r_d - r_f + 0.5 * sigma ** 2) * T) / (sigma * sqrt_T)
d2 = d1 - sigma * sqrt_T
return d1, d2
def gk_price(S, K, T, r_d, r_f, sigma, option_type="call"):
d1, d2 = _d1_d2(S, K, T, r_d, r_f, sigma)
S = np.asarray(S, dtype=float)
if option_type == "call":
return float(S * np.exp(-r_f * T) * norm.cdf(d1) - K * np.exp(-r_d * T) * norm.cdf(d2))
return float(K * np.exp(-r_d * T) * norm.cdf(-d2) - S * np.exp(-r_f * T) * norm.cdf(-d1))
def gk_delta(S, K, T, r_d, r_f, sigma, option_type="call"):
"""
Delta — sensitivity of option price to spot rate change.
Call: e^(r_f·T)·N(d1) Put: e^(r_f·T)·N(d1)
"""
d1, _ = _d1_d2(S, K, T, r_d, r_f, sigma)
factor = np.exp(-r_f * T)
if option_type == "call":
return float(factor * norm.cdf(d1))
return float(-factor * norm.cdf(-d1))
def gk_gamma(S, K, T, r_d, r_f, sigma):
"""
Gamma — rate of change of delta.
Γ = e^(r_f·T)·N'(d1) / (S·σ·√T) (same sign for calls and puts)
"""
d1, _ = _d1_d2(S, K, T, r_d, r_f, sigma)
return float(np.exp(-r_f * T) * norm.pdf(d1) / (np.asarray(S) * sigma * np.sqrt(T)))
def gk_vega(S, K, T, r_d, r_f, sigma):
"""
Vega — sensitivity to implied volatility.
ν = S·e^(r_f·T)·N'(d1)·√T (same for calls and puts)
"""
d1, _ = _d1_d2(S, K, T, r_d, r_f, sigma)
return float(np.asarray(S) * np.exp(-r_f * T) * norm.pdf(d1) * np.sqrt(T))
def gk_theta(S, K, T, r_d, r_f, sigma, option_type="call"):
"""
Theta — time decay (per year).
Call: S·σ·e^(r_f·T)·N'(d1)/(2√T) r_d·K·e^(r_d·T)·N(d2) + r_f·S·e^(r_f·T)·N(d1)
Put: S·σ·e^(r_f·T)·N'(d1)/(2√T) + r_d·K·e^(r_d·T)·N(d2) r_f·S·e^(r_f·T)·N(d1)
"""
d1, d2 = _d1_d2(S, K, T, r_d, r_f, sigma)
S = np.asarray(S, dtype=float)
decay = -(S * sigma * np.exp(-r_f * T) * norm.pdf(d1)) / (2 * np.sqrt(T))
if option_type == "call":
return float(decay - r_d * K * np.exp(-r_d * T) * norm.cdf(d2)
+ r_f * S * np.exp(-r_f * T) * norm.cdf(d1))
return float(decay + r_d * K * np.exp(-r_d * T) * norm.cdf(-d2)
- r_f * S * np.exp(-r_f * T) * norm.cdf(-d1))
def gk_rho_d(S, K, T, r_d, r_f, sigma, option_type="call"):
"""
Rho_d — sensitivity to DOMESTIC interest rate.
Call: K·T·e^(r_d·T)·N(d2) Put: K·T·e^(r_d·T)·N(d2)
"""
_, d2 = _d1_d2(S, K, T, r_d, r_f, sigma)
factor = K * T * np.exp(-r_d * T)
if option_type == "call":
return float(factor * norm.cdf(d2))
return float(-factor * norm.cdf(-d2))
def gk_phi(S, K, T, r_d, r_f, sigma, option_type="call"):
"""
Phi (ρ_f) — sensitivity to FOREIGN interest rate.
Call: S·T·e^(r_f·T)·N(d1) Put: S·T·e^(r_f·T)·N(d1)
"""
d1, _ = _d1_d2(S, K, T, r_d, r_f, sigma)
factor = np.asarray(S) * T * np.exp(-r_f * T)
if option_type == "call":
return float(-factor * norm.cdf(d1))
return float(factor * norm.cdf(-d1))
+41
View File
@@ -0,0 +1,41 @@
"""greeks.py — Portfolio-level Greek aggregation using Garman-Kohlhagen model."""
from .garman_kohlhagen import (
gk_delta, gk_gamma, gk_vega, gk_theta, gk_rho_d, gk_phi
)
def portfolio_greeks(
options: list[dict], S: float, sigma: float,
T: float, r_d: float, r_f: float
) -> dict:
"""Aggregate GK Greeks across all legs of a forex options portfolio."""
total = {"delta": 0.0, "gamma": 0.0, "vega": 0.0,
"theta": 0.0, "rho_d": 0.0, "phi": 0.0}
legs = []
for opt in options:
otype = opt["type"]
K, qty = float(opt["K"]), float(opt["qty"])
leg_T = float(opt.get("T", T))
d = gk_delta(S, K, leg_T, r_d, r_f, sigma, otype) * qty
g = gk_gamma(S, K, leg_T, r_d, r_f, sigma) * qty
v = gk_vega(S, K, leg_T, r_d, r_f, sigma) * qty
th = gk_theta(S, K, leg_T, r_d, r_f, sigma, otype) * qty
rho = gk_rho_d(S, K, leg_T, r_d, r_f, sigma, otype) * qty
phi = gk_phi(S, K, leg_T, r_d, r_f, sigma, otype) * qty
total["delta"] += d; total["gamma"] += g; total["vega"] += v
total["theta"] += th; total["rho_d"] += rho; total["phi"] += phi
legs.append({
"label": f"{'+'if qty>0 else ''}{int(qty)} {otype.upper()} K={K}",
"delta": round(d, 5), "gamma": round(g, 7),
"vega": round(v, 4), "theta": round(th, 4),
})
return {
"total": {k: round(v, 6) for k, v in total.items()},
"legs": legs,
}
+44
View File
@@ -0,0 +1,44 @@
"""montecarlo.py — GBM Monte Carlo for forex options (uses GK cost basis)."""
import numpy as np
from .garman_kohlhagen import gk_price
def run_montecarlo(options, S0, sigma, r_d, r_f, T, n_paths=1000, n_steps=100):
"""Simulate GBM price paths and compute P&L distribution at expiry."""
dt = T / n_steps
Z = np.random.standard_normal((n_paths, n_steps))
paths = np.zeros((n_paths, n_steps + 1)); paths[:,0] = S0
# GBM: dS = (r_d - r_f)·S·dt + σ·S·dW (Garman-Kohlhagen drift)
for t in range(1, n_steps + 1):
paths[:,t] = paths[:,t-1] * np.exp(
(r_d - r_f - 0.5*sigma**2)*dt + sigma*np.sqrt(dt)*Z[:,t-1]
)
terminal = paths[:,-1]
pnl = np.zeros(n_paths)
for opt in options:
K, qty = float(opt["K"]), float(opt["qty"])
payoff = (np.maximum(terminal - K, 0) if opt["type"]=="call"
else np.maximum(K - terminal, 0))
pnl += payoff * qty
# Subtract initial GK cost
cost = sum(
gk_price(S0, float(o["K"]), float(o.get("T",T)), r_d, r_f, sigma, o["type"])
* float(o["qty"]) for o in options
)
pnl -= cost
idx = np.random.choice(n_paths, size=min(60, n_paths), replace=False)
return {
"time_axis": [round(i*dt, 4) for i in range(n_steps+1)],
"sample_paths": paths[idx].tolist(),
"pnl": pnl.tolist(),
"pnl_mean": round(float(pnl.mean()), 5),
"pnl_std": round(float(pnl.std()), 5),
"pnl_5pct": round(float(np.percentile(pnl, 5)), 5),
"pnl_95pct": round(float(np.percentile(pnl, 95)), 5),
"prob_profit": round(float((pnl > 0).mean()), 4),
}
+274
View File
@@ -0,0 +1,274 @@
"""
quant_analysis.py — Quantitative signals for forex pairs.
Core analytics powered by:
• gs-quant (Goldman Sachs, 2024) — volatility, RSI, MACD, Bollinger Bands,
max drawdown, z-scores, rolling statistics
• Custom implementations for models not in gs-quant:
1. EWMA Vol Forecast — RiskMetrics λ=0.94 (JP Morgan, 1994)
2. VaR / CVaR — Parametric Normal + Historical ES (Basel III)
3. Hurst Exponent — Variance-scaling (Hurst 1951)
4. Ornstein-Uhlenbeck — OLS AR(1) (Uhlenbeck & Ornstein 1930)
5. Carry Signal — Uncovered Interest Parity (Fama 1984)
6. Momentum — Price momentum (Jegadeesh & Titman 1993)
"""
import numpy as np
import pandas as pd
from scipy import stats
# GS-Quant timeseries — all work offline, no credentials required
from gs_quant.timeseries import econometrics as gseco
from gs_quant.timeseries import statistics as gsstat
from gs_quant.timeseries import technicals as gstech
# ─── helpers ──────────────────────────────────────────────────────────────────
def _to_series(prices) -> pd.Series:
arr = np.array(prices, dtype=float)
idx = pd.date_range(end=pd.Timestamp.today().normalize(), periods=len(arr), freq='D')
return pd.Series(arr, index=idx)
def _safe_last(series: pd.Series, default=0.0) -> float:
try:
v = series.dropna()
return float(v.iloc[-1]) if len(v) else default
except Exception:
return default
def _hurst_exponent(prices: np.ndarray) -> float:
"""Variance-scaling H estimator: Var[ΔX_τ] ~ τ^(2H). (Hurst 1951)"""
prices = np.array(prices, dtype=float)
max_lag = min(len(prices) // 3, 30)
if max_lag < 3:
return 0.5
lags = range(2, max_lag)
variances = [np.var(np.diff(prices, n=lag)) for lag in lags]
try:
slope, *_ = stats.linregress(np.log(list(lags)), np.log(variances))
return float(np.clip(slope / 2, 0.01, 0.99))
except Exception:
return 0.5
def _estimate_ou_params(prices: np.ndarray) -> dict:
"""OLS AR(1) → OU parameters. (Uhlenbeck & Ornstein 1930)"""
X = np.array(prices, dtype=float)
Xl, Xc = X[:-1], X[1:]
n = len(Xl)
b_num = n * np.dot(Xl, Xc) - Xl.sum() * Xc.sum()
b_den = n * np.dot(Xl, Xl) - Xl.sum() ** 2
b = float(np.clip(b_num / b_den if b_den else 0.999, 0.001, 0.9999))
a = float(Xc.mean() - b * Xl.mean())
resid_sigma = float(np.std(Xc - (a + b * Xl)) * np.sqrt(252))
kappa = float(-np.log(b) * 252)
theta = float(a / (1 - b))
half_life = float(np.log(2) / max(kappa, 1e-6) / 252 * 365)
return {"kappa": round(kappa, 4), "theta": round(theta, 6),
"sigma": round(resid_sigma, 6), "half_life_days": round(half_life, 1)}
# ─── main signal engine ───────────────────────────────────────────────────────
def compute_signals(prices: list, r_d: float = 0.05, r_f: float = 0.04) -> dict:
"""
Full quant signal suite — core analytics via gs-quant (Goldman Sachs),
extended with OU, Hurst, VaR and carry trade signals.
"""
px_raw = np.array(prices, dtype=float)
n = len(px_raw)
if n < 10:
return {"error": "Need ≥10 observations."}
px = _to_series(px_raw)
w20 = min(20, n)
w60 = min(60, n)
rets_raw = np.diff(np.log(px_raw))
# ── 1. Volatility (gs-quant econometrics.volatility) ────────────────────
# GS implementation: annualized realized vol over rolling window
hv20_s = gseco.volatility(px, w20)
hv60_s = gseco.volatility(px, w60)
hv20 = _safe_last(hv20_s, np.std(rets_raw[-w20:]) * np.sqrt(252))
hv60 = _safe_last(hv60_s, np.std(rets_raw[-w60:]) * np.sqrt(252))
vol_regime = ("HIGH" if hv20 > hv60 * 1.25 else
"LOW" if hv20 < hv60 * 0.80 else "NORMAL")
# EWMA forecast (RiskMetrics λ=0.94, JP Morgan 1994)
lam, ewma_var = 0.94, float(np.var(rets_raw[-w20:]))
for r in rets_raw[-w20:]:
ewma_var = lam * ewma_var + (1 - lam) * r ** 2
ewma_vol = float(np.sqrt(ewma_var * 252))
# GS max-drawdown (institutional risk metric)
dd_s = gseco.max_drawdown(px, w60)
max_dd = _safe_last(dd_s, -0.01)
# ── 2. VaR / CVaR (custom — Parametric Normal + Basel III) ─────────────
mu_d, sig_d = float(np.mean(rets_raw[-w20:])), float(np.std(rets_raw[-w20:]))
var95 = float(-(mu_d + sig_d * stats.norm.ppf(0.05)))
var99 = float(-(mu_d + sig_d * stats.norm.ppf(0.01)))
tail = np.sort(rets_raw[-w60:])[:max(1, int(0.05 * w60))]
cvar95 = float(-np.mean(tail))
# ── 3. Hurst Exponent (custom — Hurst 1951) ─────────────────────────────
hurst = _hurst_exponent(px_raw[-w60:])
hurst_regime = ("MEAN-REVERTING" if hurst < 0.45 else
"TRENDING" if hurst > 0.55 else "RANDOM WALK")
hurst_action = {"MEAN-REVERTING": "FADE extreme moves — OU strategies apply",
"TRENDING": "FOLLOW momentum — trend-following applies",
"RANDOM WALK": "No structural edge — vol strategies apply"}[hurst_regime]
# ── 4. Ornstein-Uhlenbeck (custom — Uhlenbeck & Ornstein 1930) ──────────
ou = _estimate_ou_params(px_raw[-w60:])
ou_std = ou["sigma"] / np.sqrt(max(ou["kappa"], 0.01) * 252)
# Z-score via gs-quant statistics (institutional grade)
z_s = gsstat.zscores(px, w20)
gs_z = _safe_last(z_s, 0.0)
ou_z = float((px_raw[-1] - ou["theta"]) / max(ou_std, 1e-9))
ou_signal = "SELL" if ou_z > 2 else ("BUY" if ou_z < -2 else "NEUTRAL")
ou_conf = min(92, 50 + int(abs(ou_z) * 15)) if ou_signal != "NEUTRAL" else 40
# ── 5. RSI (gs-quant technicals.relative_strength_index) ────────────────
rsi_s = gstech.relative_strength_index(px, 14)
rsi = _safe_last(rsi_s, 50.0)
rsi_signal = ("OVERBOUGHT" if rsi > 70 else
"OVERSOLD" if rsi < 30 else "NEUTRAL")
rsi_bias = ("BEARISH" if rsi > 70 else
"BULLISH" if rsi < 30 else "NEUTRAL")
# ── 6. MACD (gs-quant technicals.macd) ──────────────────────────────────
macd_s = gstech.macd(px)
macd = _safe_last(macd_s, 0.0)
macd_signal = "BULLISH" if macd > 0 else "BEARISH"
# ── 7. Bollinger Bands (gs-quant technicals.bollinger_bands) ────────────
bb_s = gstech.bollinger_bands(px, w20)
current_px = float(px_raw[-1])
sma_s = gstech.moving_average(px, w20)
sma = _safe_last(sma_s, current_px)
std_s = gsstat.std(px, w20)
gsstd = _safe_last(std_s, float(np.std(px_raw[-w20:])))
bb_upper = sma + 2.0 * gsstd
bb_lower = sma - 2.0 * gsstd
bb_pct = float((current_px - bb_lower) / max(bb_upper - bb_lower, 1e-9)) # 0=lower,1=upper
bb_signal = ("NEAR UPPER BAND" if bb_pct > 0.85 else
"NEAR LOWER BAND" if bb_pct < 0.15 else "MID BAND")
bb_bias = ("BEARISH" if bb_pct > 0.85 else
"BULLISH" if bb_pct < 0.15 else "NEUTRAL")
# ── 8. Momentum (Jegadeesh & Titman 1993) ───────────────────────────────
ret5 = float(px_raw[-1] / px_raw[max(-5, -n)] - 1) if n >= 5 else 0.0
ret20 = float(px_raw[-1] / px_raw[max(-20, -n)] - 1) if n >= 20 else 0.0
mom_signal = ("BULLISH" if ret5 > 0 and ret20 > 0 else
"BEARISH" if ret5 < 0 and ret20 < 0 else "MIXED")
# ── 9. Carry (Uncovered Interest Parity, Fama 1984) ─────────────────────
carry_diff = r_d - r_f
carry_signal = ("BUY BASE" if carry_diff > 0.005 else
"SELL BASE" if carry_diff < -0.005 else "NEUTRAL")
# ── Composite signal (8 inputs, gs-quant enhanced) ────────────────────────
bull = sum([
ret5 > 0, ret20 > 0,
ou_signal == "BUY",
carry_signal == "BUY BASE",
macd_signal == "BULLISH",
rsi_bias == "BULLISH",
bb_bias == "BULLISH",
hurst_regime == "TRENDING" and ret5 > 0,
])
bear = sum([
ret5 < 0, ret20 < 0,
ou_signal == "SELL",
carry_signal == "SELL BASE",
macd_signal == "BEARISH",
rsi_bias == "BEARISH",
bb_bias == "BEARISH",
hurst_regime == "TRENDING" and ret5 < 0,
])
if bull >= bear + 3:
composite, conf = "BULLISH", min(95, 50 + bull * 6)
elif bear >= bull + 3:
composite, conf = "BEARISH", min(95, 50 + bear * 6)
elif bull > bear:
composite, conf = "BULLISH", min(70, 50 + bull * 4)
elif bear > bull:
composite, conf = "BEARISH", min(70, 50 + bear * 4)
else:
composite, conf = "NEUTRAL", 45
return {
"composite": composite,
"confidence": conf,
"n_observations": n,
"powered_by": "gs-quant (Goldman Sachs) + custom quant models",
"volatility": {
"hv_20_pct": round(hv20, 2),
"hv_60_pct": round(hv60, 2),
"regime": vol_regime,
"ewma_forecast_pct": round(ewma_vol * 100, 2),
"max_drawdown_pct": round(max_dd * 100, 2),
"method": "gs-quant econometrics.volatility() + EWMA λ=0.94 (RiskMetrics 1994)",
},
"risk": {
"var_95_pct": round(var95 * 100, 3),
"var_99_pct": round(var99 * 100, 3),
"cvar_95_pct": round(cvar95 * 100, 3),
"method": "Parametric Normal VaR / Historical CVaR (Basel III)",
},
"hurst": {
"exponent": round(hurst, 3),
"regime": hurst_regime,
"action": hurst_action,
"method": "Variance-scaling estimator (Hurst 1951)",
},
"mean_reversion": {
"kappa": ou["kappa"],
"theta": ou["theta"],
"half_life_days": ou["half_life_days"],
"zscore": round(ou_z, 2),
"gs_zscore": round(gs_z, 2),
"signal": ou_signal,
"confidence": ou_conf,
"method": "OLS AR(1) → OU SDE (Uhlenbeck & Ornstein 1930) + gs-quant zscores",
},
"rsi": {
"value": round(rsi, 2),
"signal": rsi_signal,
"bias": rsi_bias,
"method": "gs-quant technicals.relative_strength_index(14) (Wilder 1978)",
},
"macd": {
"value": round(macd, 6),
"signal": macd_signal,
"method": "gs-quant technicals.macd() — 12/26/9 EMA crossover (Appel 1979)",
},
"bollinger": {
"upper": round(bb_upper, 5),
"lower": round(bb_lower, 5),
"sma": round(sma, 5),
"pct_b": round(bb_pct, 3),
"signal": bb_signal,
"bias": bb_bias,
"method": "gs-quant technicals.bollinger_bands(20,2σ) (Bollinger 1983)",
},
"momentum": {
"return_5d_pct": round(ret5 * 100, 3),
"return_20d_pct": round(ret20 * 100, 3),
"signal": mom_signal,
"method": "Price momentum (Jegadeesh & Titman 1993)",
},
"carry": {
"r_d": round(r_d * 100, 2),
"r_f": round(r_f * 100, 2),
"differential_pct": round(carry_diff * 100, 2),
"signal": carry_signal,
"method": "Uncovered Interest Parity (Fama 1984)",
},
}
+33
View File
@@ -0,0 +1,33 @@
"""surface.py — 2-D risk surface computation over spot × vol grid."""
import numpy as np
from .greeks import portfolio_greeks
from .garman_kohlhagen import gk_price
def compute_surfaces(options, S_range, sigma_range, T, r_d, r_f):
"""Compute Delta, Gamma, Vega, Theta, and P&L surfaces."""
n_s, n_v = len(S_range), len(sigma_range)
D = np.zeros((n_s, n_v)); G = np.zeros((n_s, n_v))
V = np.zeros((n_s, n_v)); Th = np.zeros((n_s, n_v))
for i, S in enumerate(S_range):
for j, sigma in enumerate(sigma_range):
g = portfolio_greeks(options, S, sigma, T, r_d, r_f)["total"]
D[i,j]=g["delta"]; G[i,j]=g["gamma"]
V[i,j]=g["vega"]; Th[i,j]=g["theta"]
# P&L via Delta-Gamma approx around grid midpoint
S0 = S_range[len(S_range)//2]
sig0 = sigma_range[len(sigma_range)//2]
base = portfolio_greeks(options, S0, sig0, T, r_d, r_f)["total"]
PnL = np.zeros((n_s, n_v))
for i, S in enumerate(S_range):
dS = S - S0
PnL[i,:] = base["delta"]*dS + 0.5*base["gamma"]*dS**2
return {
"S_range": S_range.tolist(), "sigma_range": sigma_range.tolist(),
"delta": D.tolist(), "gamma": G.tolist(),
"vega": V.tolist(), "theta": Th.tolist(), "pnl": PnL.tolist(),
}
+17
View File
@@ -0,0 +1,17 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata
app = FastAPI(title="QuantRisk FX Terminal API",
description="Forex options risk analytics — Garman-Kohlhagen model.",
version="3.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
for router in [greeks, surface, market, montecarlo, scenarios, strategies, export, forex, institutional, news, livedata]:
app.include_router(router.router, prefix="/api")
@app.get("/")
def root():
return {"status": "ok", "version": "3.0.0", "model": "Garman-Kohlhagen (1983)", "docs": "/docs"}
View File
+43
View File
@@ -0,0 +1,43 @@
import io, csv
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from ..schemas import GreeksRequest
from ..core.greeks import portfolio_greeks
from ..core.garman_kohlhagen import gk_price
router = APIRouter(prefix="/export", tags=["export"])
@router.post("/csv")
def export_csv(req: GreeksRequest):
options = [o.model_dump() for o in req.options]
result = portfolio_greeks(options, req.S, req.sigma, req.T, req.r_d, req.r_f)
out = io.StringIO()
w = csv.writer(out)
w.writerow(["QUANTRISK FX — GARMAN-KOHLHAGEN GREEKS REPORT"])
w.writerow(["Spot", req.S, "Sigma", req.sigma, "T", req.T,
"r_d", req.r_d, "r_f", req.r_f])
w.writerow([])
w.writerow(["PORTFOLIO TOTALS"])
w.writerow(["Greek", "Value", "Description"])
desc = {"delta":"Price sensitivity","gamma":"Delta curvature","vega":"Vol sensitivity",
"theta":"Time decay/yr","rho_d":"Dom rate sensitivity","phi":"For rate sensitivity"}
for k, v in result["total"].items():
w.writerow([k.upper(), v, desc.get(k,"")])
w.writerow([])
w.writerow(["LEG BREAKDOWN"])
w.writerow(["Leg","Delta","Gamma","Vega","Theta"])
for leg in result["legs"]:
w.writerow([leg["label"],leg["delta"],leg["gamma"],leg["vega"],leg["theta"]])
w.writerow([])
w.writerow(["OPTION PRICES (Garman-Kohlhagen)"])
w.writerow(["Leg","GK Price"])
for opt in options:
price = gk_price(req.S, opt["K"], opt.get("T",req.T), req.r_d, req.r_f,
req.sigma, opt["type"])
w.writerow([f"{opt['type'].upper()} K={opt['K']} qty={opt['qty']}", round(price,5)])
out.seek(0)
return StreamingResponse(
io.BytesIO(out.getvalue().encode()),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=gk_greeks_report.csv"},
)
+147
View File
@@ -0,0 +1,147 @@
"""forex.py — Live forex data and quant signal endpoints via yfinance."""
import numpy as np
import yfinance as yf
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..core.quant_analysis import compute_signals
router = APIRouter(prefix="/forex", tags=["forex"])
# Supported pairs: yfinance symbol → display label + default rates
PAIRS = {
"EURUSD": {"sym": "EURUSD=X", "r_d": 0.0525, "r_f": 0.0400, "pip": 0.0001},
"GBPUSD": {"sym": "GBPUSD=X", "r_d": 0.0525, "r_f": 0.0525, "pip": 0.0001},
"USDJPY": {"sym": "USDJPY=X", "r_d": 0.0010, "r_f": 0.0525, "pip": 0.01},
"USDCHF": {"sym": "USDCHF=X", "r_d": 0.0175, "r_f": 0.0525, "pip": 0.0001},
"AUDUSD": {"sym": "AUDUSD=X", "r_d": 0.0525, "r_f": 0.0435, "pip": 0.0001},
"USDCAD": {"sym": "USDCAD=X", "r_d": 0.0500, "r_f": 0.0525, "pip": 0.0001},
"NZDUSD": {"sym": "NZDUSD=X", "r_d": 0.0525, "r_f": 0.0550, "pip": 0.0001},
"EURJPY": {"sym": "EURJPY=X", "r_d": 0.0010, "r_f": 0.0400, "pip": 0.01},
"GBPJPY": {"sym": "GBPJPY=X", "r_d": 0.0010, "r_f": 0.0525, "pip": 0.01},
"EURGBP": {"sym": "EURGBP=X", "r_d": 0.0525, "r_f": 0.0400, "pip": 0.0001},
"XAUUSD": {"sym": "GC=F", "r_d": 0.0525, "r_f": 0.0000, "pip": 0.01},
}
def _fetch_rate(sym: str) -> dict:
t = yf.Ticker(sym)
fi = t.fast_info
spot = fi.last_price
prev = fi.previous_close
if not spot:
return None
change = round((spot - prev) / prev * 100, 3) if prev else 0.0
return {"spot": round(float(spot), 5), "prev": round(float(prev), 5) if prev else None,
"change_pct": change}
@router.get("/pairs")
def list_pairs():
"""Return metadata for all supported forex pairs."""
return [{"pair": k, **{f: v for f, v in meta.items() if f != "sym"}}
for k, meta in PAIRS.items()]
@router.get("/rates")
def all_rates():
"""Fetch current rates for all major pairs (bulk call)."""
results = []
for pair, meta in PAIRS.items():
try:
data = _fetch_rate(meta["sym"])
if data:
results.append({"pair": pair, **data,
"r_d": meta["r_d"], "r_f": meta["r_f"]})
except Exception:
pass
return results
@router.get("/rate/{pair}")
def get_rate(pair: str):
"""Current spot rate + 24h change for a single pair."""
pair = pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'. Supported: {list(PAIRS)}")
meta = PAIRS[pair]
data = _fetch_rate(meta["sym"])
if not data:
raise HTTPException(503, "Rate unavailable from data provider.")
return {"pair": pair, **data, "r_d": meta["r_d"], "r_f": meta["r_f"],
"pip": meta["pip"]}
@router.get("/ohlc/{pair}")
def get_ohlc(pair: str, interval: str = "5m", period: str = "2d"):
"""
OHLC candlestick data for a pair.
interval: 1m 5m 15m 30m 1h 4h 1d
period: 1d 2d 5d 1mo
"""
pair = pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
sym = PAIRS[pair]["sym"]
valid_intervals = {"1m", "5m", "15m", "30m", "1h", "4h", "1d"}
if interval not in valid_intervals:
interval = "5m"
try:
hist = yf.download(sym, period=period, interval=interval,
progress=False, auto_adjust=True)
if hist.empty:
raise HTTPException(503, "No OHLC data returned.")
hist = hist.dropna()
# Flatten MultiIndex columns if present
if isinstance(hist.columns, type(hist.columns)) and hasattr(hist.columns, 'droplevel'):
try:
hist.columns = hist.columns.droplevel(1)
except Exception:
pass
return {
"pair": pair,
"interval": interval,
"dates": [str(d) for d in hist.index],
"open": [round(float(v), 5) for v in hist["Open"]],
"high": [round(float(v), 5) for v in hist["High"]],
"low": [round(float(v), 5) for v in hist["Low"]],
"close": [round(float(v), 5) for v in hist["Close"]],
"volume": [int(v) for v in hist.get("Volume", [0]*len(hist))],
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(503, f"Data fetch failed: {e}")
@router.get("/signals/{pair}")
def get_signals(pair: str):
"""
Compute full quant signal suite using 60 days of daily closes.
Signals: EWMA vol, VaR/CVaR, Hurst exponent, OU mean-reversion,
momentum, carry — all with academic citations.
"""
pair = pair.upper()
if pair not in PAIRS:
raise HTTPException(404, f"Unknown pair '{pair}'.")
meta = PAIRS[pair]
try:
hist = yf.download(meta["sym"], period="90d", interval="1d",
progress=False, auto_adjust=True)
if hist.empty or len(hist) < 10:
raise HTTPException(503, "Insufficient history for signal computation.")
if isinstance(hist.columns, type(hist.columns)) and hasattr(hist.columns, 'droplevel'):
try:
hist.columns = hist.columns.droplevel(1)
except Exception:
pass
closes = [float(v) for v in hist["Close"].dropna()]
signals = compute_signals(closes, r_d=meta["r_d"], r_f=meta["r_f"])
signals["pair"] = pair
signals["current_price"] = round(closes[-1], 5)
return signals
except HTTPException:
raise
except Exception as e:
raise HTTPException(503, f"Signal computation failed: {e}")
+10
View File
@@ -0,0 +1,10 @@
from fastapi import APIRouter
from ..schemas import GreeksRequest
from ..core.greeks import portfolio_greeks
router = APIRouter(prefix="/greeks", tags=["greeks"])
@router.post("")
def compute_greeks(req: GreeksRequest):
options = [o.model_dump() for o in req.options]
return portfolio_greeks(options, req.S, req.sigma, req.T, req.r_d, req.r_f)
+270
View File
@@ -0,0 +1,270 @@
"""
institutional.py — Free institutional flow data.
Sources:
1. CFTC TFF (Traders in Financial Futures) — Socrata API on publicreporting.cftc.gov
Dataset: gpe5-46if (no API key needed, weekly, official CFTC data)
Categories: Dealers, Asset Managers, Leveraged Money (hedge funds), Other, Non-reportable
2. Volume Profile — approximated from yfinance OHLCV daily bars
(distributes bar volume proportionally across the High-Low range)
"""
import numpy as np
import httpx
import urllib.parse
import yfinance as yf
from fastapi import APIRouter, HTTPException
router = APIRouter(prefix="/institutional", tags=["institutional"])
CFTC_BASE = "https://publicreporting.cftc.gov"
CFTC_DATASET = "gpe5-46if"
# Map forex pair → CFTC market name + yfinance symbol
COT_MAP = {
"EURUSD": {"cot": "EURO FX - CHICAGO MERCANTILE EXCHANGE", "sym": "EURUSD=X"},
"GBPUSD": {"cot": "BRITISH POUND STERLING - CHICAGO MERCANTILE EXCHANGE", "sym": "GBPUSD=X"},
"USDJPY": {"cot": "JAPANESE YEN - CHICAGO MERCANTILE EXCHANGE", "sym": "USDJPY=X"},
"AUDUSD": {"cot": "AUSTRALIAN DOLLAR - CHICAGO MERCANTILE EXCHANGE", "sym": "AUDUSD=X"},
"USDCAD": {"cot": "CANADIAN DOLLAR - CHICAGO MERCANTILE EXCHANGE", "sym": "USDCAD=X"},
"NZDUSD": {"cot": "NEW ZEALAND DOLLAR - CHICAGO MERCANTILE EXCHANGE", "sym": "NZDUSD=X"},
"USDCHF": {"cot": "SWISS FRANC - CHICAGO MERCANTILE EXCHANGE", "sym": "USDCHF=X"},
"EURJPY": {"cot": "EURO FX - CHICAGO MERCANTILE EXCHANGE", "sym": "EURJPY=X"},
"GBPJPY": {"cot": "BRITISH POUND STERLING - CHICAGO MERCANTILE EXCHANGE", "sym": "GBPJPY=X"},
"EURGBP": {"cot": "EURO FX - CHICAGO MERCANTILE EXCHANGE", "sym": "EURGBP=X"},
"XAUUSD": {"cot": "GOLD - COMMODITY EXCHANGE INC.", "sym": "GC=F"},
}
def _safe_int(v) -> int:
try: return int(v or 0)
except: return 0
def _safe_float(v) -> float:
try: return float(v or 0)
except: return 0.0
def _fetch_cot(market_name: str, weeks: int = 52) -> list[dict]:
"""Fetch TFF COT data from CFTC Socrata API (publicreporting.cftc.gov)."""
where = urllib.parse.quote(f"market_and_exchange_names='{market_name}'")
url = (
f"{CFTC_BASE}/resource/{CFTC_DATASET}.json"
f"?$where={where}"
f"&$order=report_date_as_yyyy_mm_dd+DESC"
f"&$limit={weeks}"
)
try:
resp = httpx.get(url, timeout=20, follow_redirects=True)
resp.raise_for_status()
return resp.json()
except Exception:
return []
def _parse_tff(records: list[dict]) -> dict:
"""Parse TFF records into structured signal data (newest-first records → oldest-first output)."""
if not records:
return {}
rows = list(reversed(records)) # oldest first for charting
dates = []
oi, dealer_net, assetmgr_net, levmoney_net, other_net = [], [], [], [], []
chg_oi, chg_am_long, chg_am_short, chg_lm_long, chg_lm_short = [], [], [], [], []
pct_am_long, pct_am_short, pct_lm_long, pct_lm_short = [], [], [], []
am_long_list, am_short_list, lm_long_list, lm_short_list = [], [], [], []
for r in rows:
d = (r.get("report_date_as_yyyy_mm_dd") or "")[:10]
if not d:
continue
dates.append(d)
aml = _safe_int(r.get("asset_mgr_positions_long"))
ams = _safe_int(r.get("asset_mgr_positions_short"))
lml = _safe_int(r.get("lev_money_positions_long"))
lms = _safe_int(r.get("lev_money_positions_short"))
dl = _safe_int(r.get("dealer_positions_long_all"))
ds = _safe_int(r.get("dealer_positions_short_all"))
ol = _safe_int(r.get("other_rept_positions_long"))
os_ = _safe_int(r.get("other_rept_positions_short"))
total_oi = _safe_int(r.get("open_interest_all"))
oi.append(total_oi)
dealer_net.append(dl - ds)
assetmgr_net.append(aml - ams)
levmoney_net.append(lml - lms)
other_net.append(ol - os_)
am_long_list.append(aml)
am_short_list.append(ams)
lm_long_list.append(lml)
lm_short_list.append(lms)
chg_oi.append(_safe_int(r.get("change_in_open_interest_all")))
chg_am_long.append(_safe_int(r.get("change_in_asset_mgr_long")))
chg_am_short.append(_safe_int(r.get("change_in_asset_mgr_short")))
chg_lm_long.append(_safe_int(r.get("change_in_lev_money_long")))
chg_lm_short.append(_safe_int(r.get("change_in_lev_money_short")))
pct_am_long.append(_safe_float(r.get("pct_of_oi_asset_mgr_long")))
pct_am_short.append(_safe_float(r.get("pct_of_oi_asset_mgr_short")))
pct_lm_long.append(_safe_float(r.get("pct_of_oi_lev_money_long")))
pct_lm_short.append(_safe_float(r.get("pct_of_oi_lev_money_short")))
if not dates:
return {}
def _cot_index(series):
lo, hi = min(series), max(series)
return round((series[-1] - lo) / max(hi - lo, 1) * 100, 1) if hi > lo else 50.0
am_idx = _cot_index(assetmgr_net)
lm_idx = _cot_index(levmoney_net)
def _bias(idx):
return "BULLISH" if idx >= 65 else ("BEARISH" if idx <= 35 else "NEUTRAL")
am_bias = _bias(am_idx)
lm_bias = _bias(lm_idx)
# Composite: weight asset managers 60%, leveraged money 40%
composite_idx = round(am_idx * 0.6 + lm_idx * 0.4, 1)
composite_bias = _bias(composite_idx)
wk_chg_am = (assetmgr_net[-1] - assetmgr_net[-2]) if len(assetmgr_net) >= 2 else 0
wk_chg_lm = (levmoney_net[-1] - levmoney_net[-2]) if len(levmoney_net) >= 2 else 0
return {
"dates": dates,
"open_interest": oi,
"change_oi": chg_oi,
# Asset Managers (institutional — real money)
"am_net": assetmgr_net,
"am_long": am_long_list,
"am_short": am_short_list,
"am_pct_long": pct_am_long,
"am_pct_short": pct_am_short,
"am_index": am_idx,
"am_bias": am_bias,
"am_current_net": assetmgr_net[-1],
"am_wk_change": wk_chg_am,
# Leveraged Money (hedge funds, CTAs)
"lm_net": levmoney_net,
"lm_long": lm_long_list,
"lm_short": lm_short_list,
"lm_pct_long": pct_lm_long,
"lm_pct_short": pct_lm_short,
"lm_index": lm_idx,
"lm_bias": lm_bias,
"lm_current_net": levmoney_net[-1],
"lm_wk_change": wk_chg_lm,
# Dealers
"dealer_net": dealer_net,
# Other
"other_net": other_net,
# Composite
"composite_index": composite_idx,
"composite_bias": composite_bias,
"weeks": len(dates),
"latest_date": dates[-1],
"source": "CFTC TFF — Traders in Financial Futures (Socrata API)",
}
def _volume_profile(sym: str, period: str = "3mo", buckets: int = 40) -> dict:
"""
Approximate volume profile from daily OHLCV.
Distributes each bar's volume uniformly across its High-Low range.
"""
try:
hist = yf.download(sym, period=period, interval="1d",
progress=False, auto_adjust=True)
if hist.empty:
return {}
if hasattr(hist.columns, "droplevel"):
try: hist.columns = hist.columns.droplevel(1)
except Exception: pass
highs = hist["High"].dropna().values.astype(float)
lows = hist["Low"].dropna().values.astype(float)
volumes = hist["Volume"].dropna().values.astype(float)
closes = hist["Close"].dropna().values.astype(float)
global_lo = float(np.min(lows))
global_hi = float(np.max(highs))
if global_hi <= global_lo:
return {}
bucket_size = (global_hi - global_lo) / buckets
vol_profile = np.zeros(buckets)
for i in range(len(highs)):
lo_b = int((lows[i] - global_lo) / bucket_size)
hi_b = int((highs[i] - global_lo) / bucket_size)
lo_b = max(0, min(lo_b, buckets - 1))
hi_b = max(0, min(hi_b, buckets - 1))
span = max(hi_b - lo_b + 1, 1)
vol_per_bucket = volumes[i] / span if volumes[i] > 0 else 0
vol_profile[lo_b:hi_b + 1] += vol_per_bucket
prices = [round(global_lo + (j + 0.5) * bucket_size, 5) for j in range(buckets)]
poc_idx = int(np.argmax(vol_profile))
poc = prices[poc_idx]
total_vol = float(np.sum(vol_profile))
va_target = total_vol * 0.70
lo_idx, hi_idx = poc_idx, poc_idx
va_vol = float(vol_profile[poc_idx])
while va_vol < va_target and (lo_idx > 0 or hi_idx < buckets - 1):
expand_lo = vol_profile[lo_idx - 1] if lo_idx > 0 else 0
expand_hi = vol_profile[hi_idx + 1] if hi_idx < buckets - 1 else 0
if expand_hi >= expand_lo:
hi_idx = min(hi_idx + 1, buckets - 1); va_vol += expand_hi
else:
lo_idx = max(lo_idx - 1, 0); va_vol += expand_lo
return {
"prices": prices,
"volumes": [round(float(v), 0) for v in vol_profile],
"poc": poc,
"vah": prices[hi_idx],
"val": prices[lo_idx],
"current_price": round(float(closes[-1]), 5),
"global_hi": round(global_hi, 5),
"global_lo": round(global_lo, 5),
"total_volume": round(total_vol, 0),
}
except Exception:
return {}
@router.get("/{pair}")
def get_institutional(pair: str, weeks: int = 26):
"""
Institutional flow data: CFTC TFF positioning + Volume Profile.
Data sources are 100% free — no API keys required.
"""
pair = pair.upper()
if pair not in COT_MAP:
raise HTTPException(404, f"No institutional data for '{pair}'.")
meta = COT_MAP[pair]
weeks = min(max(weeks, 4), 52)
raw = _fetch_cot(meta["cot"], weeks)
cot = _parse_tff(raw)
vp = _volume_profile(meta["sym"])
if not cot and not vp:
raise HTTPException(503, "CFTC API and volume profile both unavailable.")
return {
"pair": pair,
"cot": cot,
"volume_profile": vp,
"data_sources": [
"CFTC TFF (Traders in Financial Futures) — publicreporting.cftc.gov, weekly, free",
"Volume Profile — yfinance OHLCV daily, 3 months",
],
}
+59
View File
@@ -0,0 +1,59 @@
import time
import httpx
from fastapi import APIRouter
router = APIRouter(prefix="/live", tags=["live"])
# Simple in-memory cache to avoid hammering free APIs
_cache: dict = {}
CACHE_TTL = 30 # seconds
def _cached(key: str, ttl: int = CACHE_TTL):
entry = _cache.get(key)
if entry and time.time() - entry["ts"] < ttl:
return entry["data"]
return None
def _store(key: str, data):
_cache[key] = {"ts": time.time(), "data": data}
return data
@router.get("/aircraft")
def get_aircraft():
cached = _cached("aircraft", ttl=30)
if cached is not None:
return cached
try:
with httpx.Client(timeout=10) as client:
r = client.get("https://opensky-network.org/api/states/all")
r.raise_for_status()
data = r.json()
states = data.get("states") or []
# Filter: has position, not on ground, limit 600
filtered = [
s for s in states
if s[5] is not None and s[6] is not None and not s[8]
][:600]
result = {"time": data.get("time"), "states": filtered}
return _store("aircraft", result)
except Exception as e:
return {"time": None, "states": [], "error": str(e)}
@router.get("/earthquakes")
def get_earthquakes():
cached = _cached("earthquakes", ttl=300)
if cached is not None:
return cached
try:
with httpx.Client(timeout=10) as client:
r = client.get(
"https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson"
)
r.raise_for_status()
return _store("earthquakes", r.json())
except Exception as e:
return {"features": [], "error": str(e)}
+66
View File
@@ -0,0 +1,66 @@
from fastapi import APIRouter, HTTPException
import yfinance as yf
router = APIRouter(prefix="/market", tags=["market"])
@router.get("/{ticker}")
def get_market_data(ticker: str):
"""Return current spot price, company name, and daily change for a ticker."""
try:
t = yf.Ticker(ticker.upper())
info = t.fast_info
spot = info.last_price
prev = info.previous_close
if not spot:
raise HTTPException(status_code=404, detail=f"Ticker '{ticker}' not found.")
change_pct = round((spot - prev) / prev * 100, 2) if prev else 0.0
name = getattr(info, "exchange", ticker.upper())
return {
"ticker": ticker.upper(),
"spot": round(float(spot), 2),
"prev_close": round(float(prev), 2) if prev else None,
"change_pct": change_pct,
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/iv-surface/{ticker}")
def get_iv_surface(ticker: str):
"""
Fetch the real implied volatility surface from market option chains.
Returns strikes, expiries, and IV values for a heatmap.
"""
try:
t = yf.Ticker(ticker.upper())
expiries = t.options[:6] # Limit to 6 nearest expiries
if not expiries:
raise HTTPException(status_code=404, detail="No options data found.")
rows = []
for exp in expiries:
chain = t.option_chain(exp)
for _, row in chain.calls.iterrows():
if row.get("impliedVolatility") and row["impliedVolatility"] > 0:
rows.append({
"expiry": exp,
"strike": float(row["strike"]),
"iv": round(float(row["impliedVolatility"]), 4),
"type": "call",
})
for _, row in chain.puts.iterrows():
if row.get("impliedVolatility") and row["impliedVolatility"] > 0:
rows.append({
"expiry": exp,
"strike": float(row["strike"]),
"iv": round(float(row["impliedVolatility"]), 4),
"type": "put",
})
spot = float(t.fast_info.last_price)
return {"ticker": ticker.upper(), "spot": spot, "data": rows}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter
from ..schemas import MonteCarloRequest
from ..core.montecarlo import run_montecarlo
router = APIRouter(prefix="/montecarlo", tags=["montecarlo"])
@router.post("")
def monte_carlo(req: MonteCarloRequest):
options = [o.model_dump() for o in req.options]
return run_montecarlo(options, req.S0, req.sigma, req.r_d, req.r_f,
req.T, req.n_paths, req.n_steps)
+110
View File
@@ -0,0 +1,110 @@
"""
news.py — Free economic calendar from Forex Factory.
Source: nfs.faireconomy.media (official FF data mirror, JSON format)
ff_calendar_thisweek.json — current week
(nextweek.json appears Fri/Sat only, gracefully skipped if 404)
No API key. No auth.
"""
import httpx
from datetime import datetime, timezone
from fastapi import APIRouter
router = APIRouter(prefix="/news", tags=["news"])
FF_JSON_URLS = [
"https://nfs.faireconomy.media/ff_calendar_thisweek.json",
"https://nfs.faireconomy.media/ff_calendar_nextweek.json",
]
# In-memory cache — refresh every hour
_cache: dict = {"data": None, "ts": 0.0}
_CACHE_TTL = 3600 # seconds
def _fetch_ff_json(url: str) -> list[dict]:
try:
resp = httpx.get(
url, timeout=15, follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0 (compatible; QuantRiskFX/3.0)"}
)
if resp.status_code != 200:
return []
raw = resp.json()
events = []
for ev in (raw if isinstance(raw, list) else []):
# `date` is already ISO-8601 with TZ offset, e.g. "2026-06-07T08:30:00-04:00"
date_str = ev.get("date", "")
dt_utc = None
if date_str:
try:
dt_et = datetime.fromisoformat(date_str)
dt_utc = dt_et.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")
except ValueError:
pass
events.append({
"title": ev.get("title", "").strip(),
"country": ev.get("country", "").strip(),
"date_raw": date_str,
"datetime_utc": dt_utc,
"impact": ev.get("impact", "").strip(),
"forecast": ev.get("forecast", "").strip(),
"previous": ev.get("previous", "").strip(),
"actual": ev.get("actual", "").strip(),
"url": "",
})
return events
except Exception:
return []
def _load_calendar() -> dict:
import time
now = time.time()
if _cache["data"] is not None and (now - _cache["ts"]) < _CACHE_TTL:
return _cache["data"]
seen: set[tuple] = set()
all_events: list = []
for url in FF_JSON_URLS:
for ev in _fetch_ff_json(url):
key = (ev["title"], ev["country"], ev["datetime_utc"])
if key not in seen:
seen.add(key)
all_events.append(ev)
all_events.sort(key=lambda e: e["datetime_utc"] or "")
now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%MZ")
next_high = next(
(e for e in all_events
if e["impact"] == "High"
and (e["datetime_utc"] or "") >= now_utc
and not e["actual"]),
None,
)
result = {
"events": all_events,
"count": len(all_events),
"now_utc": now_utc,
"next_high": next_high,
"source": "Forex Factory Economic Calendar — nfs.faireconomy.media (free, no API key)",
"note": "Times in UTC (source is US Eastern Time).",
"cached": False,
}
_cache["data"] = result
_cache["ts"] = now
return result
@router.get("/calendar")
def get_calendar():
"""
Economic calendar from Forex Factory (current + next week).
Times returned as UTC ISO-8601 strings. Cached for 1 hour.
"""
result = _load_calendar()
return {**result, "cached": _cache["ts"] > 0}
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter
from ..schemas import ScenarioRequest
from ..core.garman_kohlhagen import gk_price
router = APIRouter(prefix="/scenarios", tags=["scenarios"])
@router.post("")
def compute_scenarios(req: ScenarioRequest):
options = [o.model_dump() for o in req.options]
def portfolio_value(S, sigma):
return sum(
gk_price(S, opt["K"], opt.get("T", req.T), req.r_d, req.r_f, sigma, opt["type"])
* opt["qty"] for opt in options
)
base = portfolio_value(req.S0, req.sigma0)
results = []
for shock in req.shocks:
S_s = req.S0 * (1 + shock.dS_pct)
vol_s = max(0.005, req.sigma0 + shock.dVol)
pnl = portfolio_value(S_s, vol_s) - base
results.append({
"label": shock.label,
"dS_pct": shock.dS_pct, "dVol": shock.dVol,
"S_shocked": round(S_s, 5), "vol_shocked": round(vol_s, 4),
"pnl": round(pnl, 5),
"pnl_pct": round(pnl / abs(base) * 100, 2) if base else 0,
})
return {"base_value": round(base, 5), "scenarios": results}
+93
View File
@@ -0,0 +1,93 @@
from fastapi import APIRouter
router = APIRouter(prefix="/strategies", tags=["strategies"])
# Pre-built strategy templates — all expressed relative to ATM spot (K=100 placeholder)
STRATEGIES = [
{
"name": "Long Call",
"description": "Bullish. Unlimited upside, limited downside to premium paid.",
"legs": [{"type": "call", "K_offset": 0, "qty": 1}],
},
{
"name": "Long Put",
"description": "Bearish. Profit if spot falls below strike.",
"legs": [{"type": "put", "K_offset": 0, "qty": 1}],
},
{
"name": "Covered Call",
"description": "Long stock + short OTM call. Income strategy.",
"legs": [{"type": "call", "K_offset": 5, "qty": -1}],
},
{
"name": "Protective Put",
"description": "Long stock + long put. Portfolio insurance.",
"legs": [{"type": "put", "K_offset": -5, "qty": 1}],
},
{
"name": "Straddle",
"description": "Long call + put at same strike. Profits from large moves either way.",
"legs": [
{"type": "call", "K_offset": 0, "qty": 1},
{"type": "put", "K_offset": 0, "qty": 1},
],
},
{
"name": "Strangle",
"description": "OTM call + OTM put. Cheaper than straddle, needs bigger move.",
"legs": [
{"type": "call", "K_offset": 5, "qty": 1},
{"type": "put", "K_offset": -5, "qty": 1},
],
},
{
"name": "Bull Call Spread",
"description": "Long ATM call + short OTM call. Capped upside, lower cost.",
"legs": [
{"type": "call", "K_offset": 0, "qty": 1},
{"type": "call", "K_offset": 10, "qty": -1},
],
},
{
"name": "Bear Put Spread",
"description": "Long ATM put + short OTM put. Profits from moderate decline.",
"legs": [
{"type": "put", "K_offset": 0, "qty": 1},
{"type": "put", "K_offset": -10, "qty": -1},
],
},
{
"name": "Iron Condor",
"description": "4-leg strategy. Profit from low volatility, defined risk.",
"legs": [
{"type": "put", "K_offset": -15, "qty": 1},
{"type": "put", "K_offset": -5, "qty": -1},
{"type": "call", "K_offset": 5, "qty": -1},
{"type": "call", "K_offset": 15, "qty": 1},
],
},
{
"name": "Butterfly",
"description": "3-strike spread. Max profit when spot pins at middle strike.",
"legs": [
{"type": "call", "K_offset": -10, "qty": 1},
{"type": "call", "K_offset": 0, "qty": -2},
{"type": "call", "K_offset": 10, "qty": 1},
],
},
]
@router.get("")
def list_strategies():
"""Return all available strategy templates."""
return STRATEGIES
@router.get("/{name}")
def get_strategy(name: str):
"""Return a specific strategy by name (case-insensitive)."""
for s in STRATEGIES:
if s["name"].lower() == name.lower():
return s
return {"error": f"Strategy '{name}' not found."}
+14
View File
@@ -0,0 +1,14 @@
import numpy as np
from fastapi import APIRouter
from ..schemas import SurfaceRequest
from ..core.surface import compute_surfaces
router = APIRouter(prefix="/surface", tags=["surface"])
@router.post("")
def compute_surface(req: SurfaceRequest):
options = [o.model_dump() for o in req.options]
# If caller didn't set S range, default to ±20% around midpoint — handled frontend-side
S_range = np.linspace(req.S_low, req.S_high, req.S_steps)
sigma_range = np.linspace(req.vol_low, req.vol_high, req.vol_steps)
return compute_surfaces(options, S_range, sigma_range, req.T, req.r_d, req.r_f)
+66
View File
@@ -0,0 +1,66 @@
from pydantic import BaseModel, Field
from typing import Literal
class OptionLeg(BaseModel):
type: Literal["call", "put"]
K: float = Field(..., gt=0, description="Strike price (exchange rate)")
T: float = Field(..., gt=0, description="Time to expiry in years")
qty: float = Field(..., description="Signed quantity (positive=long)")
class GreeksRequest(BaseModel):
options: list[OptionLeg]
S: float = Field(..., gt=0, description="Spot exchange rate")
sigma: float = Field(..., gt=0, lt=5)
T: float = Field(..., gt=0)
r_d: float = Field(default=0.0525, description="Domestic risk-free rate")
r_f: float = Field(default=0.0400, description="Foreign risk-free rate")
class SurfaceRequest(BaseModel):
options: list[OptionLeg]
S_low: float = Field(default=0.0)
S_high: float = Field(default=0.0)
S_steps: int = Field(default=40)
vol_low: float = Field(default=0.05)
vol_high: float = Field(default=0.30)
vol_steps: int = Field(default=40)
T: float = Field(default=0.5)
r_d: float = Field(default=0.0525)
r_f: float = Field(default=0.0400)
class MonteCarloRequest(BaseModel):
options: list[OptionLeg]
S0: float = Field(..., gt=0)
sigma: float = Field(..., gt=0)
r_d: float = Field(default=0.0525)
r_f: float = Field(default=0.0400)
T: float = Field(..., gt=0)
n_paths: int = Field(default=1000, ge=100, le=10000)
n_steps: int = Field(default=100, ge=10, le=500)
class ScenarioShock(BaseModel):
label: str
dS_pct: float
dVol: float
class ScenarioRequest(BaseModel):
options: list[OptionLeg]
S0: float
sigma0: float
T: float
r_d: float = 0.0525
r_f: float = 0.0400
shocks: list[ScenarioShock] = Field(default_factory=lambda: [
ScenarioShock(label="Flash Crash", dS_pct=-0.03, dVol=0.08),
ScenarioShock(label="Sharp Sell-off", dS_pct=-0.015,dVol=0.04),
ScenarioShock(label="Mild Weakness", dS_pct=-0.005,dVol=0.01),
ScenarioShock(label="Base Case", dS_pct=0.00, dVol=0.00),
ScenarioShock(label="Mild Strength", dS_pct=0.005, dVol=-0.01),
ScenarioShock(label="Sharp Rally", dS_pct=0.015, dVol=-0.03),
ScenarioShock(label="Breakout", dS_pct=0.03, dVol=-0.05),
])
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.110,<1.0
uvicorn[standard]>=0.29,<1.0
numpy>=1.24,<3.0
scipy>=1.10,<2.0
pandas>=2.0,<4.0
yfinance>=0.2.38
pydantic>=2.0,<3.0
reportlab>=4.0,<5.0