Files
wifi-densepose/examples/neural-trader/production/fractional-kelly.js
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

646 lines
20 KiB
JavaScript

/**
* Fractional Kelly Criterion Engine
*
* PRODUCTION: Foundation for optimal bet sizing in trading and sports betting
*
* Research-backed implementation:
* - Full Kelly leads to ruin in practice (Dotan, 2024)
* - 1/5th Kelly achieved 98% ROI in NBA betting simulations
* - 1/8th Kelly recommended for conservative strategies
*
* Features:
* - Multiple Kelly fractions (1/2, 1/4, 1/5, 1/8)
* - Calibration-aware adjustments
* - Multi-bet portfolio optimization
* - Risk-of-ruin calculations
* - Drawdown protection
*/
// Kelly Configuration
const kellyConfig = {
// Fraction strategies
fractions: {
aggressive: 0.5, // Half Kelly
moderate: 0.25, // Quarter Kelly
conservative: 0.2, // Fifth Kelly (recommended)
ultraSafe: 0.125 // Eighth Kelly
},
// Risk management
risk: {
maxBetFraction: 0.05, // Never bet more than 5% of bankroll
minEdge: 0.01, // Minimum 1% edge required
maxDrawdown: 0.25, // Stop at 25% drawdown
confidenceThreshold: 0.6 // Minimum model confidence
},
// Bankroll management
bankroll: {
initial: 10000,
reserveRatio: 0.1, // Keep 10% as reserve
rebalanceThreshold: 0.2 // Rebalance when 20% deviation
}
};
/**
* Kelly Criterion Calculator
* Optimal bet sizing for positive expected value bets
*/
class KellyCriterion {
constructor(config = kellyConfig) {
this.config = config;
this.bankroll = config.bankroll.initial;
this.peakBankroll = this.bankroll;
this.history = [];
this.stats = {
totalBets: 0,
wins: 0,
losses: 0,
totalWagered: 0,
totalProfit: 0
};
}
/**
* Calculate full Kelly fraction
* f* = (bp - q) / b
* where b = decimal odds - 1, p = win probability, q = 1 - p
*/
calculateFullKelly(winProbability, decimalOdds) {
const b = decimalOdds - 1; // Net odds
const p = winProbability;
const q = 1 - p;
const kelly = (b * p - q) / b;
return Math.max(0, kelly); // Never negative
}
/**
* Calculate fractional Kelly with safety bounds
*/
calculateFractionalKelly(winProbability, decimalOdds, fraction = 'conservative') {
const fullKelly = this.calculateFullKelly(winProbability, decimalOdds);
if (fullKelly <= 0) {
return { stake: 0, edge: 0, fullKelly: 0, reason: 'negative_ev' };
}
const fractionValue = typeof fraction === 'number'
? fraction
: this.config.fractions[fraction] || 0.2;
let adjustedKelly = fullKelly * fractionValue;
// Apply maximum bet constraint
adjustedKelly = Math.min(adjustedKelly, this.config.risk.maxBetFraction);
// Calculate edge
const edge = (winProbability * decimalOdds) - 1;
// Check minimum edge requirement
if (edge < this.config.risk.minEdge) {
return { stake: 0, edge, fullKelly, reason: 'insufficient_edge' };
}
// Calculate actual stake
const availableBankroll = this.bankroll * (1 - this.config.bankroll.reserveRatio);
const stake = availableBankroll * adjustedKelly;
return {
stake: Math.round(stake * 100) / 100,
stakePercent: adjustedKelly * 100,
fullKelly: fullKelly * 100,
fractionalKelly: adjustedKelly * 100,
edge: edge * 100,
expectedValue: stake * edge,
fraction: fractionValue,
reason: 'approved'
};
}
/**
* Calculate Kelly for calibrated probability models
* Adjusts for model confidence/calibration quality
*/
calculateCalibratedKelly(predictedProb, calibrationScore, decimalOdds, fraction = 'conservative') {
// Shrink probability toward 0.5 based on calibration quality
// Perfect calibration (1.0) = use predicted prob
// Poor calibration (0.5) = shrink significantly toward 0.5
const shrinkage = 1 - calibrationScore;
const adjustedProb = predictedProb * (1 - shrinkage * 0.5) + 0.5 * shrinkage * 0.5;
// Only bet if confidence exceeds threshold
if (calibrationScore < this.config.risk.confidenceThreshold) {
return {
stake: 0,
reason: 'low_calibration',
calibrationScore,
adjustedProb
};
}
const result = this.calculateFractionalKelly(adjustedProb, decimalOdds, fraction);
return {
...result,
originalProb: predictedProb,
adjustedProb,
calibrationScore
};
}
/**
* Multi-bet Kelly (simultaneous independent bets)
* Reduces individual stakes to account for correlation risk
*/
calculateMultiBetKelly(bets, fraction = 'conservative') {
if (bets.length === 0) return [];
// Calculate individual Kelly for each bet
const individualBets = bets.map(bet => ({
...bet,
kelly: this.calculateFractionalKelly(bet.winProbability, bet.decimalOdds, fraction)
}));
// Filter to positive EV bets only
const positiveBets = individualBets.filter(b => b.kelly.stake > 0);
if (positiveBets.length === 0) return individualBets;
// Apply correlation adjustment (reduce stakes when many bets)
// Use sqrt(n) scaling to account for diversification
const correlationFactor = 1 / Math.sqrt(positiveBets.length);
// Total stake shouldn't exceed max bet fraction
const totalKelly = positiveBets.reduce((sum, b) => sum + b.kelly.fractionalKelly / 100, 0);
const scaleFactor = totalKelly > this.config.risk.maxBetFraction
? this.config.risk.maxBetFraction / totalKelly
: 1;
return individualBets.map(bet => {
if (bet.kelly.stake === 0) return bet;
const adjustedStake = bet.kelly.stake * correlationFactor * scaleFactor;
return {
...bet,
kelly: {
...bet.kelly,
originalStake: bet.kelly.stake,
stake: Math.round(adjustedStake * 100) / 100,
correlationAdjustment: correlationFactor,
portfolioScaling: scaleFactor
}
};
});
}
/**
* Calculate risk of ruin given betting strategy
*/
calculateRiskOfRuin(winProbability, decimalOdds, betFraction, targetMultiple = 2) {
const p = winProbability;
const q = 1 - p;
const b = decimalOdds - 1;
// Simplified risk of ruin formula
// R = (q/p)^(bankroll/unit)
if (p <= q / b) {
return 1; // Negative EV = certain ruin
}
const edge = b * p - q;
const variance = p * q * (b + 1) ** 2;
const sharpe = edge / Math.sqrt(variance);
// Approximate risk of ruin using normal approximation
const unitsToTarget = Math.log(targetMultiple) / Math.log(1 + betFraction * edge);
const riskOfRuin = Math.exp(-2 * edge * unitsToTarget / variance);
return Math.min(1, Math.max(0, riskOfRuin));
}
/**
* Place a bet and update bankroll
*/
placeBet(stake, decimalOdds, won) {
if (stake > this.bankroll) {
throw new Error('Insufficient bankroll');
}
const profit = won ? stake * (decimalOdds - 1) : -stake;
this.bankroll += profit;
this.peakBankroll = Math.max(this.peakBankroll, this.bankroll);
this.stats.totalBets++;
this.stats.totalWagered += stake;
this.stats.totalProfit += profit;
if (won) this.stats.wins++;
else this.stats.losses++;
this.history.push({
timestamp: Date.now(),
stake,
decimalOdds,
won,
profit,
bankroll: this.bankroll
});
// Check drawdown protection
const drawdown = (this.peakBankroll - this.bankroll) / this.peakBankroll;
if (drawdown >= this.config.risk.maxDrawdown) {
return {
...this.getStats(),
warning: 'max_drawdown_reached',
drawdown: drawdown * 100
};
}
return this.getStats();
}
/**
* Get current statistics
*/
getStats() {
const drawdown = (this.peakBankroll - this.bankroll) / this.peakBankroll;
const roi = this.stats.totalWagered > 0
? (this.stats.totalProfit / this.stats.totalWagered) * 100
: 0;
const winRate = this.stats.totalBets > 0
? (this.stats.wins / this.stats.totalBets) * 100
: 0;
return {
bankroll: Math.round(this.bankroll * 100) / 100,
peakBankroll: Math.round(this.peakBankroll * 100) / 100,
drawdown: Math.round(drawdown * 10000) / 100,
totalBets: this.stats.totalBets,
wins: this.stats.wins,
losses: this.stats.losses,
winRate: Math.round(winRate * 100) / 100,
totalWagered: Math.round(this.stats.totalWagered * 100) / 100,
totalProfit: Math.round(this.stats.totalProfit * 100) / 100,
roi: Math.round(roi * 100) / 100
};
}
/**
* Simulate betting strategy
*/
simulate(bets, fraction = 'conservative') {
const results = [];
for (const bet of bets) {
const kelly = this.calculateFractionalKelly(bet.winProbability, bet.decimalOdds, fraction);
if (kelly.stake > 0) {
const outcome = this.placeBet(kelly.stake, bet.decimalOdds, bet.actualWin);
results.push({
bet,
kelly,
outcome,
bankroll: this.bankroll
});
}
}
return {
finalStats: this.getStats(),
betResults: results
};
}
/**
* Reset bankroll to initial state
*/
reset() {
this.bankroll = this.config.bankroll.initial;
this.peakBankroll = this.bankroll;
this.history = [];
this.stats = {
totalBets: 0,
wins: 0,
losses: 0,
totalWagered: 0,
totalProfit: 0
};
}
}
/**
* Sports Betting Kelly Extension
* Specialized for sports betting markets
*/
class SportsBettingKelly extends KellyCriterion {
constructor(config = kellyConfig) {
super(config);
this.marketEfficiency = 0.95; // Assume 95% efficient markets
}
/**
* Convert American odds to decimal
*/
americanToDecimal(americanOdds) {
if (americanOdds > 0) {
return (americanOdds / 100) + 1;
} else {
return (100 / Math.abs(americanOdds)) + 1;
}
}
/**
* Calculate implied probability from odds
*/
impliedProbability(decimalOdds) {
return 1 / decimalOdds;
}
/**
* Calculate edge over market
*/
calculateEdge(modelProbability, decimalOdds) {
const impliedProb = this.impliedProbability(decimalOdds);
return modelProbability - impliedProb;
}
/**
* Find value bets from model predictions vs market odds
*/
findValueBets(predictions, marketOdds, minEdge = 0.02) {
const valueBets = [];
for (let i = 0; i < predictions.length; i++) {
const pred = predictions[i];
const odds = marketOdds[i];
// Check home team value
const homeEdge = this.calculateEdge(pred.homeWinProb, odds.homeDecimal);
if (homeEdge >= minEdge) {
valueBets.push({
matchId: pred.matchId,
selection: 'home',
modelProbability: pred.homeWinProb,
decimalOdds: odds.homeDecimal,
edge: homeEdge,
kelly: this.calculateFractionalKelly(pred.homeWinProb, odds.homeDecimal)
});
}
// Check away team value
const awayEdge = this.calculateEdge(pred.awayWinProb, odds.awayDecimal);
if (awayEdge >= minEdge) {
valueBets.push({
matchId: pred.matchId,
selection: 'away',
modelProbability: pred.awayWinProb,
decimalOdds: odds.awayDecimal,
edge: awayEdge,
kelly: this.calculateFractionalKelly(pred.awayWinProb, odds.awayDecimal)
});
}
// Check draw if applicable
if (pred.drawProb && odds.drawDecimal) {
const drawEdge = this.calculateEdge(pred.drawProb, odds.drawDecimal);
if (drawEdge >= minEdge) {
valueBets.push({
matchId: pred.matchId,
selection: 'draw',
modelProbability: pred.drawProb,
decimalOdds: odds.drawDecimal,
edge: drawEdge,
kelly: this.calculateFractionalKelly(pred.drawProb, odds.drawDecimal)
});
}
}
}
return valueBets.sort((a, b) => b.edge - a.edge);
}
}
/**
* Trading Kelly Extension
* Specialized for financial market position sizing
*/
class TradingKelly extends KellyCriterion {
constructor(config = kellyConfig) {
super(config);
}
/**
* Calculate position size for a trade
* Uses expected return and win rate from historical analysis
*/
calculatePositionSize(winRate, avgWin, avgLoss, accountSize = null) {
const bankroll = accountSize || this.bankroll;
// Convert to Kelly inputs
// For trading: b = avgWin/avgLoss (reward/risk ratio)
const b = avgWin / Math.abs(avgLoss);
const p = winRate;
const q = 1 - p;
const fullKelly = (b * p - q) / b;
if (fullKelly <= 0) {
return {
positionSize: 0,
reason: 'negative_expectancy',
expectancy: (winRate * avgWin) + ((1 - winRate) * avgLoss)
};
}
const fractionValue = this.config.fractions.conservative;
let adjustedKelly = fullKelly * fractionValue;
adjustedKelly = Math.min(adjustedKelly, this.config.risk.maxBetFraction);
const positionSize = bankroll * adjustedKelly;
const expectancy = (winRate * avgWin) + ((1 - winRate) * avgLoss);
return {
positionSize: Math.round(positionSize * 100) / 100,
positionPercent: adjustedKelly * 100,
fullKelly: fullKelly * 100,
rewardRiskRatio: b,
winRate: winRate * 100,
expectancy,
expectancyPercent: expectancy * 100
};
}
/**
* Calculate optimal leverage using Kelly
*/
calculateOptimalLeverage(expectedReturn, volatility, riskFreeRate = 0.05) {
// Kelly for continuous returns: f* = (μ - r) / σ²
const excessReturn = expectedReturn - riskFreeRate;
const kelly = excessReturn / (volatility * volatility);
// Apply fraction and caps
const fractionValue = this.config.fractions.conservative;
let adjustedLeverage = kelly * fractionValue;
// Cap leverage at reasonable levels
const maxLeverage = 3.0;
adjustedLeverage = Math.min(adjustedLeverage, maxLeverage);
adjustedLeverage = Math.max(adjustedLeverage, 0);
return {
optimalLeverage: Math.round(adjustedLeverage * 100) / 100,
fullKellyLeverage: Math.round(kelly * 100) / 100,
sharpeRatio: excessReturn / volatility,
expectedReturn: expectedReturn * 100,
volatility: volatility * 100
};
}
}
// Demo and test
async function main() {
console.log('═'.repeat(70));
console.log('FRACTIONAL KELLY CRITERION ENGINE');
console.log('═'.repeat(70));
console.log();
// 1. Basic Kelly calculations
console.log('1. Basic Kelly Calculations:');
console.log('─'.repeat(70));
const kelly = new KellyCriterion();
// Example: 55% win probability, 2.0 decimal odds (even money)
const basic = kelly.calculateFractionalKelly(0.55, 2.0);
console.log(' Win Prob: 55%, Odds: 2.0 (even money)');
console.log(` Full Kelly: ${basic.fullKelly.toFixed(2)}%`);
console.log(` 1/5th Kelly: ${basic.fractionalKelly.toFixed(2)}%`);
console.log(` Recommended Stake: $${basic.stake.toFixed(2)}`);
console.log(` Edge: ${basic.edge.toFixed(2)}%`);
console.log();
// 2. Calibrated Kelly (for ML models)
console.log('2. Calibrated Kelly (ML Model Adjustment):');
console.log('─'.repeat(70));
const calibrated = kelly.calculateCalibratedKelly(0.60, 0.85, 2.0);
console.log(' Model Prediction: 60%, Calibration Score: 0.85');
console.log(` Adjusted Prob: ${(calibrated.adjustedProb * 100).toFixed(2)}%`);
console.log(` Recommended Stake: $${calibrated.stake.toFixed(2)}`);
console.log();
// 3. Multi-bet portfolio
console.log('3. Multi-Bet Portfolio:');
console.log('─'.repeat(70));
const multiBets = kelly.calculateMultiBetKelly([
{ id: 1, winProbability: 0.55, decimalOdds: 2.0 },
{ id: 2, winProbability: 0.52, decimalOdds: 2.1 },
{ id: 3, winProbability: 0.58, decimalOdds: 1.9 },
{ id: 4, winProbability: 0.51, decimalOdds: 2.2 }
]);
console.log(' Bet │ Win Prob │ Odds │ Individual │ Portfolio │ Final Stake');
console.log('─'.repeat(70));
for (const bet of multiBets) {
if (bet.kelly.stake > 0) {
console.log(` ${bet.id}${(bet.winProbability * 100).toFixed(0)}% │ ${bet.decimalOdds.toFixed(1)}$${bet.kelly.originalStake?.toFixed(2) || bet.kelly.stake.toFixed(2)}${(bet.kelly.correlationAdjustment * 100 || 100).toFixed(0)}% │ $${bet.kelly.stake.toFixed(2)}`);
}
}
console.log();
// 4. Risk of ruin analysis
console.log('4. Risk of Ruin Analysis:');
console.log('─'.repeat(70));
const strategies = [
{ name: 'Full Kelly', fraction: 1.0 },
{ name: 'Half Kelly', fraction: 0.5 },
{ name: '1/5th Kelly', fraction: 0.2 },
{ name: '1/8th Kelly', fraction: 0.125 }
];
console.log(' Strategy │ Bet Size │ Risk of Ruin (2x target)');
console.log('─'.repeat(70));
for (const strat of strategies) {
const fullKelly = kelly.calculateFullKelly(0.55, 2.0);
const betFraction = fullKelly * strat.fraction;
const ror = kelly.calculateRiskOfRuin(0.55, 2.0, betFraction, 2);
console.log(` ${strat.name.padEnd(12)}${(betFraction * 100).toFixed(2)}% │ ${(ror * 100).toFixed(2)}%`);
}
console.log();
// 5. Sports betting simulation
console.log('5. Sports Betting Simulation (100 bets):');
console.log('─'.repeat(70));
const sportsKelly = new SportsBettingKelly();
// Generate simulated bets with 55% edge
const simulatedBets = [];
let rng = 42;
const random = () => { rng = (rng * 9301 + 49297) % 233280; return rng / 233280; };
for (let i = 0; i < 100; i++) {
const trueProb = 0.50 + random() * 0.15; // 50-65% true probability
const odds = 1.8 + random() * 0.4; // 1.8-2.2 odds
const actualWin = random() < trueProb;
simulatedBets.push({
winProbability: trueProb,
decimalOdds: odds,
actualWin
});
}
// Run simulations with different Kelly fractions
const fractions = ['aggressive', 'moderate', 'conservative', 'ultraSafe'];
console.log(' Fraction │ Final Bankroll │ ROI │ Max Drawdown');
console.log('─'.repeat(70));
for (const frac of fractions) {
sportsKelly.reset();
sportsKelly.simulate(simulatedBets, frac);
const stats = sportsKelly.getStats();
console.log(` ${frac.padEnd(12)}$${stats.bankroll.toFixed(2).padStart(12)}${stats.roi.toFixed(1).padStart(6)}% │ ${stats.drawdown.toFixed(1)}%`);
}
console.log();
// 6. Trading position sizing
console.log('6. Trading Position Sizing:');
console.log('─'.repeat(70));
const tradingKelly = new TradingKelly();
const position = tradingKelly.calculatePositionSize(0.55, 0.02, -0.015, 100000);
console.log(' Win Rate: 55%, Avg Win: 2%, Avg Loss: -1.5%');
console.log(` Reward/Risk Ratio: ${position.rewardRiskRatio.toFixed(2)}`);
console.log(` Position Size: $${position.positionSize.toFixed(2)} (${position.positionPercent.toFixed(2)}%)`);
console.log(` Expectancy: ${position.expectancyPercent.toFixed(2)}% per trade`);
console.log();
// 7. Optimal leverage
console.log('7. Optimal Leverage Calculation:');
console.log('─'.repeat(70));
const leverage = tradingKelly.calculateOptimalLeverage(0.12, 0.18, 0.05);
console.log(' Expected Return: 12%, Volatility: 18%, Risk-Free: 5%');
console.log(` Sharpe Ratio: ${leverage.sharpeRatio.toFixed(2)}`);
console.log(` Full Kelly Leverage: ${leverage.fullKellyLeverage.toFixed(2)}x`);
console.log(` Recommended (1/5): ${leverage.optimalLeverage.toFixed(2)}x`);
console.log();
console.log('═'.repeat(70));
console.log('Fractional Kelly engine demonstration completed');
console.log('═'.repeat(70));
}
// Export for use as module
export {
KellyCriterion,
SportsBettingKelly,
TradingKelly,
kellyConfig
};
main().catch(console.error);