Phase 0 completion: NT8 SDK core framework with risk management and position sizing
Some checks failed
Build and Test / build (push) Has been cancelled
Some checks failed
Build and Test / build (push) Has been cancelled
This commit is contained in:
935
Specs/SDK/risk_management_package.md
Normal file
935
Specs/SDK/risk_management_package.md
Normal file
@@ -0,0 +1,935 @@
|
||||
# **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.**
|
||||
Reference in New Issue
Block a user