Some checks failed
Build and Test / build (push) Has been cancelled
- Move SetStopLoss/SetProfitTarget before EnterLong/EnterShort in SubmitOrderToNT8(). NT8 requires stop/target pre-registered to the signal name before entry fires — calling after caused silent fallback to strategy defaults in backtest, nullifying all dynamic target scaling. Add [dynamic] tag to SUBMIT log line for observability. - Wire OnPositionUpdate() in NT8StrategyBase to call _riskManager.OnPnLUpdate() and PortfolioRiskManager.ReportPnL() on every position change. Daily loss limit checks were evaluating against PnL permanently — safety net now live. - Fix BasicRiskManager.CheckEmergencyConditions() hardcoded 1000 limit. Add dailyLossLimit constructor overload; emergency halt now fires at 90% of the configured DailyLossLimit, not a hardcoded . - Add AVWAP slope hard veto in SimpleORBStrategy.OnBar(). Trend=0.00 (AVWAP working against trade direction) can no longer pass grade filter. Pre-scoring veto prevents high gap/volume scores from masking a directionally broken setup. - Raise default MinTradeGrade from 4 (B) to 5 (A) in both SimpleORBStrategy and SimpleORBNT8 defaults. - Scale TargetTicks dynamically in CreateIntent() based on ORB/ATR ratio (0.75x-1.75x of base target). Tight ORBs get extended targets; wide ORBs get tightened. Hard floor at stopTicks+4. - Add NarrowRange, OrbRangeVsAtr, GapDirectionAlignment, BreakoutVolumeStrength, PriorDayCloseStrength default weights in ConfluenceScorer constructor. All 10 factors now registered. - Fix session_open_price tracking for GapDirectionAlignment factor. TodayOpen in DailyBarContext now reflects first RTH bar open, not the breakout bar open. - Log all 10 factor scores at signal acceptance for observability.
300 lines
11 KiB
C#
300 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 double _configuredDailyLossLimit;
|
|
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;
|
|
_configuredDailyLossLimit = 1000.0;
|
|
}
|
|
|
|
public BasicRiskManager(ILogger logger, double dailyLossLimit)
|
|
{
|
|
if (logger == null) throw new ArgumentNullException("logger");
|
|
if (dailyLossLimit <= 0.0) throw new ArgumentException("dailyLossLimit must be positive", "dailyLossLimit");
|
|
_logger = logger;
|
|
_configuredDailyLossLimit = dailyLossLimit;
|
|
}
|
|
|
|
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 configured limit
|
|
if (dayPnL <= -(_configuredDailyLossLimit * 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");
|
|
}
|
|
}
|
|
}
|
|
}
|