Risk Engine Design for Derivatives DEXs: Liquidation and Solvency

Build robust risk engines for derivatives DEXs. Implement liquidation systems, solvency monitoring, and protocol protection mechanisms.

Kevan Shah · · 18 min read

The risk engine is the heart of any derivatives exchange. It monitors every position, calculates real-time risk metrics, and protects the protocol from insolvency. A well-designed risk engine can mean the difference between a thriving exchange and catastrophic failure.

This comprehensive guide covers the architecture, implementation, and best practices for building production-grade risk management systems for perpetual DEXs.

Risk Engine Architecture

Core Components

contract RiskEngine {
    struct PositionRisk {
        bytes32 positionId;
        address trader;
        uint256 marginRatio;
        int256 unrealizedPnL;
        uint256 liquidationPrice;
        uint256 notionalValue;
        bool isLiquidatable;
        RiskTier tier;
    }
    
    enum RiskTier {
        SAFE,        // > 20% margin
        CAUTION,     // 10-20% margin
        WARNING,     // 6-10% margin
        LIQUIDATABLE // < 5% margin
    }
    
    // Risk parameters
    uint256 public initialMarginRatio = 1000;      // 10%
    uint256 public maintenanceMarginRatio = 500;    // 5%
    uint256 public liquidationFee = 250;            // 2.5%
    uint256 public maxLeverage = 50;                // 50x
    
    // Protocol safety
    uint256 public insuranceFundBalance;
    uint256 public badDebt;
    uint256 public lastHealthCheck;
    
    mapping(bytes32 => PositionRisk) public positionRisks;
    mapping(address => bool) public liquidators;
    mapping(address => uint256) public traderTotalExposure;
    
    event PositionRiskUpdated(
        bytes32 indexed positionId,
        RiskTier tier,
        uint256 marginRatio
    );
    
    event LiquidationExecuted(
        bytes32 indexed positionId,
        address indexed liquidator,
        uint256 liquidationPrice,
        uint256 collateralSeized
    );
    
    modifier onlyLiquidator() {
        require(liquidators[msg.sender], "Not authorized liquidator");
        _;
    }
    
    // Update risk metrics for a position
    function updatePositionRisk(bytes32 positionId) external {
        Position storage position = positions[positionId];
        
        // Get current market data
        uint256 markPrice = oracle.getMarkPrice(position.market);
        uint256 indexPrice = oracle.getIndexPrice(position.market);
        
        // Calculate PnL
        int256 unrealizedPnL = calculateUnrealizedPnL(position, markPrice);
        
        // Calculate margin ratio
        uint256 notional = (position.size * markPrice) / 1e18;
        int256 equity = int256(position.collateral) + unrealizedPnL - position.fundingDebt;
        
        uint256 marginRatio;
        if (equity <= 0) {
            marginRatio = 0;
        } else {
            marginRatio = (uint256(equity) * 10000) / notional;
        }
        
        // Determine risk tier
        RiskTier tier = determineRiskTier(marginRatio);
        
        // Calculate liquidation price
        uint256 liqPrice = calculateLiquidationPrice(
            position.isLong,
            position.entryPrice,
            position.leverage,
            maintenanceMarginRatio
        );
        
        // Store risk data
        positionRisks[positionId] = PositionRisk({
            positionId: positionId,
            trader: position.trader,
            marginRatio: marginRatio,
            unrealizedPnL: unrealizedPnL,
            liquidationPrice: liqPrice,
            notionalValue: notional,
            isLiquidatable: tier == RiskTier.LIQUIDATABLE,
            tier: tier
        });
        
        emit PositionRiskUpdated(positionId, tier, marginRatio);
    }
    
    function determineRiskTier(uint256 marginRatio) public view returns (RiskTier) {
        if (marginRatio >= 2000) return RiskTier.SAFE;
        if (marginRatio >= 1000) return RiskTier.CAUTION;
        if (marginRatio >= 600) return RiskTier.WARNING;
        return RiskTier.LIQUIDATABLE;
    }
}

