| 3 min read

Risk Engine Design Patterns for Financial AI Systems

risk management financial AI design patterns Python trading systems

Why Risk Engines Are Non-Negotiable

I have seen AI trading systems generate brilliant signals and then blow up spectacularly because nobody built a proper risk engine. The signal is only half the system. The risk engine is what keeps you in the game when your model encounters market conditions it has never seen before.

Here are the design patterns I use for every financial AI system I build.

Pattern 1: The Layered Gate Architecture

Every trade signal passes through multiple independent risk gates before execution. Each gate can approve, modify, or reject the trade. This defense-in-depth approach means no single failure mode can cause catastrophic losses.

from abc import ABC, abstractmethod
from dataclasses import dataclass

@dataclass
class TradeSignal:
    symbol: str
    direction: str  # 'long' or 'short'
    size: float
    price: float
    strategy_id: str

@dataclass  
class RiskDecision:
    approved: bool
    modified_size: float | None = None
    reason: str = ""

class RiskGate(ABC):
    @abstractmethod
    def evaluate(self, signal: TradeSignal, portfolio: dict) -> RiskDecision:
        pass

class RiskEngine:
    def __init__(self):
        self.gates: list[RiskGate] = []
    
    def add_gate(self, gate: RiskGate):
        self.gates.append(gate)
    
    def evaluate(self, signal: TradeSignal, portfolio: dict) -> RiskDecision:
        current_size = signal.size
        
        for gate in self.gates:
            decision = gate.evaluate(
                TradeSignal(**{**signal.__dict__, 'size': current_size}),
                portfolio
            )
            if not decision.approved:
                return decision
            if decision.modified_size is not None:
                current_size = decision.modified_size
        
        return RiskDecision(approved=True, modified_size=current_size)

Pattern 2: Position Limits

Position limits prevent any single position from dominating your portfolio. I implement both absolute limits (maximum dollar exposure) and relative limits (maximum percentage of portfolio).

class PositionLimitGate(RiskGate):
    def __init__(self, max_position_pct: float = 0.05, max_total_exposure: float = 1.0):
        self.max_position_pct = max_position_pct
        self.max_total_exposure = max_total_exposure
    
    def evaluate(self, signal: TradeSignal, portfolio: dict) -> RiskDecision:
        portfolio_value = portfolio['total_value']
        position_value = signal.size * signal.price
        position_pct = position_value / portfolio_value
        
        if position_pct > self.max_position_pct:
            adjusted_size = (self.max_position_pct * portfolio_value) / signal.price
            return RiskDecision(
                approved=True,
                modified_size=adjusted_size,
                reason=f"Position capped at {self.max_position_pct:.0%}"
            )
        
        total_exposure = portfolio['total_exposure'] + position_value
        if total_exposure / portfolio_value > self.max_total_exposure:
            return RiskDecision(
                approved=False,
                reason="Total exposure limit exceeded"
            )
        
        return RiskDecision(approved=True)

Pattern 3: Drawdown Circuit Breaker

This is the most important safety mechanism. When the portfolio drawdown exceeds a threshold, the circuit breaker halts all new positions and optionally reduces existing ones.

class DrawdownCircuitBreaker(RiskGate):
    def __init__(self, max_drawdown: float = 0.10, cooldown_hours: int = 24):
        self.max_drawdown = max_drawdown
        self.cooldown_hours = cooldown_hours
        self.tripped_at = None
    
    def evaluate(self, signal: TradeSignal, portfolio: dict) -> RiskDecision:
        current_drawdown = portfolio['current_drawdown']
        
        if current_drawdown >= self.max_drawdown:
            self.tripped_at = datetime.utcnow()
            return RiskDecision(
                approved=False,
                reason=f"Circuit breaker: drawdown {current_drawdown:.1%} exceeds {self.max_drawdown:.0%}"
            )
        
        if self.tripped_at:
            hours_since = (datetime.utcnow() - self.tripped_at).total_seconds() / 3600
            if hours_since < self.cooldown_hours:
                return RiskDecision(
                    approved=False,
                    reason=f"Circuit breaker cooldown: {self.cooldown_hours - hours_since:.0f}h remaining"
                )
            self.tripped_at = None
        
        return RiskDecision(approved=True)

Pattern 4: Correlation Guard

AI models often generate correlated signals across related instruments. Without a correlation guard, you end up with a portfolio that looks diversified but actually has concentrated directional exposure.

class CorrelationGuard(RiskGate):
    def __init__(self, max_correlated_exposure: float = 0.15):
        self.max_correlated_exposure = max_correlated_exposure
        self.correlation_matrix = {}
    
    def evaluate(self, signal: TradeSignal, portfolio: dict) -> RiskDecision:
        correlated_exposure = 0
        for position in portfolio['positions']:
            corr = self.get_correlation(signal.symbol, position['symbol'])
            if corr > 0.7:
                correlated_exposure += position['value'] / portfolio['total_value']
        
        if correlated_exposure > self.max_correlated_exposure:
            return RiskDecision(
                approved=False,
                reason=f"Correlated exposure {correlated_exposure:.0%} exceeds limit"
            )
        
        return RiskDecision(approved=True)

Pattern 5: Volatility Scaling

Position sizes should shrink when volatility expands. This is not just prudent risk management; it also improves risk-adjusted returns because you take smaller positions when the market is most uncertain.

class VolatilityScaler(RiskGate):
    def __init__(self, target_vol: float = 0.15):
        self.target_vol = target_vol
    
    def evaluate(self, signal: TradeSignal, portfolio: dict) -> RiskDecision:
        current_vol = portfolio.get('realized_vol_20d', 0.15)
        scale_factor = self.target_vol / max(current_vol, 0.01)
        scale_factor = min(scale_factor, 2.0)  # Cap upward scaling
        
        adjusted_size = signal.size * scale_factor
        return RiskDecision(
            approved=True,
            modified_size=adjusted_size,
            reason=f"Vol scaled by {scale_factor:.2f}x"
        )

Wiring It All Together

engine = RiskEngine()
engine.add_gate(DrawdownCircuitBreaker(max_drawdown=0.10))
engine.add_gate(PositionLimitGate(max_position_pct=0.05))
engine.add_gate(CorrelationGuard(max_correlated_exposure=0.15))
engine.add_gate(VolatilityScaler(target_vol=0.15))

# Every signal goes through all gates
decision = engine.evaluate(signal, portfolio_state)
if decision.approved:
    execute_trade(signal, size=decision.modified_size)
else:
    log_rejection(signal, decision.reason)

The Golden Rule

Build your risk engine before your signal generator. If that sounds backwards, consider this: a mediocre signal with excellent risk management will survive long enough to improve. A brilliant signal with no risk management will eventually find the one market condition that destroys it. The risk engine is what keeps the lights on.