""" 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