Liquidation Engine

Full Liquidation

contract LiquidationEngine {
    struct Liquidation {
        bytes32 positionId;
        address liquidator;
        uint256 liquidationPrice;
        uint256 collateralSeized;
        uint256 debtRepaid;
        uint256 liquidatorReward;
        uint256 insuranceContribution;
        uint256 timestamp;
    }
    
    uint256 public constant LIQUIDATION_FEE = 250;        // 2.5%
    uint256 public constant LIQUIDATOR_REWARD = 125;      // 1.25%
    uint256 public constant INSURANCE_CONTRIBUTION = 125; // 1.25%
    uint256 public constant MAX_SLIPPAGE = 200;           // 2%
    
    mapping(bytes32 => Liquidation) public liquidations;
    uint256 public totalLiquidations;
    uint256 public totalBadDebt;
    
    event LiquidationExecuted(
        bytes32 indexed positionId,
        address indexed liquidator,
        uint256 collateralSeized,
        uint256 badDebt
    );
    
    function liquidate(bytes32 positionId) external onlyLiquidator {
        PositionRisk memory risk = riskEngine.getPositionRisk(positionId);
        require(risk.isLiquidatable, "Position not liquidatable");
        
        Position storage position = positions[positionId];
        uint256 liquidationPrice = oracle.getMarkPrice(position.market);
        
        // Verify price is within acceptable slippage
        require(
            isPriceValidForLiquidation(positionId, liquidationPrice),
            "Price slippage too high"
        );
        
        // Calculate position value and debt
        uint256 positionValue = (position.size * liquidationPrice) / 1e18;
        int256 unrealizedPnL = calculateUnrealizedPnL(position, liquidationPrice);
        int256 equity = int256(position.collateral) + unrealizedPnL - position.fundingDebt;
        
        // Calculate liquidation amounts
        uint256 collateralSeized;
        uint256 badDebt = 0;
        
        if (equity > 0) {
            collateralSeized = uint256(equity);
            
            // Distribute seized collateral
            uint256 liquidatorReward = (collateralSeized * LIQUIDATOR_REWARD) / 10000;
            uint256 insuranceContribution = (collateralSeized * INSURANCE_CONTRIBUTION) / 10000;
            uint256 protocolFee = collateralSeized - liquidatorReward - insuranceContribution;
            
            // Transfers
            collateralToken.transfer(msg.sender, liquidatorReward);
            collateralToken.transfer(insuranceFund, insuranceContribution);
            collateralToken.transfer(treasury, protocolFee);
            
        } else {
            // Bad debt scenario
            badDebt = uint256(-equity);
            collateralSeized = 0;
            totalBadDebt += badDebt;
            
            // Cover bad debt from insurance fund
            require(
                insuranceFundBalance >= badDebt,
                "Insufficient insurance funds"
            );
            insuranceFundBalance -= badDebt;
        }
        
        // Close position
        _closePosition(positionId);
        
        // Record liquidation
        bytes32 liquidationId = keccak256(abi.encodePacked(
            positionId,
            block.timestamp,
            msg.sender
        ));
        
        liquidations[liquidationId] = Liquidation({
            positionId: positionId,
            liquidator: msg.sender,
            liquidationPrice: liquidationPrice,
            collateralSeized: collateralSeized,
            debtRepaid: badDebt,
            liquidatorReward: liquidatorReward,
            insuranceContribution: insuranceContribution,
            timestamp: block.timestamp
        });
        
        totalLiquidations++;
        
        emit LiquidationExecuted(positionId, msg.sender, collateralSeized, badDebt);
    }
    
    function isPriceValidForLiquidation(
        bytes32 positionId,
        uint256 liquidationPrice
    ) internal view returns (bool) {
        Position storage position = positions[positionId];
        uint256 twapPrice = oracle.getTWAP(position.market, 15 minutes);
        
        uint256 deviation = liquidationPrice > twapPrice
            ? ((liquidationPrice - twapPrice) * 10000) / twapPrice
            : ((twapPrice - liquidationPrice) * 10000) / twapPrice;
        
        return deviation <= MAX_SLIPPAGE;
    }
}

