S2-B3/S3-05: PortfolioRiskManager, connection loss recovery, long-only lock
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:
@@ -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);
|
||||
}
|
||||
|
||||
/// <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()
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
265
src/NT8.Core/Risk/PortfolioRiskManager.cs
Normal file
265
src/NT8.Core/Risk/PortfolioRiskManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
tests/NT8.Core.Tests/Risk/PortfolioRiskManagerTests.cs
Normal file
117
tests/NT8.Core.Tests/Risk/PortfolioRiskManagerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user