292 lines
11 KiB
C#
292 lines
11 KiB
C#
using NT8.Core.Common.Models;
|
|
using NT8.Core.Logging;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
|
|
namespace NT8.Core.Risk
|
|
{
|
|
/// <summary>
|
|
/// Basic risk manager implementing Tier 1 risk controls
|
|
/// Thread-safe implementation using locks for state consistency
|
|
/// </summary>
|
|
public class BasicRiskManager : IRiskManager
|
|
{
|
|
private readonly ILogger _logger;
|
|
private readonly object _lock = new object();
|
|
|
|
// Risk state - protected by _lock
|
|
private double _dailyPnL;
|
|
private double _maxDrawdown;
|
|
private bool _tradingHalted;
|
|
private DateTime _lastUpdate = DateTime.UtcNow;
|
|
private readonly Dictionary<string, double> _symbolExposure = new Dictionary<string, double>();
|
|
|
|
public BasicRiskManager(ILogger logger)
|
|
{
|
|
if (logger == null) throw new ArgumentNullException("logger");
|
|
_logger = logger;
|
|
}
|
|
|
|
public RiskDecision ValidateOrder(StrategyIntent intent, StrategyContext context, RiskConfig config)
|
|
{
|
|
if (intent == null) throw new ArgumentNullException("intent");
|
|
if (context == null) throw new ArgumentNullException("context");
|
|
if (config == null) throw new ArgumentNullException("config");
|
|
|
|
lock (_lock)
|
|
{
|
|
// Check if trading is halted
|
|
if (_tradingHalted)
|
|
{
|
|
_logger.LogWarning("Order rejected - trading halted by risk manager");
|
|
|
|
var haltedMetrics = new Dictionary<string, object>();
|
|
haltedMetrics.Add("halted", true);
|
|
haltedMetrics.Add("daily_pnl", _dailyPnL);
|
|
|
|
return new RiskDecision(
|
|
allow: false,
|
|
rejectReason: "Trading halted by risk manager",
|
|
modifiedIntent: null,
|
|
riskLevel: RiskLevel.Critical,
|
|
riskMetrics: haltedMetrics
|
|
);
|
|
}
|
|
|
|
// Tier 1: Daily loss cap
|
|
if (_dailyPnL <= -config.DailyLossLimit)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Daily loss limit breached: {0:C} <= {1:C}", _dailyPnL, -config.DailyLossLimit);
|
|
|
|
var limitMetrics = new Dictionary<string, object>();
|
|
limitMetrics.Add("daily_pnl", _dailyPnL);
|
|
limitMetrics.Add("limit", config.DailyLossLimit);
|
|
|
|
return new RiskDecision(
|
|
allow: false,
|
|
rejectReason: String.Format("Daily loss limit breached: {0:C}", _dailyPnL),
|
|
modifiedIntent: null,
|
|
riskLevel: RiskLevel.Critical,
|
|
riskMetrics: limitMetrics
|
|
);
|
|
}
|
|
|
|
// Tier 1: Per-trade risk limit
|
|
var tradeRisk = CalculateTradeRisk(intent, context);
|
|
if (tradeRisk > config.MaxTradeRisk)
|
|
{
|
|
_logger.LogWarning("Trade risk too high: {0:C} > {1:C}", tradeRisk, config.MaxTradeRisk);
|
|
|
|
var riskMetrics = new Dictionary<string, object>();
|
|
riskMetrics.Add("trade_risk", tradeRisk);
|
|
riskMetrics.Add("limit", config.MaxTradeRisk);
|
|
|
|
return new RiskDecision(
|
|
allow: false,
|
|
rejectReason: String.Format("Trade risk too high: {0:C}", tradeRisk),
|
|
modifiedIntent: null,
|
|
riskLevel: RiskLevel.High,
|
|
riskMetrics: riskMetrics
|
|
);
|
|
}
|
|
|
|
// Tier 1: Position limits
|
|
var currentPositions = GetOpenPositionCount();
|
|
if (currentPositions >= config.MaxOpenPositions && context.CurrentPosition.Quantity == 0)
|
|
{
|
|
_logger.LogWarning("Max open positions exceeded: {0} >= {1}", currentPositions, config.MaxOpenPositions);
|
|
|
|
var positionMetrics = new Dictionary<string, object>();
|
|
positionMetrics.Add("open_positions", currentPositions);
|
|
positionMetrics.Add("limit", config.MaxOpenPositions);
|
|
|
|
return new RiskDecision(
|
|
allow: false,
|
|
rejectReason: String.Format("Max open positions exceeded: {0}", currentPositions),
|
|
modifiedIntent: null,
|
|
riskLevel: RiskLevel.Medium,
|
|
riskMetrics: positionMetrics
|
|
);
|
|
}
|
|
|
|
// All checks passed - determine risk level
|
|
var riskLevel = DetermineRiskLevel(config);
|
|
|
|
_logger.LogDebug("Order approved: {0} {1} risk=${2:F2} level={3}", intent.Symbol, intent.Side, tradeRisk, riskLevel);
|
|
|
|
var successMetrics = new Dictionary<string, object>();
|
|
successMetrics.Add("trade_risk", tradeRisk);
|
|
successMetrics.Add("daily_pnl", _dailyPnL);
|
|
successMetrics.Add("max_drawdown", _maxDrawdown);
|
|
successMetrics.Add("open_positions", currentPositions);
|
|
|
|
return new RiskDecision(
|
|
allow: true,
|
|
rejectReason: null,
|
|
modifiedIntent: null,
|
|
riskLevel: riskLevel,
|
|
riskMetrics: successMetrics
|
|
);
|
|
}
|
|
}
|
|
|
|
private static double CalculateTradeRisk(StrategyIntent intent, StrategyContext context)
|
|
{
|
|
// Get tick value for symbol - this will be enhanced in later phases
|
|
var tickValue = GetTickValue(intent.Symbol);
|
|
return intent.StopTicks * tickValue;
|
|
}
|
|
|
|
private static double GetTickValue(string symbol)
|
|
{
|
|
// Static tick values for Phase 0 - will be configurable in Phase 1
|
|
switch (symbol)
|
|
{
|
|
case "ES": return 12.50;
|
|
case "MES": return 1.25;
|
|
case "NQ": return 5.00;
|
|
case "MNQ": return 0.50;
|
|
case "CL": return 10.00;
|
|
case "GC": return 10.00;
|
|
default: return 12.50; // Default to ES
|
|
}
|
|
}
|
|
|
|
private int GetOpenPositionCount()
|
|
{
|
|
// For Phase 0, return simplified count
|
|
// Will be enhanced with actual position tracking in Phase 1
|
|
return _symbolExposure.Count(kvp => Math.Abs(kvp.Value) > 0.01);
|
|
}
|
|
|
|
private RiskLevel DetermineRiskLevel(RiskConfig config)
|
|
{
|
|
var lossPercent = Math.Abs(_dailyPnL) / config.DailyLossLimit;
|
|
|
|
if (lossPercent >= 0.8) return RiskLevel.High;
|
|
if (lossPercent >= 0.5) return RiskLevel.Medium;
|
|
return RiskLevel.Low;
|
|
}
|
|
|
|
public void OnFill(OrderFill fill)
|
|
{
|
|
if (fill == null) throw new ArgumentNullException("fill");
|
|
|
|
lock (_lock)
|
|
{
|
|
_lastUpdate = DateTime.UtcNow;
|
|
|
|
// Update symbol exposure
|
|
var fillValue = fill.Quantity * fill.FillPrice;
|
|
if (_symbolExposure.ContainsKey(fill.Symbol))
|
|
{
|
|
_symbolExposure[fill.Symbol] += fillValue;
|
|
}
|
|
else
|
|
{
|
|
_symbolExposure[fill.Symbol] = fillValue;
|
|
}
|
|
|
|
_logger.LogDebug("Fill processed: {0} {1} @ {2:F2}, Exposure: {3:C}",
|
|
fill.Symbol, fill.Quantity, fill.FillPrice, _symbolExposure[fill.Symbol]);
|
|
}
|
|
}
|
|
|
|
public void OnPnLUpdate(double netPnL, double dayPnL)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var oldDailyPnL = _dailyPnL;
|
|
_dailyPnL = dayPnL;
|
|
_maxDrawdown = Math.Min(_maxDrawdown, dayPnL);
|
|
_lastUpdate = DateTime.UtcNow;
|
|
|
|
if (Math.Abs(dayPnL - oldDailyPnL) > 0.01)
|
|
{
|
|
_logger.LogDebug("P&L Update: Daily={0:C}, Max DD={1:C}", dayPnL, _maxDrawdown);
|
|
}
|
|
|
|
// Check for emergency conditions
|
|
CheckEmergencyConditions(dayPnL);
|
|
}
|
|
}
|
|
|
|
private void CheckEmergencyConditions(double dayPnL)
|
|
{
|
|
// Emergency halt if daily loss exceeds 90% of limit
|
|
// Using a default limit of 1000 as this method doesn't have access to config
|
|
// In Phase 1, this should be improved to use the actual config value
|
|
if (dayPnL <= -(1000 * 0.9) && !_tradingHalted)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Emergency halt triggered at 90% of daily loss limit: {0:C}", dayPnL);
|
|
}
|
|
}
|
|
|
|
public async Task<bool> EmergencyFlatten(string reason)
|
|
{
|
|
if (String.IsNullOrEmpty(reason)) throw new ArgumentException("Reason required", "reason");
|
|
|
|
lock (_lock)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Emergency flatten triggered: {0}", reason);
|
|
}
|
|
|
|
// In Phase 0, this is a placeholder
|
|
// Phase 1 will implement actual position flattening via OMS
|
|
await Task.Delay(100);
|
|
|
|
_logger.LogInformation("Emergency flatten completed");
|
|
return true;
|
|
}
|
|
|
|
public RiskStatus GetRiskStatus()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var alerts = new List<string>();
|
|
|
|
if (_tradingHalted)
|
|
alerts.Add("Trading halted");
|
|
|
|
if (_dailyPnL <= -500) // Half of typical daily limit
|
|
alerts.Add(String.Format("Significant daily loss: {0:C}", _dailyPnL));
|
|
|
|
if (_maxDrawdown <= -1000)
|
|
alerts.Add(String.Format("Large drawdown: {0:C}", _maxDrawdown));
|
|
|
|
return new RiskStatus(
|
|
tradingEnabled: !_tradingHalted,
|
|
dailyPnL: _dailyPnL,
|
|
dailyLossLimit: 1000, // Will come from config in Phase 1
|
|
maxDrawdown: _maxDrawdown,
|
|
openPositions: GetOpenPositionCount(),
|
|
lastUpdate: _lastUpdate,
|
|
activeAlerts: alerts
|
|
);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset daily state - typically called at start of new trading day
|
|
/// </summary>
|
|
public void ResetDaily()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_dailyPnL = 0;
|
|
_maxDrawdown = 0;
|
|
_tradingHalted = false;
|
|
_symbolExposure.Clear();
|
|
_lastUpdate = DateTime.UtcNow;
|
|
|
|
_logger.LogInformation("Daily risk state reset");
|
|
}
|
|
}
|
|
}
|
|
}
|