Partial Liquidation

contract PartialLiquidation {
    uint256 public constant PARTIAL_LIQUIDATION_PERCENT = 25; // Close 25% at a time
    
    function partialLiquidate(bytes32 positionId) external onlyLiquidator {
        PositionRisk memory risk = riskEngine.getPositionRisk(positionId);
        require(
            risk.tier == RiskTier.WARNING || risk.tier == RiskTier.LIQUIDATABLE,
            "Position not at risk"
        );
        
        Position storage position = positions[positionId];
        uint256 closeSize = (position.size * PARTIAL_LIQUIDATION_PERCENT) / 100;
        
        uint256 liquidationPrice = oracle.getMarkPrice(position.market);
        
        // Calculate PnL on closed portion
        int256 closedPnL = calculatePnLForSize(position, closeSize, liquidationPrice);
        
        // Calculate fees
        uint256 closeNotional = (closeSize * liquidationPrice) / 1e18;
        uint256 fee = (closeNotional * LIQUIDATION_FEE) / 10000;
        
        // Update position
        position.size -= closeSize;
        position.collateral = closedPnL > 0
            ? position.collateral + uint256(closedPnL) - fee
            : position.collateral - uint256(-closedPnL) - fee;
        
        // Distribute fee
        uint256 liquidatorReward = (fee * 80) / 100;
        uint256 protocolFee = fee - liquidatorReward;
        
        collateralToken.transfer(msg.sender, liquidatorReward);
        collateralToken.transfer(treasury, protocolFee);
        
        // Recalculate risk
        riskEngine.updatePositionRisk(positionId);
        
        emit PartialLiquidation(positionId, closeSize, fee);
    }
}

Liquidation Queue and Priority

contract LiquidationQueue {
    struct LiquidatablePosition {
        bytes32 positionId;
        uint256 marginRatio;
        uint256 timestamp;
        uint256 rewardBonus;
    }
    
    LiquidatablePosition[] public liquidationQueue;
    mapping(bytes32 => uint256) public positionQueueIndex;
    
    uint256 public constant QUEUE_PROCESSING_INTERVAL = 1 minutes;
    uint256 public lastQueueProcessing;
    
    function addToLiquidationQueue(bytes32 positionId) external {
        PositionRisk memory risk = riskEngine.getPositionRisk(positionId);
        require(risk.isLiquidatable, "Position not liquidatable");
        
        uint256 index = liquidationQueue.length;
        positionQueueIndex[positionId] = index;
        
        liquidationQueue.push(LiquidatablePosition({
            positionId: positionId,
            marginRatio: risk.marginRatio,
            timestamp: block.timestamp,
            rewardBonus: calculateRewardBonus(risk.marginRatio)
        }));
        
        _sortQueueByPriority();
        
        emit AddedToQueue(positionId, risk.marginRatio);
    }
    
    function calculateRewardBonus(uint256 marginRatio) internal pure returns (uint256) {
        // Higher bonus for riskier positions
        if (marginRatio < 100) return 500;  // 5% bonus
        if (marginRatio < 300) return 300;  // 3% bonus
        if (marginRatio < 500) return 100;  // 1% bonus
        return 0;
    }
    
    function _sortQueueByPriority() internal {
        // Sort by margin ratio ascending (riskiest first)
        for (uint i = 0; i < liquidationQueue.length; i++) {
            for (uint j = i + 1; j < liquidationQueue.length; j++) {
                if (liquidationQueue[j].marginRatio < liquidationQueue[i].marginRatio) {
                    LiquidatablePosition memory temp = liquidationQueue[i];
                    liquidationQueue[i] = liquidationQueue[j];
                    liquidationQueue[j] = temp;
                    
                    positionQueueIndex[liquidationQueue[i].positionId] = i;
                    positionQueueIndex[liquidationQueue[j].positionId] = j;
                }
            }
        }
    }
    
    function processQueue(uint256 count) external {
        require(
            block.timestamp >= lastQueueProcessing + QUEUE_PROCESSING_INTERVAL,
            "Processing too frequent"
        );
        
        uint256 processed = 0;
        for (uint i = 0; i < liquidationQueue.length && processed < count; i++) {
            LiquidatablePosition memory pos = liquidationQueue[i];
            
            // Check if still liquidatable
            PositionRisk memory risk = riskEngine.getPositionRisk(pos.positionId);
            
            if (risk.isLiquidatable) {
                try liquidationEngine.liquidate(pos.positionId) {
                    processed++;
                    
                    // Remove from queue
                    _removeFromQueue(i);
                    i--; // Adjust index
                } catch {
                    // Skip if liquidation fails
                    continue;
                }
            } else {
                // No longer liquidatable, remove
                _removeFromQueue(i);
                i--;
            }
        }
        
        lastQueueProcessing = block.timestamp;
        
        emit QueueProcessed(processed);
    }
    
    function _removeFromQueue(uint256 index) internal {
        bytes32 positionId = liquidationQueue[index].positionId;
        
        // Move last element to removed position
        if (index < liquidationQueue.length - 1) {
            liquidationQueue[index] = liquidationQueue[liquidationQueue.length - 1];
            positionQueueIndex[liquidationQueue[index].positionId] = index;
        }
        
        liquidationQueue.pop();
        delete positionQueueIndex[positionId];
    }
}

