PaperTrade / risk_engine.py
Khanna, Videh Rakesh Rakesh
feat: bearish neutralization guard, T7 trigger, WhatsApp alerts, equity curve + portfolio review
edd5dd2
Raw
History Blame Contribute Delete
12.9 kB
"""
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)}")