935 lines
33 KiB
Markdown
935 lines
33 KiB
Markdown
# **Risk Management Package**
|
|
|
|
## **IMPLEMENTATION INSTRUCTIONS**
|
|
|
|
Implement the BasicRiskManager exactly as specified. This is the most critical component - all trades must pass through risk validation.
|
|
|
|
### **File 1: `src/NT8.Core/Risk/BasicRiskManager.cs`**
|
|
```csharp
|
|
using NT8.Core.Common.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
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<BasicRiskManager> _logger;
|
|
private readonly object _lock = new();
|
|
|
|
// 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();
|
|
|
|
public BasicRiskManager(ILogger<BasicRiskManager> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public RiskDecision ValidateOrder(StrategyIntent intent, StrategyContext context, RiskConfig config)
|
|
{
|
|
if (intent == null) throw new ArgumentNullException(nameof(intent));
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
if (config == null) throw new ArgumentNullException(nameof(config));
|
|
|
|
lock (_lock)
|
|
{
|
|
// Check if trading is halted
|
|
if (_tradingHalted)
|
|
{
|
|
_logger.LogWarning("Order rejected - trading halted by risk manager");
|
|
return new RiskDecision(
|
|
Allow: false,
|
|
RejectReason: "Trading halted by risk manager",
|
|
ModifiedIntent: null,
|
|
RiskLevel: RiskLevel.Critical,
|
|
RiskMetrics: new() { ["halted"] = true, ["daily_pnl"] = _dailyPnL }
|
|
);
|
|
}
|
|
|
|
// Tier 1: Daily loss cap
|
|
if (_dailyPnL <= -config.DailyLossLimit)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Daily loss limit breached: {DailyPnL:C} <= {Limit:C}",
|
|
_dailyPnL, -config.DailyLossLimit);
|
|
|
|
return new RiskDecision(
|
|
Allow: false,
|
|
RejectReason: $"Daily loss limit breached: {_dailyPnL:C}",
|
|
ModifiedIntent: null,
|
|
RiskLevel: RiskLevel.Critical,
|
|
RiskMetrics: new() { ["daily_pnl"] = _dailyPnL, ["limit"] = config.DailyLossLimit }
|
|
);
|
|
}
|
|
|
|
// Tier 1: Per-trade risk limit
|
|
var tradeRisk = CalculateTradeRisk(intent, context);
|
|
if (tradeRisk > config.MaxTradeRisk)
|
|
{
|
|
_logger.LogWarning("Trade risk too high: {Risk:C} > {Limit:C}", tradeRisk, config.MaxTradeRisk);
|
|
|
|
return new RiskDecision(
|
|
Allow: false,
|
|
RejectReason: $"Trade risk too high: {tradeRisk:C}",
|
|
ModifiedIntent: null,
|
|
RiskLevel: RiskLevel.High,
|
|
RiskMetrics: new() { ["trade_risk"] = tradeRisk, ["limit"] = config.MaxTradeRisk }
|
|
);
|
|
}
|
|
|
|
// Tier 1: Position limits
|
|
var currentPositions = GetOpenPositionCount();
|
|
if (currentPositions >= config.MaxOpenPositions && context.CurrentPosition.Quantity == 0)
|
|
{
|
|
_logger.LogWarning("Max open positions exceeded: {Current} >= {Limit}",
|
|
currentPositions, config.MaxOpenPositions);
|
|
|
|
return new RiskDecision(
|
|
Allow: false,
|
|
RejectReason: $"Max open positions exceeded: {currentPositions}",
|
|
ModifiedIntent: null,
|
|
RiskLevel: RiskLevel.Medium,
|
|
RiskMetrics: new() { ["open_positions"] = currentPositions, ["limit"] = config.MaxOpenPositions }
|
|
);
|
|
}
|
|
|
|
// All checks passed - determine risk level
|
|
var riskLevel = DetermineRiskLevel(config);
|
|
|
|
_logger.LogDebug("Order approved: {Symbol} {Side} risk=${Risk:F2} level={Level}",
|
|
intent.Symbol, intent.Side, tradeRisk, riskLevel);
|
|
|
|
return new RiskDecision(
|
|
Allow: true,
|
|
RejectReason: null,
|
|
ModifiedIntent: null,
|
|
RiskLevel: riskLevel,
|
|
RiskMetrics: new() {
|
|
["trade_risk"] = tradeRisk,
|
|
["daily_pnl"] = _dailyPnL,
|
|
["max_drawdown"] = _maxDrawdown,
|
|
["open_positions"] = currentPositions
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
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
|
|
return symbol switch
|
|
{
|
|
"ES" => 12.50,
|
|
"MES" => 1.25,
|
|
"NQ" => 5.00,
|
|
"MNQ" => 0.50,
|
|
"CL" => 10.00,
|
|
"GC" => 10.00,
|
|
_ => 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;
|
|
|
|
return lossPercent switch
|
|
{
|
|
>= 0.8 => RiskLevel.High,
|
|
>= 0.5 => RiskLevel.Medium,
|
|
_ => RiskLevel.Low
|
|
};
|
|
}
|
|
|
|
public void OnFill(OrderFill fill)
|
|
{
|
|
if (fill == null) throw new ArgumentNullException(nameof(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: {Symbol} {Qty} @ {Price:F2}, Exposure: {Exposure: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={DayPnL:C}, Max DD={MaxDD:C}",
|
|
dayPnL, _maxDrawdown);
|
|
}
|
|
|
|
// Check for emergency conditions
|
|
CheckEmergencyConditions(dayPnL);
|
|
}
|
|
}
|
|
|
|
private void CheckEmergencyConditions(double dayPnL)
|
|
{
|
|
// Emergency halt if daily loss exceeds 90% of limit
|
|
if (dayPnL <= -(_dailyPnL * 0.9) && !_tradingHalted)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Emergency halt triggered at 90% of daily loss limit: {DayPnL:C}", dayPnL);
|
|
}
|
|
}
|
|
|
|
public async Task<bool> EmergencyFlatten(string reason)
|
|
{
|
|
if (string.IsNullOrEmpty(reason)) throw new ArgumentException("Reason required", nameof(reason));
|
|
|
|
lock (_lock)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Emergency flatten triggered: {Reason}", 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($"Significant daily loss: {_dailyPnL:C}");
|
|
|
|
if (_maxDrawdown <= -1000)
|
|
alerts.Add($"Large drawdown: {_maxDrawdown:C}");
|
|
|
|
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");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## **COMPREHENSIVE TEST SUITE**
|
|
|
|
### **File 2: `tests/NT8.Core.Tests/Risk/BasicRiskManagerTests.cs`**
|
|
```csharp
|
|
using NT8.Core.Risk;
|
|
using NT8.Core.Common.Models;
|
|
using NT8.Core.Tests.TestHelpers;
|
|
using Microsoft.Extensions.Logging;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace NT8.Core.Tests.Risk;
|
|
|
|
public class BasicRiskManagerTests : IDisposable
|
|
{
|
|
private readonly ILogger<BasicRiskManager> _logger;
|
|
private readonly BasicRiskManager _riskManager;
|
|
|
|
public BasicRiskManagerTests()
|
|
{
|
|
_logger = NullLogger<BasicRiskManager>.Instance;
|
|
_riskManager = new BasicRiskManager(_logger);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_WithinLimits_ShouldAllow()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(stopTicks: 8);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeTrue();
|
|
result.RejectReason.Should().BeNull();
|
|
result.RiskLevel.Should().Be(RiskLevel.Low);
|
|
result.RiskMetrics.Should().ContainKey("trade_risk");
|
|
result.RiskMetrics.Should().ContainKey("daily_pnl");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_ExceedsDailyLimit_ShouldReject()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 1000,
|
|
MaxTradeRisk: 500,
|
|
MaxOpenPositions: 5,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
// Simulate daily loss exceeding limit
|
|
_riskManager.OnPnLUpdate(0, -1001);
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Daily loss limit breached");
|
|
result.RiskLevel.Should().Be(RiskLevel.Critical);
|
|
result.RiskMetrics["daily_pnl"].Should().Be(-1001);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_ExceedsTradeRisk_ShouldReject()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(stopTicks: 100); // High risk trade
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 10000,
|
|
MaxTradeRisk: 500, // Lower than calculated trade risk
|
|
MaxOpenPositions: 5,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Trade risk too high");
|
|
result.RiskLevel.Should().Be(RiskLevel.High);
|
|
|
|
// Verify risk calculation
|
|
var expectedRisk = 100 * 12.50; // 100 ticks * ES tick value
|
|
result.RiskMetrics["trade_risk"].Should().Be(expectedRisk);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ES", 8, 100.0)] // ES: 8 ticks * $12.50 = $100
|
|
[InlineData("MES", 8, 10.0)] // MES: 8 ticks * $1.25 = $10
|
|
[InlineData("NQ", 4, 20.0)] // NQ: 4 ticks * $5.00 = $20
|
|
[InlineData("MNQ", 10, 5.0)] // MNQ: 10 ticks * $0.50 = $5
|
|
public void ValidateOrder_RiskCalculation_ShouldBeAccurate(string symbol, int stopTicks, double expectedRisk)
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: symbol, stopTicks: stopTicks);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeTrue();
|
|
result.RiskMetrics["trade_risk"].Should().Be(expectedRisk);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_MaxPositionsExceeded_ShouldReject()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 10000,
|
|
MaxTradeRisk: 1000,
|
|
MaxOpenPositions: 1, // Very low limit
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
// Simulate existing position by processing a fill
|
|
var fill = new OrderFill(
|
|
OrderId: Guid.NewGuid().ToString(),
|
|
Symbol: "NQ",
|
|
Quantity: 2,
|
|
FillPrice: 15000.0,
|
|
FillTime: DateTime.UtcNow,
|
|
Commission: 5.0,
|
|
ExecutionId: Guid.NewGuid().ToString()
|
|
);
|
|
_riskManager.OnFill(fill);
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Max open positions exceeded");
|
|
result.RiskLevel.Should().Be(RiskLevel.Medium);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmergencyFlatten_ShouldHaltTrading()
|
|
{
|
|
// Arrange
|
|
var reason = "Test emergency halt";
|
|
|
|
// Act
|
|
var result = await _riskManager.EmergencyFlatten(reason);
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
result.Should().BeTrue();
|
|
status.TradingEnabled.Should().BeFalse();
|
|
status.ActiveAlerts.Should().Contain("Trading halted");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmergencyFlatten_WithNullReason_ShouldThrow()
|
|
{
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentException>(() => _riskManager.EmergencyFlatten(null));
|
|
await Assert.ThrowsAsync<ArgumentException>(() => _riskManager.EmergencyFlatten(""));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_AfterEmergencyFlatten_ShouldRejectAllOrders()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
|
|
// Trigger emergency flatten
|
|
_riskManager.EmergencyFlatten("Test").Wait();
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Trading halted");
|
|
result.RiskLevel.Should().Be(RiskLevel.Critical);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnPnLUpdate_WithLargeDrawdown_ShouldUpdateStatus()
|
|
{
|
|
// Arrange
|
|
var largeLoss = -1500.0;
|
|
|
|
// Act
|
|
_riskManager.OnPnLUpdate(largeLoss, largeLoss);
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
status.DailyPnL.Should().Be(largeLoss);
|
|
status.MaxDrawdown.Should().Be(largeLoss);
|
|
status.ActiveAlerts.Should().Contain(alert => alert.Contains("drawdown"));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnFill_ShouldUpdateExposure()
|
|
{
|
|
// Arrange
|
|
var fill = new OrderFill(
|
|
OrderId: Guid.NewGuid().ToString(),
|
|
Symbol: "ES",
|
|
Quantity: 2,
|
|
FillPrice: 4200.0,
|
|
FillTime: DateTime.UtcNow,
|
|
Commission: 4.50,
|
|
ExecutionId: Guid.NewGuid().ToString()
|
|
);
|
|
|
|
// Act
|
|
_riskManager.OnFill(fill);
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
status.LastUpdate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_WithNullParameters_ShouldThrow()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>(() => _riskManager.ValidateOrder(null, context, config));
|
|
Assert.Throws<ArgumentNullException>(() => _riskManager.ValidateOrder(intent, null, config));
|
|
Assert.Throws<ArgumentNullException>(() => _riskManager.ValidateOrder(intent, context, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void RiskLevel_ShouldEscalateWithLosses()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new RiskConfig(1000, 500, 5, true); // $1000 daily limit
|
|
|
|
// Act & Assert - Low risk (no losses)
|
|
var result1 = _riskManager.ValidateOrder(intent, context, config);
|
|
result1.RiskLevel.Should().Be(RiskLevel.Low);
|
|
|
|
// Medium risk (50% of daily limit)
|
|
_riskManager.OnPnLUpdate(-500, -500);
|
|
var result2 = _riskManager.ValidateOrder(intent, context, config);
|
|
result2.RiskLevel.Should().Be(RiskLevel.Medium);
|
|
|
|
// High risk (80% of daily limit)
|
|
_riskManager.OnPnLUpdate(-800, -800);
|
|
var result3 = _riskManager.ValidateOrder(intent, context, config);
|
|
result3.RiskLevel.Should().Be(RiskLevel.High);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetDaily_ShouldClearState()
|
|
{
|
|
// Arrange - Set up some risk state
|
|
_riskManager.OnPnLUpdate(-500, -500);
|
|
var fill = new OrderFill("test", "ES", 2, 4200, DateTime.UtcNow, 4.50, "exec1");
|
|
_riskManager.OnFill(fill);
|
|
|
|
// Act
|
|
_riskManager.ResetDaily();
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
status.DailyPnL.Should().Be(0);
|
|
status.MaxDrawdown.Should().Be(0);
|
|
status.TradingEnabled.Should().BeTrue();
|
|
status.OpenPositions.Should().Be(0);
|
|
status.ActiveAlerts.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void GetRiskStatus_ShouldReturnCurrentState()
|
|
{
|
|
// Arrange
|
|
var testPnL = -300.0;
|
|
_riskManager.OnPnLUpdate(testPnL, testPnL);
|
|
|
|
// Act
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
status.TradingEnabled.Should().BeTrue();
|
|
status.DailyPnL.Should().Be(testPnL);
|
|
status.MaxDrawdown.Should().Be(testPnL);
|
|
status.LastUpdate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
status.ActiveAlerts.Should().NotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcurrentAccess_ShouldBeThreadSafe()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
var tasks = new List<Task>();
|
|
|
|
// Act - Multiple threads accessing simultaneously
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
tasks.Add(Task.Run(() =>
|
|
{
|
|
_riskManager.ValidateOrder(intent, context, config);
|
|
_riskManager.OnPnLUpdate(-10 * i, -10 * i);
|
|
}));
|
|
}
|
|
|
|
Task.WaitAll(tasks.ToArray());
|
|
|
|
// Assert - Should not throw and should have consistent state
|
|
var status = _riskManager.GetRiskStatus();
|
|
status.Should().NotBeNull();
|
|
status.LastUpdate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// Cleanup if needed
|
|
}
|
|
}
|
|
```
|
|
|
|
## **RISK SCENARIO TEST DATA**
|
|
|
|
### **File 3: `tests/NT8.Core.Tests/Risk/RiskScenarioTests.cs`**
|
|
```csharp
|
|
using NT8.Core.Risk;
|
|
using NT8.Core.Common.Models;
|
|
using NT8.Core.Tests.TestHelpers;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace NT8.Core.Tests.Risk;
|
|
|
|
/// <summary>
|
|
/// Comprehensive risk scenario testing
|
|
/// These tests validate the risk manager against real-world trading scenarios
|
|
/// </summary>
|
|
public class RiskScenarioTests
|
|
{
|
|
private readonly BasicRiskManager _riskManager;
|
|
|
|
public RiskScenarioTests()
|
|
{
|
|
_riskManager = new BasicRiskManager(NullLogger<BasicRiskManager>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_TypicalTradingDay_ShouldManageRiskCorrectly()
|
|
{
|
|
// Arrange - Typical day configuration
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 1000,
|
|
MaxTradeRisk: 200,
|
|
MaxOpenPositions: 3,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Act & Assert - Morning trades should be allowed
|
|
var morningTrade1 = TestDataBuilder.CreateValidIntent(stopTicks: 8); // $100 risk
|
|
var result1 = _riskManager.ValidateOrder(morningTrade1, context, config);
|
|
result1.Allow.Should().BeTrue();
|
|
result1.RiskLevel.Should().Be(RiskLevel.Low);
|
|
|
|
// Simulate some losses
|
|
_riskManager.OnPnLUpdate(-150, -150);
|
|
|
|
var morningTrade2 = TestDataBuilder.CreateValidIntent(stopTicks: 12); // $150 risk
|
|
var result2 = _riskManager.ValidateOrder(morningTrade2, context, config);
|
|
result2.Allow.Should().BeTrue();
|
|
result2.RiskLevel.Should().Be(RiskLevel.Low);
|
|
|
|
// More losses - should escalate risk level
|
|
_riskManager.OnPnLUpdate(-600, -600);
|
|
|
|
var afternoonTrade = TestDataBuilder.CreateValidIntent(stopTicks: 8);
|
|
var result3 = _riskManager.ValidateOrder(afternoonTrade, context, config);
|
|
result3.Allow.Should().BeTrue();
|
|
result3.RiskLevel.Should().Be(RiskLevel.Medium); // Should escalate
|
|
|
|
// Near daily limit - high risk
|
|
_riskManager.OnPnLUpdate(-850, -850);
|
|
|
|
var lateTrade = TestDataBuilder.CreateValidIntent(stopTicks: 8);
|
|
var result4 = _riskManager.ValidateOrder(lateTrade, context, config);
|
|
result4.Allow.Should().BeTrue();
|
|
result4.RiskLevel.Should().Be(RiskLevel.High);
|
|
|
|
// Exceed daily limit - should halt
|
|
_riskManager.OnPnLUpdate(-1050, -1050);
|
|
|
|
var deniedTrade = TestDataBuilder.CreateValidIntent(stopTicks: 4);
|
|
var result5 = _riskManager.ValidateOrder(deniedTrade, context, config);
|
|
result5.Allow.Should().BeFalse();
|
|
result5.RiskLevel.Should().Be(RiskLevel.Critical);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_HighRiskTrade_ShouldBeRejected()
|
|
{
|
|
// Arrange - Conservative risk settings
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 2000,
|
|
MaxTradeRisk: 100, // Very conservative
|
|
MaxOpenPositions: 5,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Act - Try to place high-risk trade
|
|
var highRiskTrade = TestDataBuilder.CreateValidIntent(
|
|
symbol: "ES",
|
|
stopTicks: 20 // $250 risk, exceeds $100 limit
|
|
);
|
|
|
|
var result = _riskManager.ValidateOrder(highRiskTrade, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Trade risk too high");
|
|
result.RiskMetrics["trade_risk"].Should().Be(250.0); // 20 * $12.50
|
|
result.RiskMetrics["limit"].Should().Be(100.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_MaxPositions_ShouldLimitNewTrades()
|
|
{
|
|
// Arrange - Low position limit
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 5000,
|
|
MaxTradeRisk: 500,
|
|
MaxOpenPositions: 2,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Fill up position slots
|
|
var fill1 = new OrderFill("order1", "ES", 1, 4200, DateTime.UtcNow, 2.25, "exec1");
|
|
var fill2 = new OrderFill("order2", "NQ", 1, 15000, DateTime.UtcNow, 2.50, "exec2");
|
|
|
|
_riskManager.OnFill(fill1);
|
|
_riskManager.OnFill(fill2);
|
|
|
|
// Act - Try to add another position
|
|
var newTrade = TestDataBuilder.CreateValidIntent(symbol: "CL");
|
|
var result = _riskManager.ValidateOrder(newTrade, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Max open positions exceeded");
|
|
result.RiskLevel.Should().Be(RiskLevel.Medium);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_RecoveryAfterReset_ShouldAllowTrading()
|
|
{
|
|
// Arrange - Simulate end of bad trading day
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Simulate terrible day with emergency halt
|
|
_riskManager.OnPnLUpdate(-1500, -1500);
|
|
_riskManager.EmergencyFlatten("End of day").Wait();
|
|
|
|
var haltedTrade = TestDataBuilder.CreateValidIntent();
|
|
var haltedResult = _riskManager.ValidateOrder(haltedTrade, context, config);
|
|
haltedResult.Allow.Should().BeFalse();
|
|
|
|
// Act - Reset for new day
|
|
_riskManager.ResetDaily();
|
|
|
|
var newDayTrade = TestDataBuilder.CreateValidIntent();
|
|
var newResult = _riskManager.ValidateOrder(newDayTrade, context, config);
|
|
|
|
// Assert - Should be back to normal
|
|
newResult.Allow.Should().BeTrue();
|
|
newResult.RiskLevel.Should().Be(RiskLevel.Low);
|
|
|
|
var status = _riskManager.GetRiskStatus();
|
|
status.TradingEnabled.Should().BeTrue();
|
|
status.DailyPnL.Should().Be(0);
|
|
status.ActiveAlerts.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_VolatileMarket_ShouldHandleMultipleSymbols()
|
|
{
|
|
// Arrange - Multi-symbol trading
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 2000,
|
|
MaxTradeRisk: 300,
|
|
MaxOpenPositions: 4,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Act - Trade multiple symbols
|
|
var esTrade = TestDataBuilder.CreateValidIntent(symbol: "ES", stopTicks: 16); // $200 risk
|
|
var nqTrade = TestDataBuilder.CreateValidIntent(symbol: "NQ", stopTicks: 40); // $200 risk
|
|
var clTrade = TestDataBuilder.CreateValidIntent(symbol: "CL", stopTicks: 20); // $200 risk
|
|
|
|
var esResult = _riskManager.ValidateOrder(esTrade, context, config);
|
|
var nqResult = _riskManager.ValidateOrder(nqTrade, context, config);
|
|
var clResult = _riskManager.ValidateOrder(clTrade, context, config);
|
|
|
|
// Assert - All should be allowed
|
|
esResult.Allow.Should().BeTrue();
|
|
nqResult.Allow.Should().BeTrue();
|
|
clResult.Allow.Should().BeTrue();
|
|
|
|
// Verify risk calculations are symbol-specific
|
|
esResult.RiskMetrics["trade_risk"].Should().Be(200.0); // ES: 16 * $12.50
|
|
nqResult.RiskMetrics["trade_risk"].Should().Be(200.0); // NQ: 40 * $5.00
|
|
clResult.RiskMetrics["trade_risk"].Should().Be(200.0); // CL: 20 * $10.00
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_GradualLossEscalation_ShouldShowRiskProgression()
|
|
{
|
|
// Arrange
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 1000,
|
|
MaxTradeRisk: 200,
|
|
MaxOpenPositions: 5,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var intent = TestDataBuilder.CreateValidIntent(stopTicks: 8);
|
|
|
|
// Act & Assert - Track risk level escalation
|
|
var results = new List<(double loss, RiskLevel level)>();
|
|
|
|
// Start: No losses
|
|
var result0 = _riskManager.ValidateOrder(intent, context, config);
|
|
results.Add((0, result0.RiskLevel));
|
|
|
|
// 30% loss
|
|
_riskManager.OnPnLUpdate(-300, -300);
|
|
var result1 = _riskManager.ValidateOrder(intent, context, config);
|
|
results.Add((-300, result1.RiskLevel));
|
|
|
|
// 50% loss
|
|
_riskManager.OnPnLUpdate(-500, -500);
|
|
var result2 = _riskManager.ValidateOrder(intent, context, config);
|
|
results.Add((-500, result2.RiskLevel));
|
|
|
|
// 80% loss
|
|
_riskManager.OnPnLUpdate(-800, -800);
|
|
var result3 = _riskManager.ValidateOrder(intent, context, config);
|
|
results.Add((-800, result3.RiskLevel));
|
|
|
|
// Assert escalation pattern
|
|
results[0].level.Should().Be(RiskLevel.Low); // 0% loss
|
|
results[1].level.Should().Be(RiskLevel.Low); // 30% loss
|
|
results[2].level.Should().Be(RiskLevel.Medium); // 50% loss
|
|
results[3].level.Should().Be(RiskLevel.High); // 80% loss
|
|
}
|
|
}
|
|
```
|
|
|
|
## **VALIDATION SCRIPT**
|
|
|
|
### **File 4: `tools/validate-risk-implementation.ps1`**
|
|
```powershell
|
|
# Risk Management Validation Script
|
|
Write-Host "🔍 Validating Risk Management Implementation..." -ForegroundColor Yellow
|
|
|
|
# Build check
|
|
Write-Host "📦 Building solution..." -ForegroundColor Blue
|
|
$buildResult = dotnet build --configuration Release --verbosity quiet
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Host "❌ Build failed" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
# Test execution
|
|
Write-Host "🧪 Running risk management tests..." -ForegroundColor Blue
|
|
$testResult = dotnet test tests/NT8.Core.Tests/NT8.Core.Tests.csproj --filter "Category=Risk|FullyQualifiedName~Risk" --configuration Release --verbosity quiet
|
|
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Host "❌ Risk tests failed" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
# Specific validation scenarios
|
|
Write-Host "✅ Running validation scenarios..." -ForegroundColor Blue
|
|
|
|
$validationTests = @(
|
|
"BasicRiskManagerTests.ValidateOrder_WithinLimits_ShouldAllow",
|
|
"BasicRiskManagerTests.ValidateOrder_ExceedsDailyLimit_ShouldReject",
|
|
"BasicRiskManagerTests.ValidateOrder_ExceedsTradeRisk_ShouldReject",
|
|
"RiskScenarioTests.Scenario_TypicalTradingDay_ShouldManageRiskCorrectly"
|
|
)
|
|
|
|
foreach ($test in $validationTests) {
|
|
$result = dotnet test --filter "FullyQualifiedName~$test" --configuration Release --verbosity quiet
|
|
if ($LASTEXITCODE -eq 0) {
|
|
Write-Host " ✅ $test" -ForegroundColor Green
|
|
} else {
|
|
Write-Host " ❌ $test" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
Write-Host "🎉 Risk management validation completed successfully!" -ForegroundColor Green
|
|
```
|
|
|
|
## **SUCCESS CRITERIA**
|
|
|
|
✅ **BasicRiskManager.cs implemented exactly as specified**
|
|
✅ **All Tier 1 risk controls working (daily limits, trade limits, position limits)**
|
|
✅ **Thread-safe implementation using locks**
|
|
✅ **Emergency flatten functionality**
|
|
✅ **Comprehensive test suite with >90% coverage**
|
|
✅ **All validation scenarios pass**
|
|
✅ **Risk level escalation working correctly**
|
|
✅ **Multi-symbol risk calculations accurate**
|
|
|
|
## **CRITICAL REQUIREMENTS**
|
|
|
|
1. **Thread Safety**: All methods must be thread-safe using lock(_lock)
|
|
2. **Exact Risk Calculations**: Must match the specified tick values per symbol
|
|
3. **State Management**: Daily P&L and position tracking must be accurate
|
|
4. **Error Handling**: All null parameters must throw ArgumentNullException
|
|
5. **Logging**: All significant events must be logged at appropriate levels
|
|
|
|
**Once this is complete, risk management is fully functional and ready for integration with position sizing.** |