Insurance Fund Management

contract InsuranceFund {
    uint256 public balance;
    uint256 public targetBalance;
    uint256 public maxBalance;
    
    uint256 public constant DEPOSIT_FEE_BPS = 5;        // 0.05% of volume
    uint256 public constant WITHDRAWAL_THRESHOLD = 95; // 95% of target
    
    mapping(address => uint256) public contributorShares;
    uint256 public totalShares;
    
    event Deposit(address indexed source, uint256 amount);
    event Withdrawal(address indexed recipient, uint256 amount);
    event BadDebtCovered(uint256 amount);
    
    function deposit(uint256 amount) external {
        collateralToken.transferFrom(msg.sender, address(this), amount);
        
        uint256 sharesToMint = totalShares == 0 
            ? amount 
            : (amount * totalShares) / balance;
        
        contributorShares[msg.sender] += sharesToMint;
        totalShares += sharesToMint;
        balance += amount;
        
        emit Deposit(msg.sender, amount);
    }
    
    function coverBadDebt(uint256 amount) external onlyRiskEngine {
        require(balance >= amount, "Insufficient insurance funds");
        
        balance -= amount;
        
        emit BadDebtCovered(amount);
    }
    
    function withdraw(uint256 shares) external {
        require(
            balance >= targetBalance * WITHDRAWAL_THRESHOLD / 100,
            "Below withdrawal threshold"
        );
        
        uint256 amount = (shares * balance) / totalShares;
        
        contributorShares[msg.sender] -= shares;
        totalShares -= shares;
        balance -= amount;
        
        collateralToken.transfer(msg.sender, amount);
        
        emit Withdrawal(msg.sender, amount);
    }
    
    function allocateTradingFees(uint256 feeAmount) external onlyExchange {
        // Allocate portion of trading fees to insurance fund
        uint256 insuranceAllocation = (feeAmount * 20) / 100; // 20% of fees
        
        balance += insuranceAllocation;
        
        emit Deposit(address(exchange), insuranceAllocation);
    }
    
    function rebalance() external onlyGovernance {
        if (balance > maxBalance) {
            // Excess goes to treasury
            uint256 excess = balance - maxBalance;
            balance = maxBalance;
            collateralToken.transfer(treasury, excess);
        }
    }
}

Real-Time Risk Monitoring

