diff --git a/src/NT8.Adapters/Strategies/NT8StrategyBase.cs b/src/NT8.Adapters/Strategies/NT8StrategyBase.cs
index 5340070..646e198 100644
--- a/src/NT8.Adapters/Strategies/NT8StrategyBase.cs
+++ b/src/NT8.Adapters/Strategies/NT8StrategyBase.cs
@@ -53,6 +53,7 @@ namespace NinjaTrader.NinjaScript.Strategies
private int _ordersSubmittedToday;
private DateTime _lastBarTime;
private bool _killSwitchTriggered;
+ private bool _connectionLost;
private ExecutionCircuitBreaker _circuitBreaker;
private System.IO.StreamWriter _fileLog;
private readonly object _fileLock = new object();
@@ -194,6 +195,7 @@ namespace NinjaTrader.NinjaScript.Strategies
EnableLongTrades = true;
EnableShortTrades = true;
_killSwitchTriggered = false;
+ _connectionLost = false;
}
else if (State == State.DataLoaded)
{
@@ -220,6 +222,7 @@ namespace NinjaTrader.NinjaScript.Strategies
}
else if (State == State.Terminated)
{
+ PortfolioRiskManager.Instance.UnregisterStrategy(Name);
WriteSessionFooter();
}
}
@@ -261,6 +264,14 @@ namespace NinjaTrader.NinjaScript.Strategies
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.
if (CurrentBar == BarsRequiredToTrade || CurrentBar % 100 == 0)
{
@@ -357,9 +368,54 @@ namespace NinjaTrader.NinjaScript.Strategies
execution.Price,
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);
}
+ ///
+ /// Handles broker connection status changes. Halts new orders on disconnect,
+ /// logs reconnect, and resets the connection flag when restored.
+ ///
+ 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()
{
if (!EnableFileLogging)
@@ -418,6 +474,7 @@ namespace NinjaTrader.NinjaScript.Strategies
RiskPerTrade));
FileLog(string.Format("Sizing : MinContracts={0} MaxContracts={1}", MinContracts, MaxContracts));
FileLog(string.Format("VerboseLog : {0} FileLog: {1}", EnableVerboseLogging, EnableFileLogging));
+ FileLog(string.Format("ConnectionLost : {0}", _connectionLost));
FileLog("---");
}
@@ -481,6 +538,8 @@ namespace NinjaTrader.NinjaScript.Strategies
_sdkStrategy.Initialize(_strategyConfig, null, _logger);
ConfigureStrategyParameters();
+ PortfolioRiskManager.Instance.RegisterStrategy(Name, _riskConfig);
+ Print(string.Format("[NT8-SDK] Registered with PortfolioRiskManager: {0}", PortfolioRiskManager.Instance.GetStatusSnapshot()));
_ordersSubmittedToday = 0;
_lastBarTime = DateTime.MinValue;
@@ -590,6 +649,16 @@ namespace NinjaTrader.NinjaScript.Strategies
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
if (intent.Side == SdkOrderSide.Buy && !EnableLongTrades)
{
diff --git a/src/NT8.Adapters/Strategies/SimpleORBNT8.cs b/src/NT8.Adapters/Strategies/SimpleORBNT8.cs
index 30eb704..300523f 100644
--- a/src/NT8.Adapters/Strategies/SimpleORBNT8.cs
+++ b/src/NT8.Adapters/Strategies/SimpleORBNT8.cs
@@ -70,6 +70,7 @@ namespace NinjaTrader.NinjaScript.Strategies
Calculate = Calculate.OnBarClose;
BarsRequiredToTrade = 50;
EnableLongTrades = true;
+ // Long-only: short trades permanently disabled pending backtest confirmation
EnableShortTrades = false;
}
else if (State == State.Configure)
diff --git a/src/NT8.Core/Risk/PortfolioRiskManager.cs b/src/NT8.Core/Risk/PortfolioRiskManager.cs
new file mode 100644
index 0000000..65f7429
--- /dev/null
+++ b/src/NT8.Core/Risk/PortfolioRiskManager.cs
@@ -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
+{
+ ///
+ /// 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.
+ ///
+ public class PortfolioRiskManager
+ {
+ private static readonly object _instanceLock = new object();
+ private static PortfolioRiskManager _instance;
+
+ ///
+ /// Gets the singleton instance of PortfolioRiskManager.
+ ///
+ 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 _registeredStrategies;
+ private readonly Dictionary _strategyPnL;
+ private readonly Dictionary _strategyOpenContracts;
+
+ ///
+ /// Maximum combined daily loss across all registered strategies before all trading halts.
+ /// Default: 2000.0
+ ///
+ public double PortfolioDailyLossLimit { get; set; }
+
+ ///
+ /// Maximum total open contracts across all registered strategies simultaneously.
+ /// Default: 6
+ ///
+ public int MaxTotalOpenContracts { get; set; }
+
+ ///
+ /// When true, all new orders across all strategies are blocked immediately.
+ /// Set to true to perform an emergency halt of the entire portfolio.
+ ///
+ public bool PortfolioKillSwitch { get; set; }
+
+ private PortfolioRiskManager()
+ {
+ _registeredStrategies = new Dictionary();
+ _strategyPnL = new Dictionary();
+ _strategyOpenContracts = new Dictionary();
+ PortfolioDailyLossLimit = 2000.0;
+ MaxTotalOpenContracts = 6;
+ PortfolioKillSwitch = false;
+ }
+
+ ///
+ /// Registers a strategy with the portfolio manager. Called from
+ /// NT8StrategyBase.InitializeSdkComponents() during State.DataLoaded.
+ ///
+ /// Unique strategy identifier (use Name from NT8StrategyBase).
+ /// The strategy's risk configuration.
+ /// strategyId or config is null.
+ 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;
+ }
+ }
+
+ ///
+ /// Unregisters a strategy. Called from NT8StrategyBase during State.Terminated.
+ ///
+ /// Strategy identifier to unregister.
+ public void UnregisterStrategy(string strategyId)
+ {
+ if (string.IsNullOrEmpty(strategyId)) return;
+
+ lock (_lock)
+ {
+ _registeredStrategies.Remove(strategyId);
+ _strategyPnL.Remove(strategyId);
+ _strategyOpenContracts.Remove(strategyId);
+ }
+ }
+
+ ///
+ /// Validates a new order intent against portfolio-level risk limits.
+ /// Called before per-strategy risk validation in ProcessStrategyIntent().
+ ///
+ /// The strategy requesting the order.
+ /// The trade intent to validate.
+ /// RiskDecision indicating whether the order is allowed.
+ 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();
+ 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();
+ 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();
+ 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();
+ okMetrics.Add("portfolio_pnl", totalPnL);
+ okMetrics.Add("total_contracts", totalContracts);
+ return new RiskDecision(
+ allow: true,
+ rejectReason: null,
+ modifiedIntent: null,
+ riskLevel: RiskLevel.Low,
+ riskMetrics: okMetrics);
+ }
+ }
+
+ ///
+ /// Reports a fill to the portfolio manager. Updates open contract count for the strategy.
+ /// Called from NT8StrategyBase.OnExecutionUpdate() after each fill.
+ ///
+ /// Strategy that received the fill.
+ /// Fill details.
+ 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;
+ }
+ }
+
+ ///
+ /// Reports a P&L update for a strategy. Called from NT8StrategyBase
+ /// whenever the strategy's realized P&L changes (typically on position close).
+ ///
+ /// Strategy reporting P&L.
+ /// Current cumulative day P&L for this strategy.
+ public void ReportPnL(string strategyId, double pnl)
+ {
+ if (string.IsNullOrEmpty(strategyId)) return;
+
+ lock (_lock)
+ {
+ _strategyPnL[strategyId] = pnl;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ public void ResetDaily()
+ {
+ lock (_lock)
+ {
+ var keys = new List(_strategyPnL.Keys);
+ foreach (var key in keys)
+ _strategyPnL[key] = 0.0;
+ }
+ }
+
+ ///
+ /// Returns a snapshot of current portfolio state for diagnostics.
+ ///
+ 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);
+ }
+ }
+ }
+}
diff --git a/tests/NT8.Core.Tests/Risk/PortfolioRiskManagerTests.cs b/tests/NT8.Core.Tests/Risk/PortfolioRiskManagerTests.cs
new file mode 100644
index 0000000..e586221
--- /dev/null
+++ b/tests/NT8.Core.Tests/Risk/PortfolioRiskManagerTests.cs
@@ -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);
+ }
+ }
+}