Skip to content

Commit 70ed0de

Browse files
Pigbibiclaudecursoragent
committed
feat(position_sizing): add Kelly criterion estimator
Introduce estimate_kelly() with KellyResult for unified position sizing; cap max_position_pct at 10% per risk policy. Co-Authored-By: Claude <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3e502cf commit 70ed0de

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Kelly criterion position sizing utilities."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
7+
_DEFAULT_MAX_POSITION_PCT = 0.10
8+
9+
10+
@dataclass(frozen=True)
11+
class KellyResult:
12+
win_rate: float
13+
avg_win: float
14+
avg_loss: float
15+
kelly_fraction: float
16+
half_kelly: float
17+
max_position_pct: float
18+
19+
20+
def estimate_kelly(returns: list[float]) -> KellyResult:
21+
"""Estimate Kelly fraction from a list of per-trade returns."""
22+
if not returns:
23+
return KellyResult(
24+
win_rate=0.0,
25+
avg_win=0.0,
26+
avg_loss=0.0,
27+
kelly_fraction=0.0,
28+
half_kelly=0.0,
29+
max_position_pct=0.0,
30+
)
31+
32+
wins = [value for value in returns if value > 0]
33+
losses = [value for value in returns if value < 0]
34+
35+
win_rate = len(wins) / len(returns)
36+
avg_win = sum(wins) / len(wins) if wins else 0.0
37+
avg_loss = abs(sum(losses) / len(losses)) if losses else 0.0
38+
39+
if avg_win <= 0.0:
40+
kelly_fraction = 0.0
41+
elif avg_loss <= 0.0:
42+
kelly_fraction = min(win_rate, 1.0)
43+
else:
44+
payoff_ratio = avg_win / avg_loss
45+
kelly_fraction = (win_rate * payoff_ratio - (1.0 - win_rate)) / payoff_ratio
46+
kelly_fraction = max(0.0, min(kelly_fraction, 1.0))
47+
48+
half_kelly = kelly_fraction / 2.0
49+
max_position_pct = min(half_kelly, _DEFAULT_MAX_POSITION_PCT)
50+
51+
return KellyResult(
52+
win_rate=win_rate,
53+
avg_win=avg_win,
54+
avg_loss=avg_loss,
55+
kelly_fraction=kelly_fraction,
56+
half_kelly=half_kelly,
57+
max_position_pct=max_position_pct,
58+
)

tests/test_position_sizing.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Tests for quant_platform_kit.position_sizing."""
2+
3+
from __future__ import annotations
4+
5+
import unittest
6+
7+
from quant_platform_kit.position_sizing import KellyResult, estimate_kelly
8+
9+
10+
class PositionSizingTests(unittest.TestCase):
11+
def test_all_wins(self) -> None:
12+
result = estimate_kelly([0.10, 0.05, 0.08])
13+
14+
self.assertEqual(result.win_rate, 1.0)
15+
self.assertAlmostEqual(result.avg_win, (0.10 + 0.05 + 0.08) / 3)
16+
self.assertEqual(result.avg_loss, 0.0)
17+
self.assertEqual(result.kelly_fraction, 1.0)
18+
self.assertEqual(result.half_kelly, 0.5)
19+
self.assertEqual(result.max_position_pct, 0.10)
20+
21+
def test_all_losses(self) -> None:
22+
result = estimate_kelly([-0.10, -0.05, -0.08])
23+
24+
self.assertEqual(result.win_rate, 0.0)
25+
self.assertEqual(result.avg_win, 0.0)
26+
self.assertAlmostEqual(result.avg_loss, (0.10 + 0.05 + 0.08) / 3)
27+
self.assertEqual(result.kelly_fraction, 0.0)
28+
self.assertEqual(result.half_kelly, 0.0)
29+
self.assertEqual(result.max_position_pct, 0.0)
30+
31+
def test_break_even(self) -> None:
32+
result = estimate_kelly([0.10, -0.10])
33+
34+
self.assertEqual(result.win_rate, 0.5)
35+
self.assertAlmostEqual(result.avg_win, 0.10)
36+
self.assertAlmostEqual(result.avg_loss, 0.10)
37+
self.assertAlmostEqual(result.kelly_fraction, 0.0)
38+
self.assertAlmostEqual(result.half_kelly, 0.0)
39+
self.assertAlmostEqual(result.max_position_pct, 0.0)
40+
41+
def test_positive_edge(self) -> None:
42+
result = estimate_kelly([0.20, 0.20, -0.10])
43+
44+
self.assertAlmostEqual(result.win_rate, 2 / 3)
45+
self.assertAlmostEqual(result.avg_win, 0.20)
46+
self.assertAlmostEqual(result.avg_loss, 0.10)
47+
self.assertAlmostEqual(result.kelly_fraction, 0.5)
48+
self.assertAlmostEqual(result.half_kelly, 0.25)
49+
self.assertAlmostEqual(result.max_position_pct, 0.10)
50+
51+
def test_empty_returns(self) -> None:
52+
result = estimate_kelly([])
53+
54+
self.assertEqual(
55+
result,
56+
KellyResult(
57+
win_rate=0.0,
58+
avg_win=0.0,
59+
avg_loss=0.0,
60+
kelly_fraction=0.0,
61+
half_kelly=0.0,
62+
max_position_pct=0.0,
63+
),
64+
)
65+
66+
def test_zero_returns_are_neutral(self) -> None:
67+
result = estimate_kelly([0.0, 0.0])
68+
69+
self.assertEqual(result.win_rate, 0.0)
70+
self.assertEqual(result.avg_win, 0.0)
71+
self.assertEqual(result.avg_loss, 0.0)
72+
self.assertEqual(result.kelly_fraction, 0.0)
73+
74+
def test_single_win(self) -> None:
75+
result = estimate_kelly([0.05])
76+
77+
self.assertEqual(result.win_rate, 1.0)
78+
self.assertEqual(result.kelly_fraction, 1.0)
79+
self.assertEqual(result.max_position_pct, 0.10)
80+
81+
def test_single_loss(self) -> None:
82+
result = estimate_kelly([-0.05])
83+
84+
self.assertEqual(result.win_rate, 0.0)
85+
self.assertEqual(result.kelly_fraction, 0.0)
86+
87+
def test_half_kelly_below_cap(self) -> None:
88+
result = estimate_kelly([0.04, 0.04, -0.02])
89+
90+
self.assertAlmostEqual(result.kelly_fraction, 0.5)
91+
self.assertAlmostEqual(result.half_kelly, 0.25)
92+
self.assertAlmostEqual(result.max_position_pct, 0.10)
93+
94+
def test_negative_edge_clamped_to_zero(self) -> None:
95+
result = estimate_kelly([0.05, -0.20, -0.20])
96+
97+
self.assertGreater(result.avg_loss, result.avg_win)
98+
self.assertEqual(result.kelly_fraction, 0.0)
99+
self.assertEqual(result.half_kelly, 0.0)
100+
self.assertEqual(result.max_position_pct, 0.0)
101+
102+
103+
if __name__ == "__main__":
104+
unittest.main()

0 commit comments

Comments
 (0)