581 lines
16 KiB
Text
581 lines
16 KiB
Text
/**
|
|
* Position Sizing Calculations
|
|
* Risk-based position sizing methods for trading strategies
|
|
*/
|
|
|
|
export interface PositionSizeParams {
|
|
accountSize: number;
|
|
riskPercentage: number;
|
|
entryPrice: number;
|
|
stopLoss: number;
|
|
leverage?: number;
|
|
}
|
|
|
|
export interface KellyParams {
|
|
winRate: number;
|
|
averageWin: number;
|
|
averageLoss: number;
|
|
}
|
|
|
|
export interface VolatilityParams {
|
|
price: number;
|
|
volatility: number;
|
|
targetVolatility: number;
|
|
lookbackDays: number;
|
|
}
|
|
|
|
/**
|
|
* Calculate position size based on fixed risk percentage
|
|
*/
|
|
export function fixedRiskPositionSize(params: PositionSizeParams): number {
|
|
const { accountSize, riskPercentage, entryPrice, stopLoss, leverage = 1 } = params;
|
|
|
|
// Input validation
|
|
if (accountSize <= 0 || riskPercentage <= 0 || entryPrice <= 0 || leverage <= 0) {
|
|
return 0;
|
|
}
|
|
if (entryPrice === stopLoss) {
|
|
return 0;
|
|
}
|
|
|
|
const riskAmount = accountSize * (riskPercentage / 100);
|
|
const riskPerShare = Math.abs(entryPrice - stopLoss);
|
|
const basePositionSize = riskAmount / riskPerShare;
|
|
|
|
return Math.floor(basePositionSize * leverage);
|
|
}
|
|
|
|
/**
|
|
* Calculate position size using Kelly Criterion
|
|
*/
|
|
export function kellyPositionSize(params: KellyParams, accountSize: number): number {
|
|
const { winRate, averageWin, averageLoss } = params;
|
|
|
|
// Validate inputs
|
|
if (averageLoss === 0 || winRate <= 0 || winRate >= 1 || averageWin <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const lossRate = 1 - winRate;
|
|
const winLossRatio = averageWin / Math.abs(averageLoss);
|
|
|
|
// Correct Kelly formula: f = (bp - q) / b
|
|
// where: b = win/loss ratio, p = win rate, q = loss rate
|
|
const kellyFraction = (winRate * winLossRatio - lossRate) / winLossRatio;
|
|
|
|
// Cap Kelly fraction to prevent over-leveraging (max 25% of Kelly recommendation)
|
|
const cappedKelly = Math.max(0, Math.min(kellyFraction * 0.25, 0.25));
|
|
|
|
return accountSize * cappedKelly;
|
|
}
|
|
|
|
/**
|
|
* Calculate fractional Kelly position size (more conservative)
|
|
*/
|
|
export function fractionalKellyPositionSize(
|
|
params: KellyParams,
|
|
accountSize: number,
|
|
fraction: number = 0.25
|
|
): number {
|
|
// Input validation
|
|
if (fraction <= 0 || fraction > 1) {
|
|
return 0;
|
|
}
|
|
|
|
const fullKelly = kellyPositionSize(params, accountSize);
|
|
return fullKelly * fraction;
|
|
}
|
|
|
|
/**
|
|
* Calculate position size based on volatility targeting
|
|
*/
|
|
export function volatilityTargetPositionSize(
|
|
params: VolatilityParams,
|
|
accountSize: number
|
|
): number {
|
|
const { price, volatility, targetVolatility } = params;
|
|
|
|
// Input validation
|
|
if (volatility <= 0 || price <= 0 || targetVolatility <= 0 || accountSize <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const volatilityRatio = targetVolatility / volatility;
|
|
const basePositionValue = accountSize * Math.min(volatilityRatio, 2); // Cap at 2x leverage
|
|
|
|
return Math.floor(basePositionValue / price);
|
|
}
|
|
|
|
/**
|
|
* Calculate equal weight position size
|
|
*/
|
|
export function equalWeightPositionSize(
|
|
accountSize: number,
|
|
numberOfPositions: number,
|
|
price: number
|
|
): number {
|
|
// Input validation
|
|
if (numberOfPositions <= 0 || price <= 0 || accountSize <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const positionValue = accountSize / numberOfPositions;
|
|
return Math.floor(positionValue / price);
|
|
}
|
|
|
|
/**
|
|
* Calculate position size based on Average True Range (ATR)
|
|
*/
|
|
export function atrBasedPositionSize(
|
|
accountSize: number,
|
|
riskPercentage: number,
|
|
atrValue: number,
|
|
atrMultiplier: number = 2,
|
|
price: number
|
|
): number {
|
|
if (atrValue === 0 || price === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const riskAmount = accountSize * (riskPercentage / 100);
|
|
const stopDistance = atrValue * atrMultiplier;
|
|
const positionSize = riskAmount / stopDistance;
|
|
|
|
// Return position size in shares, not dollars
|
|
return Math.floor(positionSize);
|
|
}
|
|
|
|
/**
|
|
* Calculate position size using Van Tharp's expectancy
|
|
*/
|
|
export function expectancyPositionSize(
|
|
accountSize: number,
|
|
winRate: number,
|
|
averageWin: number,
|
|
averageLoss: number,
|
|
maxRiskPercentage: number = 2
|
|
): number {
|
|
// Input validation
|
|
if (accountSize <= 0 || winRate <= 0 || winRate >= 1 || averageWin <= 0 || averageLoss === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const expectancy = winRate * averageWin - (1 - winRate) * Math.abs(averageLoss);
|
|
|
|
if (expectancy <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Scale position size based on expectancy relative to average loss
|
|
// Higher expectancy relative to risk allows for larger position
|
|
const expectancyRatio = expectancy / Math.abs(averageLoss);
|
|
const riskPercentage = Math.min(expectancyRatio * 0.5, maxRiskPercentage);
|
|
|
|
const positionValue = accountSize * (riskPercentage / 100);
|
|
return positionValue;
|
|
}
|
|
|
|
/**
|
|
* Calculate optimal position size using Monte Carlo simulation
|
|
*/
|
|
export function monteCarloPositionSize(
|
|
accountSize: number,
|
|
historicalReturns: number[],
|
|
simulations: number = 1000,
|
|
confidenceLevel: number = 0.95
|
|
): number {
|
|
if (historicalReturns.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const outcomes: number[] = [];
|
|
const mean = historicalReturns.reduce((sum, ret) => sum + ret, 0) / historicalReturns.length;
|
|
const variance =
|
|
historicalReturns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) /
|
|
historicalReturns.length;
|
|
const stdDev = Math.sqrt(variance);
|
|
|
|
// Test different position sizes (as fraction of account)
|
|
const testFractions = [0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.25];
|
|
let optimalFraction = 0;
|
|
let bestSharpe = -Infinity;
|
|
|
|
for (const fraction of testFractions) {
|
|
const simOutcomes: number[] = [];
|
|
|
|
for (let i = 0; i < simulations; i++) {
|
|
let portfolioValue = accountSize;
|
|
|
|
// Simulate trades over a period
|
|
for (let j = 0; j < 50; j++) {
|
|
// 50 trades
|
|
const randomReturn =
|
|
historicalReturns[Math.floor(Math.random() * historicalReturns.length)];
|
|
const positionReturn = randomReturn * fraction;
|
|
portfolioValue = portfolioValue * (1 + positionReturn);
|
|
}
|
|
|
|
simOutcomes.push(portfolioValue);
|
|
}
|
|
|
|
// Calculate Sharpe ratio for this fraction
|
|
const avgOutcome = simOutcomes.reduce((sum, val) => sum + val, 0) / simOutcomes.length;
|
|
const returns = simOutcomes.map(val => (val - accountSize) / accountSize);
|
|
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
|
const returnStdDev = Math.sqrt(
|
|
returns.reduce((sum, ret) => sum + Math.pow(ret - avgReturn, 2), 0) / returns.length
|
|
);
|
|
|
|
const sharpe = returnStdDev > 0 ? avgReturn / returnStdDev : -Infinity;
|
|
|
|
if (sharpe > bestSharpe) {
|
|
bestSharpe = sharpe;
|
|
optimalFraction = fraction;
|
|
}
|
|
}
|
|
|
|
return accountSize * optimalFraction;
|
|
}
|
|
|
|
/**
|
|
* Calculate position size based on Sharpe ratio optimization
|
|
*/
|
|
export function sharpeOptimizedPositionSize(
|
|
accountSize: number,
|
|
expectedReturn: number,
|
|
volatility: number,
|
|
riskFreeRate: number = 0.02,
|
|
maxLeverage: number = 3
|
|
): number {
|
|
// Input validation
|
|
if (volatility <= 0 || accountSize <= 0 || expectedReturn <= riskFreeRate || maxLeverage <= 0) {
|
|
return 0;
|
|
}
|
|
// Kelly criterion with Sharpe ratio optimization
|
|
const excessReturn = expectedReturn - riskFreeRate;
|
|
const kellyFraction = excessReturn / (volatility * volatility);
|
|
|
|
// Apply maximum leverage constraint
|
|
const constrainedFraction = Math.max(0, Math.min(kellyFraction, maxLeverage));
|
|
|
|
return accountSize * constrainedFraction;
|
|
}
|
|
|
|
/**
|
|
* Fixed fractional position sizing
|
|
*/
|
|
export function fixedFractionalPositionSize(
|
|
accountSize: number,
|
|
riskPercentage: number,
|
|
stopLossPercentage: number,
|
|
price: number
|
|
): number {
|
|
// Input validation
|
|
if (stopLossPercentage <= 0 || price <= 0 || riskPercentage <= 0 || accountSize <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const riskAmount = accountSize * (riskPercentage / 100);
|
|
const stopLossAmount = price * (stopLossPercentage / 100);
|
|
|
|
return Math.floor(riskAmount / stopLossAmount);
|
|
}
|
|
|
|
/**
|
|
* Volatility-adjusted position sizing
|
|
*/
|
|
export function volatilityAdjustedPositionSize(
|
|
accountSize: number,
|
|
targetVolatility: number,
|
|
assetVolatility: number,
|
|
price: number
|
|
): number {
|
|
// Input validation
|
|
if (assetVolatility <= 0 || price <= 0 || targetVolatility <= 0 || accountSize <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
const volatilityRatio = targetVolatility / assetVolatility;
|
|
const cappedRatio = Math.min(volatilityRatio, 3); // Cap at 3x leverage
|
|
const positionValue = accountSize * cappedRatio;
|
|
|
|
return Math.floor(positionValue / price);
|
|
}
|
|
|
|
/**
|
|
* Calculate position size with correlation adjustment
|
|
*/
|
|
export function correlationAdjustedPositionSize(
|
|
basePositionSize: number,
|
|
existingPositions: Array<{ size: number; correlation: number }>,
|
|
maxCorrelationRisk: number = 0.3
|
|
): number {
|
|
if (existingPositions.length === 0 || basePositionSize <= 0) {
|
|
return basePositionSize;
|
|
}
|
|
|
|
// Calculate portfolio correlation risk
|
|
// This should consider the correlation between the new position and existing ones
|
|
const totalCorrelationRisk = existingPositions.reduce((total, position) => {
|
|
// Weight correlation by position size relative to new position
|
|
const relativeSize = position.size / (basePositionSize + position.size);
|
|
return total + relativeSize * Math.abs(position.correlation);
|
|
}, 0);
|
|
|
|
// Adjust position size based on correlation risk
|
|
const correlationAdjustment = Math.max(0.1, 1 - totalCorrelationRisk / maxCorrelationRisk);
|
|
|
|
return Math.floor(basePositionSize * correlationAdjustment);
|
|
}
|
|
|
|
/**
|
|
* Calculate portfolio heat (total risk across all positions)
|
|
*/
|
|
export function calculatePortfolioHeat(
|
|
positions: Array<{ value: number; risk: number }>,
|
|
accountSize: number
|
|
): number {
|
|
// Input validation
|
|
if (accountSize <= 0 || positions.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const totalRisk = positions.reduce((sum, position) => {
|
|
// Ensure risk values are positive
|
|
return sum + Math.max(0, position.risk);
|
|
}, 0);
|
|
|
|
return Math.min((totalRisk / accountSize) * 100, 100); // Cap at 100%
|
|
}
|
|
|
|
/**
|
|
* Dynamic position sizing based on market conditions
|
|
*/
|
|
export function dynamicPositionSize(
|
|
basePositionSize: number,
|
|
marketVolatility: number,
|
|
normalVolatility: number,
|
|
drawdownLevel: number,
|
|
maxDrawdownThreshold: number = 0.1
|
|
): number {
|
|
// Input validation
|
|
if (basePositionSize <= 0 || marketVolatility <= 0 || normalVolatility <= 0) {
|
|
return 0;
|
|
}
|
|
if (drawdownLevel < 0 || maxDrawdownThreshold <= 0) {
|
|
return basePositionSize;
|
|
}
|
|
|
|
// Volatility adjustment - reduce size when volatility is high
|
|
const volatilityAdjustment = Math.min(normalVolatility / marketVolatility, 2); // Cap at 2x
|
|
|
|
// Drawdown adjustment - reduce size as drawdown increases
|
|
const normalizedDrawdown = Math.min(drawdownLevel / maxDrawdownThreshold, 1);
|
|
const drawdownAdjustment = Math.max(0.1, 1 - normalizedDrawdown);
|
|
|
|
const adjustedSize = basePositionSize * volatilityAdjustment * drawdownAdjustment;
|
|
return Math.floor(Math.max(0, adjustedSize));
|
|
}
|
|
|
|
/**
|
|
* Calculate maximum position size based on liquidity
|
|
*/
|
|
export function liquidityConstrainedPositionSize(
|
|
desiredPositionSize: number,
|
|
averageDailyVolume: number,
|
|
maxVolumePercentage: number = 0.05,
|
|
price: number
|
|
): number {
|
|
if (averageDailyVolume === 0 || price === 0) {
|
|
return 0;
|
|
}
|
|
|
|
const maxShares = averageDailyVolume * maxVolumePercentage;
|
|
|
|
return Math.min(desiredPositionSize, maxShares);
|
|
}
|
|
|
|
/**
|
|
* Multi-timeframe position sizing
|
|
*/
|
|
export function multiTimeframePositionSize(
|
|
accountSize: number,
|
|
shortTermSignal: number, // -1 to 1
|
|
mediumTermSignal: number, // -1 to 1
|
|
longTermSignal: number, // -1 to 1
|
|
baseRiskPercentage: number = 1
|
|
): number {
|
|
// Input validation
|
|
if (accountSize <= 0 || baseRiskPercentage <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Clamp signals to valid range
|
|
const clampedShort = Math.max(-1, Math.min(1, shortTermSignal));
|
|
const clampedMedium = Math.max(-1, Math.min(1, mediumTermSignal));
|
|
const clampedLong = Math.max(-1, Math.min(1, longTermSignal));
|
|
|
|
// Weight the signals (long-term gets higher weight)
|
|
const weightedSignal = clampedShort * 0.2 + clampedMedium * 0.3 + clampedLong * 0.5;
|
|
|
|
// Adjust risk based on signal strength
|
|
const adjustedRisk = baseRiskPercentage * Math.abs(weightedSignal);
|
|
|
|
return accountSize * (adjustedRisk / 100);
|
|
}
|
|
|
|
/**
|
|
* Risk parity position sizing
|
|
*/
|
|
export function riskParityPositionSize(
|
|
assets: Array<{ volatility: number; price: number }>,
|
|
targetRisk: number,
|
|
accountSize: number
|
|
): number[] {
|
|
if (assets.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
// Calculate inverse volatility weights
|
|
const totalInverseVol = assets.reduce((sum, asset) => {
|
|
if (asset.volatility === 0) {
|
|
return sum;
|
|
}
|
|
return sum + 1 / asset.volatility;
|
|
}, 0);
|
|
|
|
if (totalInverseVol === 0) {
|
|
return assets.map(() => 0);
|
|
}
|
|
|
|
return assets.map(asset => {
|
|
if (asset.volatility === 0 || asset.price === 0) {
|
|
return 0;
|
|
}
|
|
// Calculate weight based on inverse volatility
|
|
const weight = 1 / asset.volatility / totalInverseVol;
|
|
|
|
// The weight itself already accounts for risk parity
|
|
// We just need to scale by target risk once
|
|
const positionValue = accountSize * weight * targetRisk;
|
|
return Math.floor(positionValue / asset.price);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate position size against risk limits
|
|
*/
|
|
export function validatePositionSize(
|
|
positionSize: number,
|
|
price: number,
|
|
accountSize: number,
|
|
maxPositionPercentage: number = 10,
|
|
maxLeverage: number = 1
|
|
): { isValid: boolean; adjustedSize: number; violations: string[] } {
|
|
const violations: string[] = [];
|
|
let adjustedSize = positionSize;
|
|
|
|
// Check maximum position percentage
|
|
const positionValue = positionSize * price;
|
|
const positionPercentage = (positionValue / accountSize) * 100;
|
|
|
|
if (positionPercentage > maxPositionPercentage) {
|
|
violations.push(`Position exceeds maximum ${maxPositionPercentage}% of account`);
|
|
adjustedSize = (accountSize * maxPositionPercentage) / 100 / price;
|
|
}
|
|
|
|
// Check leverage limits
|
|
const leverage = positionValue / accountSize;
|
|
if (leverage > maxLeverage) {
|
|
violations.push(`Position exceeds maximum leverage of ${maxLeverage}x`);
|
|
adjustedSize = Math.min(adjustedSize, (accountSize * maxLeverage) / price);
|
|
}
|
|
|
|
// Check minimum position size
|
|
if (adjustedSize < 1 && adjustedSize > 0) {
|
|
violations.push('Position size too small (less than 1 share)');
|
|
adjustedSize = 0;
|
|
}
|
|
|
|
return {
|
|
isValid: violations.length === 0,
|
|
adjustedSize: Math.max(0, adjustedSize),
|
|
violations,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Optimal F position sizing (Ralph Vince's method)
|
|
*/
|
|
export function optimalFPositionSize(
|
|
accountSize: number,
|
|
historicalReturns: number[],
|
|
maxIterations: number = 100
|
|
): number {
|
|
if (historicalReturns.length === 0 || accountSize <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Convert returns to P&L per unit
|
|
const pnlValues = historicalReturns.map(ret => ret * 1000); // Assuming $1000 per unit
|
|
|
|
let bestF = 0;
|
|
let bestTWR = 0; // Terminal Wealth Relative
|
|
|
|
// Test different f values (0.01 to 1.00)
|
|
for (let f = 0.01; f <= 1.0; f += 0.01) {
|
|
let twr = 1.0;
|
|
let valid = true;
|
|
|
|
for (const pnl of pnlValues) {
|
|
const hpr = 1 + (f * pnl) / 1000; // Holding Period Return
|
|
|
|
if (hpr <= 0) {
|
|
valid = false;
|
|
break;
|
|
}
|
|
|
|
twr *= hpr;
|
|
}
|
|
|
|
if (valid && twr > bestTWR) {
|
|
bestTWR = twr;
|
|
bestF = f;
|
|
}
|
|
}
|
|
|
|
// Apply safety factor
|
|
const safeF = bestF * 0.75; // 75% of optimal f for safety
|
|
|
|
return accountSize * safeF;
|
|
}
|
|
|
|
/**
|
|
* Secure F position sizing (safer version of Optimal F)
|
|
*/
|
|
export function secureFPositionSize(
|
|
accountSize: number,
|
|
historicalReturns: number[],
|
|
confidenceLevel: number = 0.95
|
|
): number {
|
|
if (historicalReturns.length === 0 || accountSize <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Sort returns to find worst-case scenarios
|
|
const sortedReturns = [...historicalReturns].sort((a, b) => a - b);
|
|
const worstCaseIndex = Math.floor((1 - confidenceLevel) * sortedReturns.length);
|
|
const worstCaseReturn = sortedReturns[worstCaseIndex];
|
|
|
|
// Calculate maximum position size that won't bankrupt at confidence level
|
|
const maxLoss = Math.abs(worstCaseReturn);
|
|
const maxRiskPercentage = 0.02; // Never risk more than 2% on worst case
|
|
|
|
if (maxLoss === 0) {
|
|
return accountSize * 0.1;
|
|
} // Default to 10% if no historical losses
|
|
|
|
const secureF = Math.min(maxRiskPercentage / maxLoss, 0.25); // Cap at 25%
|
|
|
|
return accountSize * secureF;
|
|
}
|