contract RealTimeRiskMonitor {
    struct SystemRiskMetrics {
        uint256 totalOpenInterest;
        uint256 totalCollateral;
        int256 totalUnrealizedPnL;
        uint256 insuranceFundRatio;
        uint256 liquidationVolume24h;
        uint256 badDebt24h;
        uint256 systemHealthScore;
    }
    
    SystemRiskMetrics public currentMetrics;
    uint256 public lastUpdateTime;
    uint256 public constant UPDATE_INTERVAL = 5 minutes;
    
    mapping(address => uint256) public assetExposure;
    uint256 public maxSingleAssetExposure = 5000; // 50%
    
    event SystemRiskAlert(string alertType, uint256 severity);
    event CircuitBreakerTriggered(string reason);
    
    function updateSystemMetrics() external {
        require(
            block.timestamp >= lastUpdateTime + UPDATE_INTERVAL,
            "Update too frequent"
        );
        
        uint256 totalOI = 0;
        uint256 totalCollat = 0;
        int256 totalPnL = 0;
        
        bytes32[] memory allPositions = getAllActivePositions();
        
        for (uint i = 0; i < allPositions.length; i++) {
            PositionRisk memory risk = riskEngine.getPositionRisk(allPositions[i]);
            
            totalOI += risk.notionalValue;
            totalCollat += risk.notionalValue * risk.marginRatio / 10000;
            totalPnL += risk.unrealizedPnL;
        }
        
        uint256 insuranceRatio = insuranceFund.balance * 10000 / totalOI;
        
        currentMetrics = SystemRiskMetrics({
            totalOpenInterest: totalOI,
            totalCollateral: totalCollat,
            totalUnrealizedPnL: totalPnL,
            insuranceFundRatio: insuranceRatio,
            liquidationVolume24h: getLiquidationVolume24h(),
            badDebt24h: getBadDebt24h(),
            systemHealthScore: calculateHealthScore(totalOI, totalCollat, insuranceRatio)
        });
        
        lastUpdateTime = block.timestamp;
        
        // Check risk thresholds
        _checkRiskThresholds();
    }
    
    function calculateHealthScore(
        uint256 totalOI,
        uint256 totalCollat,
        uint256 insuranceRatio
    ) internal pure returns (uint256) {
        // Score from 0-10000
        uint256 collateralRatio = totalOI > 0 ? (totalCollat * 10000) / totalOI : 0;
        
        // Weight: collateral ratio 60%, insurance ratio 40%
        uint256 score = (collateralRatio * 60 + insuranceRatio * 40) / 100;
        
        return score;
    }
    
    function _checkRiskThresholds() internal {
        // Check insurance fund adequacy
        if (currentMetrics.insuranceFundRatio < 500) { // < 5%
            emit SystemRiskAlert("LOW_INSURANCE", 3);
        }
        
        // Check concentration risk
        for (uint i = 0; i < supportedAssets.length; i++) {
            uint256 exposure = assetExposure[supportedAssets[i]];
            uint256 exposureRatio = currentMetrics.totalOpenInterest > 0
                ? (exposure * 10000) / currentMetrics.totalOpenInterest
                : 0;
            
            if (exposureRatio > maxSingleAssetExposure) {
                emit SystemRiskAlert("CONCENTRATION_RISK", 2);
            }
        }
        
        // Check liquidation spike
        if (currentMetrics.liquidationVolume24h > currentMetrics.totalOpenInterest / 10) {
            // > 10% of OI liquidated in 24h
            emit SystemRiskAlert("LIQUIDATION_SPIKE", 4);
            _triggerCircuitBreaker("High liquidation volume");
        }
        
        // Check bad debt spike
        if (currentMetrics.badDebt24h > insuranceFund.balance / 5) {
            // > 20% of insurance fund
            emit SystemRiskAlert("BAD_DEBT_SPIKE", 5);
            _triggerCircuitBreaker("High bad debt");
        }
        
        // Check overall health
        if (currentMetrics.systemHealthScore < 3000) {
            emit SystemRiskAlert("POOR_SYSTEM_HEALTH", 5);
            _triggerCircuitBreaker("Poor system health");
        }
    }
    
    function _triggerCircuitBreaker(string memory reason) internal {
        exchange.pauseTrading();
        emit CircuitBreakerTriggered(reason);
    }
}

