Files
Kansaram 84bde8c664 fix: add gs-quant to requirements, graceful fallback if unavailable
- 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>
2026-06-07 19:16:30 +05:30

295 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)",
},
}