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
Some checks failed
Build and Test / build (push) Has been cancelled
This commit is contained in:
@@ -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>
|
||||
|
||||
497
src/NT8.Core/Sizing/AdvancedPositionSizer.cs
Normal file
497
src/NT8.Core/Sizing/AdvancedPositionSizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user