mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-07-27 18:47:49 +00:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user