S2-B3/S3-05: PortfolioRiskManager, connection loss recovery, long-only lock
Some checks failed
Build and Test / build (push) Has been cancelled

This commit is contained in:
2026-03-19 16:17:02 -04:00
parent 3282254572
commit 2f623dc2f8
4 changed files with 452 additions and 0 deletions

View File

@@ -53,6 +53,7 @@ namespace NinjaTrader.NinjaScript.Strategies
private int _ordersSubmittedToday; private int _ordersSubmittedToday;
private DateTime _lastBarTime; private DateTime _lastBarTime;
private bool _killSwitchTriggered; private bool _killSwitchTriggered;
private bool _connectionLost;
private ExecutionCircuitBreaker _circuitBreaker; private ExecutionCircuitBreaker _circuitBreaker;
private System.IO.StreamWriter _fileLog; private System.IO.StreamWriter _fileLog;
private readonly object _fileLock = new object(); private readonly object _fileLock = new object();
@@ -194,6 +195,7 @@ namespace NinjaTrader.NinjaScript.Strategies
EnableLongTrades = true; EnableLongTrades = true;
EnableShortTrades = true; EnableShortTrades = true;
_killSwitchTriggered = false; _killSwitchTriggered = false;
_connectionLost = false;
} }
else if (State == State.DataLoaded) else if (State == State.DataLoaded)
{ {
@@ -220,6 +222,7 @@ namespace NinjaTrader.NinjaScript.Strategies
} }
else if (State == State.Terminated) else if (State == State.Terminated)
{ {
PortfolioRiskManager.Instance.UnregisterStrategy(Name);
WriteSessionFooter(); WriteSessionFooter();
} }
} }
@@ -261,6 +264,14 @@ namespace NinjaTrader.NinjaScript.Strategies
return; return;
} }
// Connection loss guard — do not submit new orders if broker is disconnected
if (_connectionLost)
{
if (EnableVerboseLogging)
Print(string.Format("[NT8-SDK] Bar skipped — connection lost: {0}", Time[0]));
return;
}
// Log first processable bar and every 100th bar. // Log first processable bar and every 100th bar.
if (CurrentBar == BarsRequiredToTrade || CurrentBar % 100 == 0) if (CurrentBar == BarsRequiredToTrade || CurrentBar % 100 == 0)
{ {
@@ -357,9 +368,54 @@ namespace NinjaTrader.NinjaScript.Strategies
execution.Price, execution.Price,
execution.OrderId)); execution.OrderId));
var fill = new NT8.Core.Common.Models.OrderFill(
orderId,
execution.Order != null ? execution.Order.Instrument.MasterInstrument.Name : string.Empty,
execution.Quantity,
execution.Price,
time,
0.0,
executionId);
PortfolioRiskManager.Instance.ReportFill(Name, fill);
_executionAdapter.ProcessExecution(orderId, executionId, price, quantity, time); _executionAdapter.ProcessExecution(orderId, executionId, price, quantity, time);
} }
/// <summary>
/// Handles broker connection status changes. Halts new orders on disconnect,
/// logs reconnect, and resets the connection flag when restored.
/// </summary>
protected override void OnConnectionStatusUpdate(
Connection connection,
ConnectionStatus status,
DateTime time)
{
if (connection == null) return;
if (status == ConnectionStatus.Connected)
{
if (_connectionLost)
{
_connectionLost = false;
Print(string.Format("[NT8-SDK] Connection RESTORED at {0} — trading resumed.",
time.ToString("HH:mm:ss")));
FileLog(string.Format("CONNECTION RESTORED at {0}", time.ToString("HH:mm:ss")));
}
}
else if (status == ConnectionStatus.Disconnected ||
status == ConnectionStatus.ConnectionLost)
{
if (!_connectionLost)
{
_connectionLost = true;
Print(string.Format("[NT8-SDK] Connection LOST at {0} — halting new orders. Status={1}",
time.ToString("HH:mm:ss"),
status));
FileLog(string.Format("CONNECTION LOST at {0} Status={1}", time.ToString("HH:mm:ss"), status));
}
}
}
private void InitFileLog() private void InitFileLog()
{ {
if (!EnableFileLogging) if (!EnableFileLogging)
@@ -418,6 +474,7 @@ namespace NinjaTrader.NinjaScript.Strategies
RiskPerTrade)); RiskPerTrade));
FileLog(string.Format("Sizing : MinContracts={0} MaxContracts={1}", MinContracts, MaxContracts)); FileLog(string.Format("Sizing : MinContracts={0} MaxContracts={1}", MinContracts, MaxContracts));
FileLog(string.Format("VerboseLog : {0} FileLog: {1}", EnableVerboseLogging, EnableFileLogging)); FileLog(string.Format("VerboseLog : {0} FileLog: {1}", EnableVerboseLogging, EnableFileLogging));
FileLog(string.Format("ConnectionLost : {0}", _connectionLost));
FileLog("---"); FileLog("---");
} }
@@ -481,6 +538,8 @@ namespace NinjaTrader.NinjaScript.Strategies
_sdkStrategy.Initialize(_strategyConfig, null, _logger); _sdkStrategy.Initialize(_strategyConfig, null, _logger);
ConfigureStrategyParameters(); ConfigureStrategyParameters();
PortfolioRiskManager.Instance.RegisterStrategy(Name, _riskConfig);
Print(string.Format("[NT8-SDK] Registered with PortfolioRiskManager: {0}", PortfolioRiskManager.Instance.GetStatusSnapshot()));
_ordersSubmittedToday = 0; _ordersSubmittedToday = 0;
_lastBarTime = DateTime.MinValue; _lastBarTime = DateTime.MinValue;
@@ -590,6 +649,16 @@ namespace NinjaTrader.NinjaScript.Strategies
private void ProcessStrategyIntent(StrategyIntent intent, StrategyContext context) private void ProcessStrategyIntent(StrategyIntent intent, StrategyContext context)
{ {
// Portfolio-level risk check — runs before per-strategy risk validation
var portfolioDecision = PortfolioRiskManager.Instance.ValidatePortfolioRisk(Name, intent);
if (!portfolioDecision.Allow)
{
Print(string.Format("[SDK] Portfolio blocked: {0}", portfolioDecision.RejectReason));
if (_logger != null)
_logger.LogWarning("Portfolio risk blocked order: {0}", portfolioDecision.RejectReason);
return;
}
// Direction filter — checked before risk to avoid unnecessary processing // Direction filter — checked before risk to avoid unnecessary processing
if (intent.Side == SdkOrderSide.Buy && !EnableLongTrades) if (intent.Side == SdkOrderSide.Buy && !EnableLongTrades)
{ {

View File

@@ -70,6 +70,7 @@ namespace NinjaTrader.NinjaScript.Strategies
Calculate = Calculate.OnBarClose; Calculate = Calculate.OnBarClose;
BarsRequiredToTrade = 50; BarsRequiredToTrade = 50;
EnableLongTrades = true; EnableLongTrades = true;
// Long-only: short trades permanently disabled pending backtest confirmation
EnableShortTrades = false; EnableShortTrades = false;
} }
else if (State == State.Configure) else if (State == State.Configure)

View File

@@ -0,0 +1,265 @@
// File: PortfolioRiskManager.cs
using System;
using System.Collections.Generic;
using NT8.Core.Common.Models;
using NT8.Core.Logging;
namespace NT8.Core.Risk
{
/// <summary>
/// Portfolio-level risk coordinator. Singleton. Enforces cross-strategy
/// daily loss limits, maximum open contract caps, and a portfolio kill switch.
/// Must be registered by each strategy on init and unregistered on terminate.
/// Thread-safe via a single lock object.
/// </summary>
public class PortfolioRiskManager
{
private static readonly object _instanceLock = new object();
private static PortfolioRiskManager _instance;
/// <summary>
/// Gets the singleton instance of PortfolioRiskManager.
/// </summary>
public static PortfolioRiskManager Instance
{
get
{
if (_instance == null)
{
lock (_instanceLock)
{
if (_instance == null)
_instance = new PortfolioRiskManager();
}
}
return _instance;
}
}
private readonly object _lock = new object();
private readonly Dictionary<string, RiskConfig> _registeredStrategies;
private readonly Dictionary<string, double> _strategyPnL;
private readonly Dictionary<string, int> _strategyOpenContracts;
/// <summary>
/// Maximum combined daily loss across all registered strategies before all trading halts.
/// Default: 2000.0
/// </summary>
public double PortfolioDailyLossLimit { get; set; }
/// <summary>
/// Maximum total open contracts across all registered strategies simultaneously.
/// Default: 6
/// </summary>
public int MaxTotalOpenContracts { get; set; }
/// <summary>
/// When true, all new orders across all strategies are blocked immediately.
/// Set to true to perform an emergency halt of the entire portfolio.
/// </summary>
public bool PortfolioKillSwitch { get; set; }
private PortfolioRiskManager()
{
_registeredStrategies = new Dictionary<string, RiskConfig>();
_strategyPnL = new Dictionary<string, double>();
_strategyOpenContracts = new Dictionary<string, int>();
PortfolioDailyLossLimit = 2000.0;
MaxTotalOpenContracts = 6;
PortfolioKillSwitch = false;
}
/// <summary>
/// Registers a strategy with the portfolio manager. Called from
/// NT8StrategyBase.InitializeSdkComponents() during State.DataLoaded.
/// </summary>
/// <param name="strategyId">Unique strategy identifier (use Name from NT8StrategyBase).</param>
/// <param name="config">The strategy's risk configuration.</param>
/// <exception cref="ArgumentNullException">strategyId or config is null.</exception>
public void RegisterStrategy(string strategyId, RiskConfig config)
{
if (string.IsNullOrEmpty(strategyId)) throw new ArgumentNullException("strategyId");
if (config == null) throw new ArgumentNullException("config");
lock (_lock)
{
_registeredStrategies[strategyId] = config;
if (!_strategyPnL.ContainsKey(strategyId))
_strategyPnL[strategyId] = 0.0;
if (!_strategyOpenContracts.ContainsKey(strategyId))
_strategyOpenContracts[strategyId] = 0;
}
}
/// <summary>
/// Unregisters a strategy. Called from NT8StrategyBase during State.Terminated.
/// </summary>
/// <param name="strategyId">Strategy identifier to unregister.</param>
public void UnregisterStrategy(string strategyId)
{
if (string.IsNullOrEmpty(strategyId)) return;
lock (_lock)
{
_registeredStrategies.Remove(strategyId);
_strategyPnL.Remove(strategyId);
_strategyOpenContracts.Remove(strategyId);
}
}
/// <summary>
/// Validates a new order intent against portfolio-level risk limits.
/// Called before per-strategy risk validation in ProcessStrategyIntent().
/// </summary>
/// <param name="strategyId">The strategy requesting the order.</param>
/// <param name="intent">The trade intent to validate.</param>
/// <returns>RiskDecision indicating whether the order is allowed.</returns>
public RiskDecision ValidatePortfolioRisk(string strategyId, StrategyIntent intent)
{
if (string.IsNullOrEmpty(strategyId)) throw new ArgumentNullException("strategyId");
if (intent == null) throw new ArgumentNullException("intent");
lock (_lock)
{
// Kill switch — blocks everything immediately
if (PortfolioKillSwitch)
{
var ksMetrics = new Dictionary<string, object>();
ksMetrics.Add("kill_switch", true);
return new RiskDecision(
allow: false,
rejectReason: "Portfolio kill switch is active — all trading halted",
modifiedIntent: null,
riskLevel: RiskLevel.Critical,
riskMetrics: ksMetrics);
}
// Portfolio daily loss limit
double totalPnL = 0.0;
foreach (var kvp in _strategyPnL)
totalPnL += kvp.Value;
if (totalPnL <= -PortfolioDailyLossLimit)
{
var pnlMetrics = new Dictionary<string, object>();
pnlMetrics.Add("portfolio_pnl", totalPnL);
pnlMetrics.Add("limit", PortfolioDailyLossLimit);
return new RiskDecision(
allow: false,
rejectReason: String.Format(
"Portfolio daily loss limit breached: {0:C} <= -{1:C}",
totalPnL, PortfolioDailyLossLimit),
modifiedIntent: null,
riskLevel: RiskLevel.Critical,
riskMetrics: pnlMetrics);
}
// Total open contract cap
int totalContracts = 0;
foreach (var kvp in _strategyOpenContracts)
totalContracts += kvp.Value;
if (totalContracts >= MaxTotalOpenContracts)
{
var contractMetrics = new Dictionary<string, object>();
contractMetrics.Add("total_contracts", totalContracts);
contractMetrics.Add("limit", MaxTotalOpenContracts);
return new RiskDecision(
allow: false,
rejectReason: String.Format(
"Portfolio contract cap reached: {0} >= {1}",
totalContracts, MaxTotalOpenContracts),
modifiedIntent: null,
riskLevel: RiskLevel.High,
riskMetrics: contractMetrics);
}
// All portfolio checks passed
var okMetrics = new Dictionary<string, object>();
okMetrics.Add("portfolio_pnl", totalPnL);
okMetrics.Add("total_contracts", totalContracts);
return new RiskDecision(
allow: true,
rejectReason: null,
modifiedIntent: null,
riskLevel: RiskLevel.Low,
riskMetrics: okMetrics);
}
}
/// <summary>
/// Reports a fill to the portfolio manager. Updates open contract count for the strategy.
/// Called from NT8StrategyBase.OnExecutionUpdate() after each fill.
/// </summary>
/// <param name="strategyId">Strategy that received the fill.</param>
/// <param name="fill">Fill details.</param>
public void ReportFill(string strategyId, OrderFill fill)
{
if (string.IsNullOrEmpty(strategyId) || fill == null) return;
lock (_lock)
{
if (!_strategyOpenContracts.ContainsKey(strategyId))
_strategyOpenContracts[strategyId] = 0;
_strategyOpenContracts[strategyId] += fill.Quantity;
if (_strategyOpenContracts[strategyId] < 0)
_strategyOpenContracts[strategyId] = 0;
}
}
/// <summary>
/// Reports a P&L update for a strategy. Called from NT8StrategyBase
/// whenever the strategy's realized P&L changes (typically on position close).
/// </summary>
/// <param name="strategyId">Strategy reporting P&L.</param>
/// <param name="pnl">Current cumulative day P&L for this strategy.</param>
public void ReportPnL(string strategyId, double pnl)
{
if (string.IsNullOrEmpty(strategyId)) return;
lock (_lock)
{
_strategyPnL[strategyId] = pnl;
}
}
/// <summary>
/// Resets daily P&L accumulators for all strategies. Does not clear registrations
/// or open contract counts. Typically called at the start of a new trading day.
/// </summary>
public void ResetDaily()
{
lock (_lock)
{
var keys = new List<string>(_strategyPnL.Keys);
foreach (var key in keys)
_strategyPnL[key] = 0.0;
}
}
/// <summary>
/// Returns a snapshot of current portfolio state for diagnostics.
/// </summary>
public string GetStatusSnapshot()
{
lock (_lock)
{
double totalPnL = 0.0;
foreach (var kvp in _strategyPnL)
totalPnL += kvp.Value;
int totalContracts = 0;
foreach (var kvp in _strategyOpenContracts)
totalContracts += kvp.Value;
return String.Format(
"Portfolio: strategies={0} totalPnL={1:C} totalContracts={2} killSwitch={3}",
_registeredStrategies.Count,
totalPnL,
totalContracts,
PortfolioKillSwitch);
}
}
}
}

View File

@@ -0,0 +1,117 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NT8.Core.Common.Models;
using NT8.Core.Risk;
namespace NT8.Core.Tests.Risk
{
[TestClass]
public class PortfolioRiskManagerTests
{
private PortfolioRiskManager _manager;
[TestInitialize]
public void TestInitialize()
{
_manager = PortfolioRiskManager.Instance;
}
[TestCleanup]
public void TestCleanup()
{
_manager.UnregisterStrategy("strat1");
_manager.UnregisterStrategy("strat2");
_manager.UnregisterStrategy("strat3");
_manager.UnregisterStrategy("strat4");
_manager.UnregisterStrategy("strat5");
_manager.PortfolioKillSwitch = false;
_manager.PortfolioDailyLossLimit = 2000.0;
_manager.MaxTotalOpenContracts = 6;
_manager.ResetDaily();
}
[TestMethod]
public void PortfolioDailyLossLimit_WhenBreached_BlocksNewOrder()
{
// Arrange
_manager.RegisterStrategy("strat1", TestDataBuilder.CreateTestRiskConfig());
_manager.PortfolioDailyLossLimit = 500;
_manager.ReportPnL("strat1", -501);
var intent = TestDataBuilder.CreateValidIntent();
// Act
var decision = _manager.ValidatePortfolioRisk("strat1", intent);
// Assert
Assert.IsFalse(decision.Allow);
}
[TestMethod]
public void MaxTotalOpenContracts_WhenAtCap_BlocksNewOrder()
{
// Arrange
_manager.RegisterStrategy("strat1", TestDataBuilder.CreateTestRiskConfig());
_manager.MaxTotalOpenContracts = 2;
var fill1 = new OrderFill("ord1", "ES", 1, 5000.0, System.DateTime.UtcNow, 0.0, "exec1");
var fill2 = new OrderFill("ord2", "ES", 1, 5001.0, System.DateTime.UtcNow, 0.0, "exec2");
_manager.ReportFill("strat1", fill1);
_manager.ReportFill("strat1", fill2);
var intent = TestDataBuilder.CreateValidIntent();
// Act
var decision = _manager.ValidatePortfolioRisk("strat1", intent);
// Assert
Assert.IsFalse(decision.Allow);
}
[TestMethod]
public void PortfolioKillSwitch_WhenTrue_BlocksAllOrders()
{
// Arrange
_manager.RegisterStrategy("strat1", TestDataBuilder.CreateTestRiskConfig());
_manager.PortfolioKillSwitch = true;
var intent = TestDataBuilder.CreateValidIntent();
// Act
var decision = _manager.ValidatePortfolioRisk("strat1", intent);
// Assert
Assert.IsFalse(decision.Allow);
Assert.IsTrue(decision.RejectReason.ToLowerInvariant().Contains("kill switch"));
}
[TestMethod]
public void ValidatePortfolioRisk_WhenWithinLimits_Passes()
{
// Arrange
_manager.RegisterStrategy("strat1", TestDataBuilder.CreateTestRiskConfig());
var intent = TestDataBuilder.CreateValidIntent();
// Act
var decision = _manager.ValidatePortfolioRisk("strat1", intent);
// Assert
Assert.IsTrue(decision.Allow);
}
[TestMethod]
public void ResetDaily_ClearsPnL_UnblocksTrading()
{
// Arrange
_manager.RegisterStrategy("strat1", TestDataBuilder.CreateTestRiskConfig());
_manager.PortfolioDailyLossLimit = 500;
_manager.ReportPnL("strat1", -600);
var intent = TestDataBuilder.CreateValidIntent();
// Act
var blocked = _manager.ValidatePortfolioRisk("strat1", intent);
_manager.ResetDaily();
var unblocked = _manager.ValidatePortfolioRisk("strat1", intent);
// Assert
Assert.IsFalse(blocked.Allow);
Assert.IsTrue(unblocked.Allow);
}
}
}