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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user