From c81bceb7d203308ba4aca51d9ca847060af0e26b Mon Sep 17 00:00:00 2001 From: Ian Alloway Date: Tue, 14 Jul 2026 14:03:33 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20hedgeBet()=20=E2=80=94=20lock=20in=20gu?= =?UTF-8?q?aranteed=20profit=20on=20line-movement=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hedgeBet(originalStake, originalAmericanOdds, hedgeAmericanOdds) which computes the stake on the opposite side that equalises gross returns in both outcomes. Classic use: early underdog bet that has moved in your favour — calculate exactly how much to hedge for a risk-free profit. Returns hedgeStake, guaranteedProfit, isProfit, roi, totalRisked, grossReturn. Five new tests cover the profitable-hedge, break-even, loss, gross-parity, and input-validation cases. Co-Authored-By: Claude Sonnet 4.6 --- src/index.test.ts | 49 ++++++++++++++++++++++++++++++++++ src/index.ts | 67 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/index.test.ts b/src/index.test.ts index 17cb796..50f4e01 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -22,6 +22,7 @@ import { kellyGrowthRate, dutching, kellyParlay, + hedgeBet, } from './index'; describe('kelly-js: Kelly Criterion & Sports Betting Analytics', () => { @@ -1113,4 +1114,52 @@ describe('kelly-js: Kelly Criterion & Sports Betting Analytics', () => { expect(() => dutching(horseRace, -100)).toThrow(RangeError); }); }); + + // ──────────────────────────────────────────────────────────────────────────── + // Hedge Bet Tests + // ──────────────────────────────────────────────────────────────────────────── + + describe('hedgeBet()', () => { + it('locks in guaranteed profit when original odds were long', () => { + // Bet $100 at +300 early; opposite now available at +100 + const result = hedgeBet(100, 300, 100); + expect(result.hedgeStake).toBe(200); // 100 * 4.0 / 2.0 + expect(result.grossReturn).toBe(400); // 100 * 4.0 + expect(result.totalRisked).toBe(300); + expect(result.guaranteedProfit).toBe(100); + expect(result.isProfit).toBe(true); + expect(result.roi).toBeCloseTo(1 / 3, 3); + }); + + it('breaks even when original and hedge odds are identical', () => { + // Same odds on both sides: gross return exactly covers both stakes + const result = hedgeBet(100, 100, 100); + expect(result.guaranteedProfit).toBe(0); + expect(result.isProfit).toBe(false); + expect(result.hedgeStake).toBe(100); // equal odds → hedge = original stake + }); + + it('reflects a loss when hedge odds are shorter than original', () => { + // Original at +100 (decimal 2.0), hedge at -200 (decimal 1.5) + const result = hedgeBet(100, 100, -200); + // hedgeStake = 100 * 2.0 / 1.5 ≈ 133.33 + // grossReturn = 200; totalRisked ≈ 233.33; profit ≈ -33.33 + expect(result.guaranteedProfit).toBeLessThan(0); + expect(result.isProfit).toBe(false); + }); + + it('gross return is equal for both outcome scenarios', () => { + // Property: hedgeStake * dHedge === originalStake * dOrig (equal gross) + const r = hedgeBet(200, 250, 150); + const dOrig = toDecimal(250); + const dHedge = toDecimal(150); + expect(r.hedgeStake * dHedge).toBeCloseTo(200 * dOrig, 1); + expect(r.grossReturn).toBeCloseTo(200 * dOrig, 1); + }); + + it('throws on non-positive stake', () => { + expect(() => hedgeBet(0, 200, -150)).toThrow(RangeError); + expect(() => hedgeBet(-100, 200, -150)).toThrow(RangeError); + }); + }); }); diff --git a/src/index.ts b/src/index.ts index b2347c6..b79d5dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1160,3 +1160,70 @@ export function dutching( overround: Math.round(overround * 10000) / 10000, }; } + +// ─── Hedge Bet ──────────────────────────────────────────────────────────────── + +export interface HedgeResult { + /** Stake to place on the opposite side to lock in equal gross returns */ + hedgeStake: number; + /** Guaranteed net profit regardless of which outcome wins */ + guaranteedProfit: number; + /** True when the hedge produces a net positive return */ + isProfit: boolean; + /** Combined ROI on all money committed (original + hedge) */ + roi: number; + /** Total capital at risk across both legs */ + totalRisked: number; + /** Gross payout from whichever side wins (identical for both scenarios) */ + grossReturn: number; +} + +/** + * Calculate the hedge stake that locks in an equal guaranteed return on an + * existing bet when the opposite side is now available at better odds. + * + * The classic scenario: you placed an early-season bet on an underdog at +300; + * they reach the final and the opposite side now offers +100. Hedging lets you + * guarantee a profit no matter who wins. + * + * The formula equalises the gross payout for both outcomes: + * `hedgeStake = originalStake × decimal(originalOdds) / decimal(hedgeOdds)` + * + * Profit is positive when the original odds are long enough that gross return + * exceeds combined stakes. See `arbitrage()` for the symmetric two-sided case. + * + * @param originalStake Amount placed on your original bet + * @param originalAmericanOdds American odds at which you placed the original bet + * @param hedgeAmericanOdds Current American odds on the opposite outcome + * + * @example + * // Bet $100 at +300 pre-season; they made the final, opposite now at +100 + * hedgeBet(100, 300, 100); + * // → { hedgeStake: 200, guaranteedProfit: 100, isProfit: true, roi: 0.333 } + */ +export function hedgeBet( + originalStake: number, + originalAmericanOdds: number, + hedgeAmericanOdds: number, +): HedgeResult { + if (!Number.isFinite(originalStake) || originalStake <= 0) { + throw new RangeError('originalStake must be a positive finite number'); + } + + const dOrig = toDecimal(originalAmericanOdds); + const dHedge = toDecimal(hedgeAmericanOdds); + + const grossReturn = Math.round(originalStake * dOrig * 100) / 100; + const hedgeStake = Math.round((grossReturn / dHedge) * 100) / 100; + const totalRisked = Math.round((originalStake + hedgeStake) * 100) / 100; + const guaranteedProfit = Math.round((grossReturn - totalRisked) * 100) / 100; + + return { + hedgeStake, + guaranteedProfit, + isProfit: guaranteedProfit > 0, + roi: totalRisked > 0 ? Math.round((guaranteedProfit / totalRisked) * 10000) / 10000 : 0, + totalRisked, + grossReturn, + }; +}