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); + } + } +}