Position Limits and Exposure Management

contract PositionLimits {
    struct LimitConfig {
        uint256 maxPositionSize;
        uint256 maxLeverage;
        uint256 maxNotionalPerAsset;
        uint256 maxDrawdown;
    }
    
    LimitConfig public limits;
    
    mapping(address => mapping(address => uint256)) public traderAssetExposure;
    mapping(address => uint256) public traderTotalExposure;
    mapping(address => int256) public traderMaxDrawdown;
    
    function checkPositionLimits(
        address trader,
        address asset,
        uint256 newSize,
        uint256 leverage
    ) external view returns (bool, string memory) {
        // Check leverage
        if (leverage > limits.maxLeverage) {
            return (false, "Exceeds max leverage");
        }
        
        // Check position size
        if (newSize > limits.maxPositionSize) {
            return (false, "Exceeds max position size");
        }
        
        // Check asset exposure
        uint256 assetExposure = traderAssetExposure[trader][asset] + newSize;
        if (assetExposure > limits.maxNotionalPerAsset) {
            return (false, "Exceeds asset exposure limit");
        }
        
        // Check total exposure
        uint256 totalExposure = traderTotalExposure[trader] + newSize;
        if (totalExposure > limits.maxNotionalPerAsset * 2) {
            return (false, "Exceeds total exposure limit");
        }
        
        return (true, "");
    }
    
    function updateExposure(
        address trader,
        address asset,
        int256 sizeDelta,
        int256 pnlDelta
    ) external {
        if (sizeDelta > 0) {
            traderAssetExposure[trader][asset] += uint256(sizeDelta);
            traderTotalExposure[trader] += uint256(sizeDelta);
        } else {
            traderAssetExposure[trader][asset] -= uint256(-sizeDelta);
            traderTotalExposure[trader] -= uint256(-sizeDelta);
        }
        
        // Track drawdown
        if (pnlDelta < 0) {
            traderMaxDrawdown[trader] += pnlDelta;
        }
    }
}

Best Practices

1. Oracle Resilience

contract MultiOracleRisk {
    address[] public oracles;
    uint256 public requiredConfirmations;
    uint256 public maxDeviation;
    
    function getConservativePrice() external view returns (uint256) {
        uint256[] memory prices = new uint256[](oracles.length);
        uint256 validCount = 0;
        
        for (uint i = 0; i < oracles.length; i++) {
            try IOracle(oracles[i]).getPrice() returns (uint256 price) {
                prices[validCount] = price;
                validCount++;
            } catch {
                continue;
            }
        }
        
        require(validCount >= requiredConfirmations, "Insufficient oracle responses");
        
        // Use median for robustness
        return findMedian(prices, validCount);
    }
}

2. Gradual Liquidation

  • Start with partial liquidations
  • Escalate to full liquidation only if necessary
  • Give traders chance to add collateral

3. Liquidator Incentives

  • Competitive rewards
  • Gas subsidies for liquidators
  • Priority queue for at-risk positions

4. Insurance Fund Growth

  • Continuous fee allocation
  • Protocol revenue sharing
  • Emergency fundraising mechanisms

5. Monitoring and Alerts

  • Real-time dashboards
  • Automated risk reports
  • Emergency response procedures

Conclusion

A robust risk engine is non-negotiable for derivatives DEXs. Key success factors:

  1. Fast Risk Updates: Sub-second position risk calculations
  2. Reliable Liquidations: Incentivize liquidators, minimize slippage
  3. Deep Insurance: Protect against tail risk events
  4. Proactive Monitoring: Catch issues before they become crises
  5. Graduated Response: Escalate from warnings to circuit breakers

The exchanges that survive extreme market conditions will be those that invested in sophisticated risk management from day one. Risk isn't overhead—it's the foundation of sustainable derivatives trading.