| """ |
| 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", "") |
|
|
| |
| 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}") |
|
|
| |
| 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']}%") |
|
|
| |
| 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)") |
|
|
| |
| 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']}%") |
|
|
| |
| 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). |
| """ |
| |
| 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) |
| |
|
|
| logger.info(f"Risk filter: {len(approved)}/{len(signals)} signals passed") |
| return approved |
|
|