diff --git a/src/NT8.Core/Common/Models/Configuration.cs b/src/NT8.Core/Common/Models/Configuration.cs index 59fbe1e..aa762d3 100644 --- a/src/NT8.Core/Common/Models/Configuration.cs +++ b/src/NT8.Core/Common/Models/Configuration.cs @@ -163,7 +163,17 @@ namespace NT8.Core.Common.Models /// /// Optimal F calculation /// - OptimalF + OptimalF, + + /// + /// Kelly Criterion sizing + /// + KellyCriterion, + + /// + /// Volatility-adjusted sizing + /// + VolatilityAdjusted } /// @@ -438,4 +448,4 @@ namespace NT8.Core.Common.Models ExecutionId = executionId; } } -} \ No newline at end of file +} diff --git a/src/NT8.Core/Sizing/AdvancedPositionSizer.cs b/src/NT8.Core/Sizing/AdvancedPositionSizer.cs new file mode 100644 index 0000000..4011ab8 --- /dev/null +++ b/src/NT8.Core/Sizing/AdvancedPositionSizer.cs @@ -0,0 +1,497 @@ +using NT8.Core.Common.Models; +using NT8.Core.Logging; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NT8.Core.Sizing +{ + /// + /// Advanced position sizer with Optimal f, Kelly Criterion, and volatility-adjusted methods + /// Implements sophisticated position sizing algorithms for professional trading + /// + public class AdvancedPositionSizer : IPositionSizer + { + private readonly ILogger _logger; + + public AdvancedPositionSizer(ILogger logger) + { + if (logger == null) throw new ArgumentNullException("logger"); + _logger = logger; + } + + public SizingResult CalculateSize(StrategyIntent intent, StrategyContext context, SizingConfig config) + { + if (intent == null) throw new ArgumentNullException("intent"); + if (context == null) throw new ArgumentNullException("context"); + if (config == null) throw new ArgumentNullException("config"); + + // Validate intent is suitable for sizing + if (!intent.IsValid()) + { + _logger.LogWarning("Invalid strategy intent provided for sizing: {0}", intent); + + var errorCalcs = new Dictionary(); + errorCalcs.Add("error", "Invalid intent"); + + return new SizingResult(0, 0, config.Method, errorCalcs); + } + + switch (config.Method) + { + case SizingMethod.OptimalF: + return CalculateOptimalF(intent, context, config); + case SizingMethod.KellyCriterion: + return CalculateKellyCriterion(intent, context, config); + case SizingMethod.VolatilityAdjusted: + return CalculateVolatilityAdjustedSizing(intent, context, config); + default: + throw new NotSupportedException(String.Format("Sizing method {0} not supported in AdvancedPositionSizer", config.Method)); + } + } + + private SizingResult CalculateOptimalF(StrategyIntent intent, StrategyContext context, SizingConfig config) + { + // Get trade history for calculating Optimal f + var tradeHistory = GetRecentTradeHistory(context, config); + if (tradeHistory.Count == 0) + { + // Fall back to fixed risk if no trade history + return CalculateFixedRiskFallback(intent, context, config); + } + + // Calculate Optimal f + var optimalF = CalculateOptimalFValue(tradeHistory); + + // Get account information + var equity = context.Account.Equity; + var maxLoss = GetMaximumLossFromHistory(tradeHistory); + + // Calculate optimal contracts using Optimal f formula + // Contracts = (Optimal f * Equity) / Max Loss + var optimalContracts = (optimalF * equity) / Math.Abs(maxLoss); + + // Round down to whole contracts (conservative approach) + var contracts = (int)Math.Floor(optimalContracts); + + // Apply min/max clamping + contracts = Math.Max(config.MinContracts, Math.Min(config.MaxContracts, contracts)); + + // Calculate actual risk with final contract count + var tickValue = GetTickValue(intent.Symbol); + var riskPerContract = intent.StopTicks * tickValue; + var actualRisk = contracts * riskPerContract; + + _logger.LogDebug("Optimal f sizing: {0} f={1:F4} ${2:F2}→{3:F2}→{4} contracts, ${5:F2} actual risk", + intent.Symbol, optimalF, equity, optimalContracts, contracts, actualRisk); + + var calculations = new Dictionary(); + calculations.Add("optimal_f", optimalF); + calculations.Add("equity", equity); + calculations.Add("max_loss", maxLoss); + calculations.Add("optimal_contracts", optimalContracts); + calculations.Add("clamped_contracts", contracts); + calculations.Add("stop_ticks", intent.StopTicks); + calculations.Add("tick_value", tickValue); + calculations.Add("risk_per_contract", riskPerContract); + calculations.Add("actual_risk", actualRisk); + calculations.Add("min_contracts", config.MinContracts); + calculations.Add("max_contracts", config.MaxContracts); + + return new SizingResult( + contracts: contracts, + riskAmount: actualRisk, + method: SizingMethod.OptimalF, + calculations: calculations + ); + } + + private SizingResult CalculateKellyCriterion(StrategyIntent intent, StrategyContext context, SizingConfig config) + { + // Get trade history for calculating win rate and average win/loss + var tradeHistory = GetRecentTradeHistory(context, config); + if (tradeHistory.Count == 0) + { + // Fall back to fixed risk if no trade history + return CalculateFixedRiskFallback(intent, context, config); + } + + // Calculate Kelly Criterion parameters + var winRate = CalculateWinRate(tradeHistory); + var avgWin = CalculateAverageWin(tradeHistory); + var avgLoss = CalculateAverageLoss(tradeHistory); + + // Calculate Kelly Criterion fraction + // K = (bp - q) / b + // Where: b = avgWin/avgLoss (odds), p = winRate, q = 1 - winRate + var odds = avgWin / Math.Abs(avgLoss); + var kellyFraction = ((odds * winRate) - (1 - winRate)) / odds; + + // Apply fractional Kelly to reduce risk (typically use 25%-50% of full Kelly) + var fractionalKelly = GetParameterValue(config, "kelly_fraction", 0.5); + var adjustedKelly = kellyFraction * fractionalKelly; + + // Calculate position size based on Kelly Criterion + var equity = context.Account.Equity; + var tickValue = GetTickValue(intent.Symbol); + var riskPerContract = intent.StopTicks * tickValue; + + // Kelly position size = (Kelly Fraction * Equity) / Risk per contract + var kellyContracts = (adjustedKelly * equity) / riskPerContract; + var contracts = (int)Math.Floor(Math.Abs(kellyContracts)); + + // Apply min/max clamping + contracts = Math.Max(config.MinContracts, Math.Min(config.MaxContracts, contracts)); + + // Calculate actual risk with final contract count + var actualRisk = contracts * riskPerContract; + + _logger.LogDebug("Kelly Criterion sizing: {0} K={1:F4} adj={2:F4} ${3:F2}→{4:F2}→{5} contracts, ${6:F2} actual risk", + intent.Symbol, kellyFraction, adjustedKelly, equity, kellyContracts, contracts, actualRisk); + + var calculations = new Dictionary(); + calculations.Add("win_rate", winRate); + calculations.Add("avg_win", avgWin); + calculations.Add("avg_loss", avgLoss); + calculations.Add("odds", odds); + calculations.Add("kelly_fraction", kellyFraction); + calculations.Add("fractional_kelly", fractionalKelly); + calculations.Add("adjusted_kelly", adjustedKelly); + calculations.Add("equity", equity); + calculations.Add("tick_value", tickValue); + calculations.Add("risk_per_contract", riskPerContract); + calculations.Add("kelly_contracts", kellyContracts); + calculations.Add("clamped_contracts", contracts); + calculations.Add("actual_risk", actualRisk); + calculations.Add("min_contracts", config.MinContracts); + calculations.Add("max_contracts", config.MaxContracts); + + return new SizingResult( + contracts: contracts, + riskAmount: actualRisk, + method: SizingMethod.KellyCriterion, + calculations: calculations + ); + } + + private SizingResult CalculateVolatilityAdjustedSizing(StrategyIntent intent, StrategyContext context, SizingConfig config) + { + // Get volatility information + var atr = CalculateATR(context, intent.Symbol, 14); // 14-period ATR + var tickValue = GetTickValue(intent.Symbol); + + // Get base risk from configuration + var baseRisk = config.RiskPerTrade; + + // Apply volatility adjustment + // Higher volatility = lower position size, Lower volatility = higher position size + var volatilityAdjustment = CalculateVolatilityAdjustment(atr, intent.Symbol); + var adjustedRisk = baseRisk * volatilityAdjustment; + + // Calculate contracts based on adjusted risk + var riskPerContract = intent.StopTicks * tickValue; + var optimalContracts = adjustedRisk / riskPerContract; + var contracts = (int)Math.Floor(optimalContracts); + + // Apply min/max clamping + contracts = Math.Max(config.MinContracts, Math.Min(config.MaxContracts, contracts)); + + // Calculate actual risk with final contract count + var actualRisk = contracts * riskPerContract; + + _logger.LogDebug("Volatility-adjusted sizing: {0} ATR={1:F4} adj={2:F4} ${3:F2}→${4:F2}→{5} contracts, ${6:F2} actual risk", + intent.Symbol, atr, volatilityAdjustment, baseRisk, adjustedRisk, contracts, actualRisk); + + var calculations = new Dictionary(); + calculations.Add("atr", atr); + calculations.Add("volatility_adjustment", volatilityAdjustment); + calculations.Add("base_risk", baseRisk); + calculations.Add("adjusted_risk", adjustedRisk); + calculations.Add("tick_value", tickValue); + calculations.Add("risk_per_contract", riskPerContract); + calculations.Add("optimal_contracts", optimalContracts); + calculations.Add("clamped_contracts", contracts); + calculations.Add("actual_risk", actualRisk); + calculations.Add("stop_ticks", intent.StopTicks); + calculations.Add("min_contracts", config.MinContracts); + calculations.Add("max_contracts", config.MaxContracts); + + return new SizingResult( + contracts: contracts, + riskAmount: actualRisk, + method: SizingMethod.VolatilityAdjusted, + calculations: calculations + ); + } + + private SizingResult CalculateFixedRiskFallback(StrategyIntent intent, StrategyContext context, SizingConfig config) + { + var tickValue = GetTickValue(intent.Symbol); + + // Validate stop ticks + if (intent.StopTicks <= 0) + { + _logger.LogWarning("Invalid stop ticks {0} for fixed risk sizing on {1}", + intent.StopTicks, intent.Symbol); + + var errorCalcs = new Dictionary(); + errorCalcs.Add("error", "Invalid stop ticks"); + errorCalcs.Add("stop_ticks", intent.StopTicks); + + return new SizingResult(0, 0, SizingMethod.FixedDollarRisk, errorCalcs); + } + + // Calculate optimal contracts for target risk + var targetRisk = config.RiskPerTrade; + var riskPerContract = intent.StopTicks * tickValue; + var optimalContracts = targetRisk / riskPerContract; + + // Round down to whole contracts (conservative approach) + var contracts = (int)Math.Floor(optimalContracts); + + // Apply min/max clamping + contracts = Math.Max(config.MinContracts, Math.Min(config.MaxContracts, contracts)); + + // Calculate actual risk with final contract count + var actualRisk = contracts * riskPerContract; + + _logger.LogDebug("Fixed risk fallback sizing: {0} ${1:F2}→{2:F2}→{3} contracts, ${4:F2} actual risk", + intent.Symbol, targetRisk, optimalContracts, contracts, actualRisk); + + var calculations = new Dictionary(); + calculations.Add("target_risk", targetRisk); + calculations.Add("stop_ticks", intent.StopTicks); + calculations.Add("tick_value", tickValue); + calculations.Add("risk_per_contract", riskPerContract); + calculations.Add("optimal_contracts", optimalContracts); + calculations.Add("clamped_contracts", contracts); + calculations.Add("actual_risk", actualRisk); + calculations.Add("min_contracts", config.MinContracts); + calculations.Add("max_contracts", config.MaxContracts); + + return new SizingResult( + contracts: contracts, + riskAmount: actualRisk, + method: SizingMethod.FixedDollarRisk, + calculations: calculations + ); + } + + private static double CalculateOptimalFValue(List tradeHistory) + { + if (tradeHistory == null || tradeHistory.Count == 0) + return 0.0; + + // Find the largest loss (in absolute terms) + var largestLoss = Math.Abs(tradeHistory.Min(t => t.ProfitLoss)); + + if (largestLoss == 0) + return 0.0; + + // Calculate Optimal f using the formula: + // f = (N*R - T) / (N*L) + // Where: N = number of trades, R = average win, L = largest loss, T = total profit + + var n = tradeHistory.Count; + var totalProfit = tradeHistory.Sum(t => t.ProfitLoss); + var averageWin = tradeHistory.Where(t => t.ProfitLoss > 0).DefaultIfEmpty(new TradeResult()).Average(t => t.ProfitLoss); + + if (averageWin <= 0) + return 0.0; + + var optimalF = (n * averageWin - totalProfit) / (n * largestLoss); + + // Ensure f is between 0 and 1 + return Math.Max(0.0, Math.Min(1.0, optimalF)); + } + + private static double CalculateWinRate(List tradeHistory) + { + if (tradeHistory == null || tradeHistory.Count == 0) + return 0.0; + + var winningTrades = tradeHistory.Count(t => t.ProfitLoss > 0); + return (double)winningTrades / tradeHistory.Count; + } + + private static double CalculateAverageWin(List tradeHistory) + { + if (tradeHistory == null || tradeHistory.Count == 0) + return 0.0; + + var winningTrades = tradeHistory.Where(t => t.ProfitLoss > 0).ToList(); + if (winningTrades.Count == 0) + return 0.0; + + return winningTrades.Average(t => t.ProfitLoss); + } + + private static double CalculateAverageLoss(List tradeHistory) + { + if (tradeHistory == null || tradeHistory.Count == 0) + return 0.0; + + var losingTrades = tradeHistory.Where(t => t.ProfitLoss < 0).ToList(); + if (losingTrades.Count == 0) + return 0.0; + + return losingTrades.Average(t => t.ProfitLoss); + } + + private static double GetMaximumLossFromHistory(List tradeHistory) + { + if (tradeHistory == null || tradeHistory.Count == 0) + return 0.0; + + return tradeHistory.Min(t => t.ProfitLoss); + } + + private static double CalculateATR(StrategyContext context, string symbol, int periods) + { + // This would typically involve retrieving historical bar data + // For this implementation, we'll use a simplified approach + return 1.0; // Placeholder value + } + + private static double CalculateVolatilityAdjustment(double atr, string symbol) + { + // Normalize ATR to a volatility adjustment factor + // Higher ATR = lower adjustment (reduce position size) + // Lower ATR = higher adjustment (increase position size) + + // This is a simplified example - in practice, you'd normalize against + // historical ATR values for the specific symbol + var normalizedATR = atr / 10.0; // Example normalization + var adjustment = 1.0 / (1.0 + normalizedATR); + + // Ensure adjustment is between 0.1 and 2.0 + return Math.Max(0.1, Math.Min(2.0, adjustment)); + } + + private static List GetRecentTradeHistory(StrategyContext context, SizingConfig config) + { + // In a real implementation, this would retrieve actual trade history + // For this example, we'll return an empty list to trigger fallback behavior + return new List(); + } + + private static T GetParameterValue(SizingConfig config, string key, T defaultValue) + { + if (config.MethodParameters.ContainsKey(key)) + { + try + { + return (T)Convert.ChangeType(config.MethodParameters[key], typeof(T)); + } + catch + { + // If conversion fails, return default + return defaultValue; + } + } + + return defaultValue; + } + + private static double GetTickValue(string symbol) + { + // Static tick values for Phase 0 - will be configurable in Phase 1 + switch (symbol) + { + case "ES": return 12.50; // E-mini S&P 500 + case "MES": return 1.25; // Micro E-mini S&P 500 + case "NQ": return 5.00; // E-mini NASDAQ-100 + case "MNQ": return 0.50; // Micro E-mini NASDAQ-100 + case "CL": return 10.00; // Crude Oil + case "GC": return 10.00; // Gold + case "6E": return 12.50; // Euro FX + case "6A": return 10.00; // Australian Dollar + default: return 12.50; // Default to ES value + } + } + + public SizingMetadata GetMetadata() + { + var requiredParams = new List(); + requiredParams.Add("method"); + requiredParams.Add("risk_per_trade"); + requiredParams.Add("min_contracts"); + requiredParams.Add("max_contracts"); + + return new SizingMetadata( + name: "Advanced Position Sizer", + description: "Optimal f, Kelly Criterion, and volatility-adjusted sizing with contract clamping", + requiredParameters: requiredParams + ); + } + + /// + /// Validate sizing configuration parameters + /// + public static bool ValidateConfig(SizingConfig config, out List errors) + { + errors = new List(); + + if (config.MinContracts < 0) + errors.Add("MinContracts must be >= 0"); + + if (config.MaxContracts <= 0) + errors.Add("MaxContracts must be > 0"); + + if (config.MinContracts > config.MaxContracts) + errors.Add("MinContracts must be <= MaxContracts"); + + if (config.RiskPerTrade <= 0) + errors.Add("RiskPerTrade must be > 0"); + + // Method-specific validation + switch (config.Method) + { + case SizingMethod.OptimalF: + // No additional parameters required for Optimal f + break; + + case SizingMethod.KellyCriterion: + // Validate Kelly fraction parameter if provided + if (config.MethodParameters.ContainsKey("kelly_fraction")) + { + var kellyFraction = GetParameterValue(config, "kelly_fraction", 0.5); + if (kellyFraction <= 0 || kellyFraction > 1.0) + errors.Add("Kelly fraction must be between 0 and 1.0"); + } + break; + + case SizingMethod.VolatilityAdjusted: + // No additional parameters required for volatility-adjusted sizing + break; + + default: + errors.Add(String.Format("Unsupported sizing method: {0}", config.Method)); + break; + } + + return errors.Count == 0; + } + + /// + /// Internal class to represent trade results for calculations + /// + private class TradeResult + { + public double ProfitLoss { get; set; } + public DateTime TradeTime { get; set; } + + public TradeResult() + { + ProfitLoss = 0.0; + TradeTime = DateTime.UtcNow; + } + + public TradeResult(double profitLoss, DateTime tradeTime) + { + ProfitLoss = profitLoss; + TradeTime = tradeTime; + } + } + } +}