mirror of
https://github.com/Mihirkansara/nexus-quant-terminal.git
synced 2026-07-28 11:07:45 +00:00
84bde8c664
- Added gs-quant to backend/requirements.txt - All gs_quant calls now conditional on GS_QUANT_AVAILABLE flag - Pure numpy/pandas fallbacks for volatility, RSI, MACD, Bollinger, z-score - Fixes ModuleNotFoundError crash on Render deployment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
295 lines
13 KiB
Python
295 lines
13 KiB
Python
"""
|
||
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
|
||
try:
|
||
from gs_quant.timeseries import econometrics as gseco
|
||
from gs_quant.timeseries import statistics as gsstat
|
||
from gs_quant.timeseries import technicals as gstech
|
||
GS_QUANT_AVAILABLE = True
|
||
except ImportError:
|
||
gseco = gsstat = gstech = None
|
||
GS_QUANT_AVAILABLE = False
|
||
|
||
|
||
# ─── 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) if GS_QUANT_AVAILABLE else None
|
||
hv60_s = gseco.volatility(px, w60) if GS_QUANT_AVAILABLE else None
|
||
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) if GS_QUANT_AVAILABLE else None
|
||
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) if GS_QUANT_AVAILABLE else None
|
||
gs_z = _safe_last(z_s, float((px_raw[-1] - np.mean(px_raw[-w20:])) / max(np.std(px_raw[-w20:]), 1e-9)))
|
||
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) ────────────────
|
||
if GS_QUANT_AVAILABLE:
|
||
rsi_s = gstech.relative_strength_index(px, 14)
|
||
rsi = _safe_last(rsi_s, 50.0)
|
||
else:
|
||
d = np.diff(px_raw[-15:])
|
||
gain = np.mean(d[d > 0]) if np.any(d > 0) else 1e-9
|
||
loss = np.mean(-d[d < 0]) if np.any(d < 0) else 1e-9
|
||
rsi = float(100 - 100 / (1 + gain / loss))
|
||
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) ──────────────────────────────────
|
||
if GS_QUANT_AVAILABLE:
|
||
macd_s = gstech.macd(px)
|
||
macd = _safe_last(macd_s, 0.0)
|
||
else:
|
||
ema12 = float(pd.Series(px_raw).ewm(span=12).mean().iloc[-1])
|
||
ema26 = float(pd.Series(px_raw).ewm(span=26).mean().iloc[-1])
|
||
macd = ema12 - ema26
|
||
macd_signal = "BULLISH" if macd > 0 else "BEARISH"
|
||
|
||
# ── 7. Bollinger Bands (gs-quant technicals.bollinger_bands) ────────────
|
||
current_px = float(px_raw[-1])
|
||
if GS_QUANT_AVAILABLE:
|
||
bb_s = gstech.bollinger_bands(px, w20)
|
||
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:])))
|
||
else:
|
||
sma = float(np.mean(px_raw[-w20:]))
|
||
gsstd = 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)",
|
||
},
|
||
}
|