Spaces:
Running
Running
File size: 12,896 Bytes
41cc612 edd5dd2 41cc612 edd5dd2 41cc612 edd5dd2 41cc612 edd5dd2 41cc612 edd5dd2 41cc612 edd5dd2 41cc612 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 | """
risk_engine.py β Portfolio risk metrics for the paper trading platform.
Reads closed trades from database.py and computes:
- Sharpe ratio (annualized, 6.5% India risk-free rate)
- Max drawdown % (on equity curve built from closed trades)
- Beta vs Nifty50 (regression of per-trade returns vs Nifty over same period)
- Portfolio volatility (annualized std dev of per-trade returns)
- Profit factor (gross profit / gross loss)
- Kelly fraction (optimal position sizing)
- Suggested position % (half-Kelly, capped at 10%)
Usage:
from risk_engine import get_portfolio_risk
metrics = get_portfolio_risk()
# metrics["sharpe_ratio"] β float
# metrics["suggested_position_size_pct"] β float (% of capital per trade)
Run standalone to test:
python risk_engine.py
"""
from __future__ import annotations
import logging
import math
from datetime import datetime
from typing import Optional
_INDIA_RISK_FREE_RATE = 6.5 / 100 # 6.5% annualized (RBI repo rate proxy)
_MIN_TRADES = 5 # minimum closed trades for meaningful metrics
# ββ HELPERS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _safe(v, default=0.0):
try:
f = float(v)
return f if math.isfinite(f) else default
except (TypeError, ValueError):
return default
def _annualized_return(mean_return_per_trade: float, avg_holding_days: float) -> float:
"""Approximate annualized return from per-trade mean and average holding period."""
if avg_holding_days <= 0:
return 0.0
trades_per_year = 252 / avg_holding_days
return mean_return_per_trade * trades_per_year / 100 # convert % to decimal
def _annualized_vol(std_per_trade: float, avg_holding_days: float) -> float:
if avg_holding_days <= 0 or std_per_trade <= 0:
return 0.0
trades_per_year = 252 / avg_holding_days
return (std_per_trade / 100) * math.sqrt(trades_per_year)
# ββ EQUITY CURVE & DRAWDOWN βββββββββββββββββββββββββββββββββββββββββββββββββββ
def _build_equity_series(sorted_trades: list[dict]) -> list[dict]:
"""Build equity curve as [{date, equity, trade_id}] starting at 100."""
series = []
equity = 100.0
for t in sorted_trades:
pnl = _safe(t.get("pnl_pct"), 0.0)
equity *= (1 + pnl / 100)
series.append({
"date": (t.get("closed_at") or "")[:10],
"equity": round(equity, 4),
"trade_id": t.get("id"),
"ticker": t.get("ticker"),
"pnl_pct": round(pnl, 3),
})
return series
def _max_drawdown(pnl_pct_list: list[float]) -> float:
"""Max drawdown % from an ordered series of per-trade P&L %."""
if not pnl_pct_list:
return 0.0
equity = 100.0 # start at 100
peak = 100.0
max_dd = 0.0
for r in pnl_pct_list:
equity *= (1 + r / 100)
if equity > peak:
peak = equity
dd = (peak - equity) / peak * 100
if dd > max_dd:
max_dd = dd
return round(max_dd, 2)
# ββ BETA CALCULATION ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _compute_beta(trades: list[dict]) -> Optional[float]:
"""
Estimate beta vs Nifty50 by matching each trade's return to the Nifty's return
over the same openβclose window. Requires at least 10 closed trades.
"""
if len(trades) < 10:
return None
try:
import yfinance as yf
import pandas as pd
nifty = yf.download("^NSEI", period="2y", progress=False, auto_adjust=True)["Close"].dropna()
except Exception as e:
logging.debug("risk_engine: beta Nifty download failed: %s", e)
return None
trade_ret = []
nifty_ret = []
for t in trades:
if not t.get("opened_at") or not t.get("closed_at") or t.get("pnl_pct") is None:
continue
try:
open_dt = pd.Timestamp(t["opened_at"]).normalize()
close_dt = pd.Timestamp(t["closed_at"]).normalize()
nifty_at_open = nifty.asof(open_dt)
nifty_at_close = nifty.asof(close_dt)
if nifty_at_open > 0 and nifty_at_close > 0:
nr = (nifty_at_close / nifty_at_open - 1) * 100
tr = _safe(t["pnl_pct"])
if direction := t.get("direction"):
if direction == "SHORT":
tr = -tr # short profits when market falls
trade_ret.append(tr)
nifty_ret.append(nr)
except Exception:
continue
if len(trade_ret) < 5:
return None
try:
import numpy as np
x = np.array(nifty_ret)
y = np.array(trade_ret)
cov = np.cov(x, y)[0, 1]
var = np.var(x)
if var <= 0:
return None
return round(float(cov / var), 3)
except Exception:
return None
# ββ KELLY FRACTION ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _kelly(win_rate: float, avg_win_pct: float, avg_loss_pct: float) -> float:
"""
Kelly criterion: fraction = (win_rate * avg_win - loss_rate * avg_loss) / avg_win
Uses absolute values. Returns 0.0 if inputs are degenerate.
"""
w = abs(avg_win_pct) / 100
l = abs(avg_loss_pct) / 100
if w <= 0:
return 0.0
p = win_rate
q = 1 - p
kelly = (p * w - q * l) / w
return max(0.0, round(kelly, 4))
# ββ HOLDING PERIOD ESTIMATION βββββββββββββββββββββββββββββββββββββββββββββββββ
def _avg_holding_days(trades: list[dict]) -> float:
"""Estimate average holding period in days from opened_at β closed_at."""
durations = []
for t in trades:
try:
if t.get("opened_at") and t.get("closed_at"):
open_dt = datetime.fromisoformat(t["opened_at"].replace("Z", "+00:00"))
close_dt = datetime.fromisoformat(t["closed_at"].replace("Z", "+00:00"))
days = abs((close_dt - open_dt).total_seconds()) / 86400
if 0 < days <= 30:
durations.append(days)
except Exception:
pass
if not durations:
return 3.0 # default 3D holding period if no data
avg = sum(durations) / len(durations)
return max(1.0, avg)
# ββ PUBLIC API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def get_portfolio_risk(include_curve: bool = False) -> dict:
"""
Compute risk metrics from the paper trading database.
Returns:
sharpe_ratio β annualized Sharpe (>1.0 = good, >2.0 = excellent)
max_drawdown_pct β largest peak-to-trough loss on equity curve (%)
beta_vs_nifty β correlation-adjusted sensitivity to Nifty moves
portfolio_volatility_ann β annualized volatility of per-trade returns
profit_factor β gross profit / gross loss (>1.5 = decent)
kelly_fraction β theoretical optimal position fraction
suggested_position_size_pct β half-Kelly, capped at 10% (conservative)
trade_count β number of closed trades used
avg_holding_days β average trade duration in days
win_rate β % of trades that were profitable
avg_win_pct β average winner return %
avg_loss_pct β average loser return %
computed_at β ISO timestamp
note β warning if insufficient data
"""
try:
from database import get_trade_history
trades = get_trade_history()
except Exception as e:
return {"error": f"database read failed: {e}", "trade_count": 0}
closed = [t for t in trades if t.get("pnl_pct") is not None]
n = len(closed)
if n < _MIN_TRADES:
return {
"sharpe_ratio": None,
"max_drawdown_pct": None,
"beta_vs_nifty": None,
"portfolio_volatility_ann": None,
"profit_factor": None,
"kelly_fraction": None,
"suggested_position_size_pct": 5.0, # conservative default
"trade_count": n,
"avg_holding_days": None,
"win_rate": None,
"avg_win_pct": None,
"avg_loss_pct": None,
"computed_at": datetime.now().isoformat(),
"note": f"Only {n} closed trade(s); need {_MIN_TRADES}+ for reliable metrics",
}
pnl_pcts = [_safe(t["pnl_pct"]) for t in closed]
# Split into winners/losers
winners = [p for p in pnl_pcts if p >= 0]
losers = [p for p in pnl_pcts if p < 0]
win_rate = len(winners) / n
avg_win = sum(winners) / len(winners) if winners else 0.0
avg_loss = abs(sum(losers) / len(losers)) if losers else 0.0
gross_profit = sum(winners)
gross_loss = abs(sum(losers))
profit_factor = round(gross_profit / gross_loss, 3) if gross_loss > 0 else float("inf")
loss_rate = 1 - win_rate
expectancy = round(win_rate * avg_win - loss_rate * avg_loss, 3)
# Holding period & volatility
avg_hold = _avg_holding_days(closed)
try:
import numpy as np
arr = np.array(pnl_pcts)
mean_ret = float(np.mean(arr))
std_ret = float(np.std(arr, ddof=1)) if n > 1 else 0.0
except ImportError:
mean_ret = sum(pnl_pcts) / n
variance = sum((p - mean_ret) ** 2 for p in pnl_pcts) / max(1, n - 1)
std_ret = math.sqrt(variance)
ann_ret = _annualized_return(mean_ret, avg_hold)
ann_vol = _annualized_vol(std_ret, avg_hold)
sharpe = None
if ann_vol > 0:
sharpe = round((ann_ret - _INDIA_RISK_FREE_RATE) / ann_vol, 3)
# Max drawdown from ordered equity curve (sort by closed_at)
sorted_trades = sorted(
[t for t in closed if t.get("closed_at")],
key=lambda t: t["closed_at"],
)
ordered_pnl = [_safe(t["pnl_pct"]) for t in sorted_trades]
max_dd = _max_drawdown(ordered_pnl)
# Equity curve (exposed when include_curve=True)
equity_series = _build_equity_series(sorted_trades) if include_curve else None
# Beta (best-effort; may return None)
beta = _compute_beta(sorted_trades)
# Kelly and position sizing
kf = _kelly(win_rate, avg_win, avg_loss)
half_kelly = kf * 0.5
suggested_position = round(min(half_kelly * 100, 10.0), 1) # cap at 10%
return {
"sharpe_ratio": sharpe,
"max_drawdown_pct": max_dd,
"beta_vs_nifty": beta,
"portfolio_volatility_ann": round(ann_vol * 100, 2) if ann_vol else None,
"profit_factor": profit_factor,
"expectancy": expectancy,
"kelly_fraction": round(kf, 4),
"suggested_position_size_pct": suggested_position,
"trade_count": n,
"avg_holding_days": round(avg_hold, 1),
"win_rate": round(win_rate * 100, 1),
"avg_win_pct": round(avg_win, 2),
"avg_loss_pct": round(avg_loss, 2),
"computed_at": datetime.now().isoformat(),
**({"equity_curve": equity_series} if include_curve else {}),
}
def format_risk_summary(metrics: dict) -> str:
"""One-line risk summary for logging / UI display."""
if metrics.get("error") or metrics.get("trade_count", 0) < _MIN_TRADES:
return f"Risk metrics: insufficient data ({metrics.get('trade_count', 0)} trades)"
sharpe = metrics.get("sharpe_ratio")
dd = metrics.get("max_drawdown_pct")
pf = metrics.get("profit_factor")
wr = metrics.get("win_rate")
pos = metrics.get("suggested_position_size_pct")
return (
f"Sharpe: {sharpe:.2f} | MaxDD: {dd:.1f}% | "
f"Profit Factor: {pf:.2f} | Win: {wr:.1f}% | "
f"Suggested size: {pos}% per trade"
)
if __name__ == "__main__":
import pprint
print("Computing portfolio risk metrics...")
metrics = get_portfolio_risk()
pprint.pprint(metrics)
print(f"\n{format_risk_summary(metrics)}")
|