Swing_Quant_Engine / backend /agents /risk_agent.py
SiddharthVenba's picture
Initial commit for HF Space
75d9b3c
Raw
History Blame Contribute Delete
5.39 kB
"""
Risk Agent β€” Trade gatekeeper.
100% rule-based (zero LLM tokens).
Filters out bad trades based on liquidity, volatility, concentration, and drawdown.
"""
import logging
from backend.data.market_data import is_indian_ticker
from config import RISK_PARAMS as RP
logger = logging.getLogger(__name__)
def evaluate_risk(signal: dict, latest_features: dict, existing_positions: list[dict] | None = None) -> dict:
"""
Evaluate risk for a trade signal. Returns approval/rejection decision.
Args:
signal: The trade signal dict
latest_features: Latest feature row for the ticker
existing_positions: Currently open positions (for concentration check)
Returns:
{
"decision": "approve"|"reject"|"reduce",
"original_signal": signal,
"risk_checks": {...},
"reason": str,
}
"""
checks = {}
reasons = []
decision = "approve"
ticker = signal.get("ticker", "")
# ── 1. Liquidity Check ──
avg_volume = float(latest_features.get("Volume") or 0)
close = float(latest_features.get("Close") or 0)
if avg_volume and close:
daily_value = avg_volume * close
threshold = RP["min_liquidity_inr"] if is_indian_ticker(ticker) else RP["min_liquidity_usd"]
checks["liquidity"] = {
"daily_value": round(daily_value),
"threshold": threshold,
"pass": daily_value >= threshold,
}
if not checks["liquidity"]["pass"]:
decision = "reject"
reasons.append(f"Illiquid: daily volume value {daily_value:,.0f} < {threshold:,.0f}")
# ── 2. Volatility Check ──
atr_pct = latest_features.get("atr_pct")
if atr_pct:
checks["volatility"] = {
"atr_pct": round(atr_pct, 2),
"threshold": RP["max_atr_pct"],
"pass": atr_pct <= RP["max_atr_pct"],
}
if not checks["volatility"]["pass"]:
if decision != "reject":
decision = "reduce"
reasons.append(f"High volatility: ATR%={atr_pct:.1f}% > {RP['max_atr_pct']}%")
# ── 3. Risk:Reward Check ──
rr = signal.get("risk_reward", 0)
checks["risk_reward"] = {
"value": rr,
"threshold": 1.5,
"pass": rr >= 1.5,
}
if not checks["risk_reward"]["pass"]:
if decision == "approve":
decision = "reduce"
reasons.append(f"Poor R:R = {rr:.1f} (min 1.5)")
# ── 4. Sector Concentration ──
if existing_positions:
sector = latest_features.get("sector", "Unknown")
sector_count = sum(1 for p in existing_positions if p.get("sector") == sector)
sector_pct = (sector_count + 1) / max(len(existing_positions), 1) * 100
checks["sector_concentration"] = {
"sector": sector,
"current_pct": round(sector_pct, 1),
"threshold": RP["max_sector_pct"],
"pass": sector_pct <= RP["max_sector_pct"],
}
if not checks["sector_concentration"]["pass"]:
if decision == "approve":
decision = "reduce"
reasons.append(f"Sector concentration: {sector} at {sector_pct:.0f}% > {RP['max_sector_pct']}%")
# ── 5. Stop Loss Distance ──
entry = signal.get("entry_price", 0)
sl = signal.get("stop_loss", 0)
if entry and sl:
sl_pct = abs(entry - sl) / entry * 100
checks["stop_loss_distance"] = {
"pct": round(sl_pct, 2),
"max_acceptable": 8.0,
"pass": sl_pct <= 8.0,
}
if not checks["stop_loss_distance"]["pass"]:
if decision == "approve":
decision = "reduce"
reasons.append(f"Wide stop loss: {sl_pct:.1f}% from entry")
reason = "; ".join(reasons) if reasons else "All risk checks passed"
result = {
"decision": decision,
"ticker": ticker,
"signal_type": signal.get("signal_type"),
"risk_checks": checks,
"reason": reason,
"agent_name": "risk_agent",
}
emoji = {"approve": "βœ…", "reject": "❌", "reduce": "⚠️"}
logger.info(f"{emoji.get(decision, '?')} Risk [{ticker}]: {decision} β€” {reason}")
return result
def filter_signals_by_risk(signals: list[dict], feature_results: list[dict],
existing_positions: list[dict] | None = None) -> list[dict]:
"""
Filter a list of signals through the risk agent.
Returns only approved/reduced signals (rejects are dropped).
"""
# Build a lookup of features by ticker
features_map = {}
for result in feature_results:
features_map[result["ticker"]] = result.get("latest", {})
approved = []
for signal in signals:
ticker = signal.get("ticker", "")
latest = features_map.get(ticker, {})
risk = evaluate_risk(signal, latest, existing_positions)
if risk["decision"] == "approve":
signal["risk_decision"] = "approved"
approved.append(signal)
elif risk["decision"] == "reduce":
signal["risk_decision"] = "reduce_size"
signal["risk_reason"] = risk["reason"]
approved.append(signal)
# Rejected signals are dropped
logger.info(f"Risk filter: {len(approved)}/{len(signals)} signals passed")
return approved