Implement advanced position sizing algorithms with Optimal f, Kelly Criterion, and volatility-adjusted methods
Some checks failed
Build and Test / build (push) Has been cancelled

This commit is contained in:
Billy Valentine
2025-09-09 18:56:25 -04:00
parent 23bb431d42
commit 86422ff540
2 changed files with 509 additions and 2 deletions

View File

@@ -163,7 +163,17 @@ namespace NT8.Core.Common.Models
/// <summary>
/// Optimal F calculation
/// </summary>
OptimalF
OptimalF,
/// <summary>
/// Kelly Criterion sizing
/// </summary>
KellyCriterion,
/// <summary>
/// Volatility-adjusted sizing
/// </summary>
VolatilityAdjusted
}
/// <summary>

View File

@@ -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
{
/// <summary>
/// Advanced position sizer with Optimal f, Kelly Criterion, and volatility-adjusted methods
/// Implements sophisticated position sizing algorithms for professional trading
/// </summary>
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<string, object>();
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<string, object>();
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<double>(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<string, object>();
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<string, object>();
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<string, object>();
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<string, object>();
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<TradeResult> 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<TradeResult> 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<TradeResult> 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<TradeResult> 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<TradeResult> 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<TradeResult> 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<TradeResult>();
}
private static T GetParameterValue<T>(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<string>();
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
);
}
/// <summary>
/// Validate sizing configuration parameters
/// </summary>
public static bool ValidateConfig(SizingConfig config, out List<string> errors)
{
errors = new List<string>();
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<double>(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;
}
/// <summary>
/// Internal class to represent trade results for calculations
/// </summary>
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;
}
}
}
}