Skip to content

Commit 6f3f428

Browse files
committed
feat: add Kelly Criterion and Sharpe Ratio to financial algorithms
1 parent 678dedb commit 6f3f428

File tree

2 files changed

+346
-0
lines changed

2 files changed

+346
-0
lines changed

financial/kelly_criterion.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""
2+
Kelly Criterion for optimal position sizing in betting and trading.
3+
4+
The Kelly Criterion is a formula used to determine the optimal size of a series of bets
5+
or investments to maximize logarithmic wealth over time. It was developed by John L.
6+
Kelly Jr. in 1956.
7+
8+
Wikipedia Reference: https://en.wikipedia.org/wiki/Kelly_criterion
9+
Investopedia: https://www.investopedia.com/articles/trading/04/091504.asp
10+
11+
The Kelly Criterion is widely used in:
12+
- Sports betting and gambling to determine optimal bet sizes
13+
- Investment portfolio management to size positions
14+
- Trading strategies to manage risk and maximize growth
15+
"""
16+
17+
from __future__ import annotations
18+
19+
20+
def kelly_criterion(win_probability: float, win_loss_ratio: float) -> float:
21+
"""
22+
Calculate the optimal fraction of bankroll to bet using the Kelly Criterion.
23+
24+
The Kelly Criterion formula:
25+
f* = (p * b - q) / b
26+
27+
Where:
28+
f* = fraction of bankroll to bet (Kelly fraction)
29+
p = probability of winning
30+
q = probability of losing (1 - p)
31+
b = win/loss ratio (amount won per unit staked / amount lost per unit staked)
32+
33+
:param win_probability: Probability of winning (0 < p < 1)
34+
:param win_loss_ratio: Ratio of win amount to loss amount (b > 0)
35+
:return: Optimal fraction of bankroll to bet
36+
37+
>>> round(kelly_criterion(0.6, 2.0), 4)
38+
0.4
39+
>>> round(kelly_criterion(0.55, 1.0), 4)
40+
0.1
41+
>>> kelly_criterion(0.5, 1.0)
42+
0.0
43+
>>> round(kelly_criterion(0.7, 3.0), 4)
44+
0.6
45+
>>> round(kelly_criterion(0.3, 2.0), 4)
46+
-0.05
47+
>>> kelly_criterion(0.0, 1.0)
48+
Traceback (most recent call last):
49+
...
50+
ValueError: win_probability must be between 0 and 1 (exclusive)
51+
>>> kelly_criterion(1.0, 1.0)
52+
Traceback (most recent call last):
53+
...
54+
ValueError: win_probability must be between 0 and 1 (exclusive)
55+
>>> kelly_criterion(0.5, 0.0)
56+
Traceback (most recent call last):
57+
...
58+
ValueError: win_loss_ratio must be > 0
59+
>>> kelly_criterion(0.5, -1.0)
60+
Traceback (most recent call last):
61+
...
62+
ValueError: win_loss_ratio must be > 0
63+
"""
64+
if win_probability <= 0 or win_probability >= 1:
65+
raise ValueError("win_probability must be between 0 and 1 (exclusive)")
66+
if win_loss_ratio <= 0:
67+
raise ValueError("win_loss_ratio must be > 0")
68+
69+
loss_probability = 1 - win_probability
70+
kelly_fraction = (win_probability * win_loss_ratio - loss_probability) / (
71+
win_loss_ratio
72+
)
73+
74+
return kelly_fraction
75+
76+
77+
def kelly_criterion_extended(
78+
win_probability: float, win_amount: float, loss_amount: float
79+
) -> float:
80+
"""
81+
Calculate the Kelly fraction using explicit win and loss amounts.
82+
83+
This is a more general form of the Kelly Criterion that accepts
84+
absolute win and loss amounts rather than a ratio.
85+
86+
Formula:
87+
f* = (p * W - q * L) / (W * L)
88+
89+
Where:
90+
p = probability of winning
91+
q = probability of losing (1 - p)
92+
W = amount won per unit bet
93+
L = amount lost per unit bet (positive value)
94+
95+
:param win_probability: Probability of winning (0 < p < 1)
96+
:param win_amount: Amount won per unit bet (W > 0)
97+
:param loss_amount: Amount lost per unit bet (L > 0)
98+
:return: Optimal fraction of bankroll to bet
99+
100+
>>> round(kelly_criterion_extended(0.6, 2.0, 1.0), 4)
101+
0.4
102+
>>> round(kelly_criterion_extended(0.55, 1.5, 1.5), 4)
103+
0.1
104+
>>> kelly_criterion_extended(0.5, 1.0, 1.0)
105+
0.0
106+
>>> round(kelly_criterion_extended(0.7, 3.0, 1.0), 4)
107+
0.6
108+
>>> kelly_criterion_extended(0.0, 1.0, 1.0)
109+
Traceback (most recent call last):
110+
...
111+
ValueError: win_probability must be between 0 and 1 (exclusive)
112+
>>> kelly_criterion_extended(0.5, 0.0, 1.0)
113+
Traceback (most recent call last):
114+
...
115+
ValueError: win_amount must be > 0
116+
>>> kelly_criterion_extended(0.5, 1.0, 0.0)
117+
Traceback (most recent call last):
118+
...
119+
ValueError: loss_amount must be > 0
120+
"""
121+
if win_probability <= 0 or win_probability >= 1:
122+
raise ValueError("win_probability must be between 0 and 1 (exclusive)")
123+
if win_amount <= 0:
124+
raise ValueError("win_amount must be > 0")
125+
if loss_amount <= 0:
126+
raise ValueError("loss_amount must be > 0")
127+
128+
loss_probability = 1 - win_probability
129+
# Convert to win/loss ratio format: b = win_amount / loss_amount
130+
# Then apply Kelly formula: (p * b - q) / b
131+
win_loss_ratio = win_amount / loss_amount
132+
kelly_fraction = (win_probability * win_loss_ratio - loss_probability) / (
133+
win_loss_ratio
134+
)
135+
136+
return kelly_fraction
137+
138+
139+
def fractional_kelly(
140+
win_probability: float, win_loss_ratio: float, fraction: float = 0.5
141+
) -> float:
142+
"""
143+
Calculate a fractional Kelly bet size to reduce volatility.
144+
145+
Many practitioners use a fraction of the Kelly Criterion (e.g., half-Kelly)
146+
to reduce risk and volatility while still achieving good growth. This is
147+
because the full Kelly can lead to large drawdowns.
148+
149+
Formula:
150+
f*_fractional = fraction * f*
151+
152+
Where f* is the Kelly Criterion optimal fraction.
153+
154+
:param win_probability: Probability of winning (0 < p < 1)
155+
:param win_loss_ratio: Ratio of win amount to loss amount (b > 0)
156+
:param fraction: Fraction of Kelly to use (0 < fraction <= 1), default 0.5
157+
:return: Fractional Kelly bet size
158+
159+
>>> round(fractional_kelly(0.6, 2.0, 0.5), 4)
160+
0.2
161+
>>> round(fractional_kelly(0.55, 1.0, 0.25), 4)
162+
0.025
163+
>>> round(fractional_kelly(0.7, 3.0, 1.0), 4)
164+
0.6
165+
>>> fractional_kelly(0.6, 2.0, 0.0)
166+
Traceback (most recent call last):
167+
...
168+
ValueError: fraction must be between 0 and 1 (exclusive for 0, inclusive for 1)
169+
>>> fractional_kelly(0.6, 2.0, 1.5)
170+
Traceback (most recent call last):
171+
...
172+
ValueError: fraction must be between 0 and 1 (exclusive for 0, inclusive for 1)
173+
>>> fractional_kelly(0.0, 2.0, 0.5)
174+
Traceback (most recent call last):
175+
...
176+
ValueError: win_probability must be between 0 and 1 (exclusive)
177+
"""
178+
if fraction <= 0 or fraction > 1:
179+
raise ValueError(
180+
"fraction must be between 0 and 1 (exclusive for 0, inclusive for 1)"
181+
)
182+
183+
full_kelly = kelly_criterion(win_probability, win_loss_ratio)
184+
return fraction * full_kelly
185+
186+
187+
if __name__ == "__main__":
188+
import doctest
189+
190+
doctest.testmod()
191+
192+
# Example: A bet with 60% win probability and 2:1 odds
193+
win_prob = 0.6
194+
odds = 2.0
195+
full_kelly = kelly_criterion(win_prob, odds)
196+
half_kelly = fractional_kelly(win_prob, odds, 0.5)
197+
198+
print(f"Win probability: {win_prob}")
199+
print(f"Win/loss ratio: {odds}")
200+
print(f"Full Kelly fraction: {full_kelly:.2%}")
201+
print(f"Half Kelly fraction: {half_kelly:.2%}")

financial/sharpe_ratio.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""
2+
Sharpe Ratio for measuring risk-adjusted returns in investment portfolios.
3+
4+
The Sharpe Ratio is a measure of risk-adjusted return developed by Nobel laureate
5+
William F. Sharpe. It calculates the excess return per unit of risk (standard deviation)
6+
and is widely used to compare the performance of investment portfolios.
7+
8+
Wikipedia Reference: https://en.wikipedia.org/wiki/Sharpe_ratio
9+
Investopedia: https://www.investopedia.com/terms/s/sharperatio.asp
10+
11+
The Sharpe Ratio is used for:
12+
- Comparing performance of different investment strategies
13+
- Evaluating mutual funds and hedge funds
14+
- Portfolio optimization and risk management
15+
- Assessing risk-adjusted returns in trading strategies
16+
"""
17+
18+
from __future__ import annotations
19+
20+
21+
def sharpe_ratio(returns: list[float], risk_free_rate: float = 0.0) -> float:
22+
"""
23+
Calculate the Sharpe Ratio for a series of returns.
24+
25+
The Sharpe Ratio formula:
26+
S = (R - Rf) / σ
27+
28+
Where:
29+
S = Sharpe Ratio
30+
R = Average return of the investment
31+
Rf = Risk-free rate of return
32+
σ = Standard deviation of returns (volatility)
33+
34+
:param returns: List of periodic returns (e.g., daily, monthly)
35+
:param risk_free_rate: Risk-free rate of return per period, default 0.0
36+
:return: Sharpe Ratio
37+
38+
>>> round(sharpe_ratio([0.1, 0.2, 0.15, 0.05, 0.12]), 4)
39+
2.2164
40+
>>> sharpe_ratio([0.05, 0.05, 0.05, 0.05, 0.05])
41+
inf
42+
>>> round(sharpe_ratio([0.1, 0.2, 0.15, 0.05, 0.12], 0.02), 4)
43+
1.8589
44+
>>> sharpe_ratio([0.0, 0.0, 0.0, 0.0, 0.0])
45+
0.0
46+
>>> round(sharpe_ratio([-0.05, -0.1, -0.08, -0.12, -0.15]), 4)
47+
-2.6261
48+
>>> sharpe_ratio([])
49+
Traceback (most recent call last):
50+
...
51+
ValueError: returns list must not be empty
52+
>>> sharpe_ratio([0.1])
53+
Traceback (most recent call last):
54+
...
55+
ValueError: returns list must contain at least 2 values
56+
"""
57+
if not returns:
58+
raise ValueError("returns list must not be empty")
59+
if len(returns) < 2:
60+
raise ValueError("returns list must contain at least 2 values")
61+
62+
# Calculate mean return
63+
mean_return = sum(returns) / len(returns)
64+
65+
# Calculate excess return
66+
excess_return = mean_return - risk_free_rate
67+
68+
# Calculate standard deviation (using sample standard deviation with n-1)
69+
variance = sum((r - mean_return) ** 2 for r in returns) / (len(returns) - 1)
70+
std_dev = variance**0.5
71+
72+
# Handle zero volatility case
73+
if std_dev == 0:
74+
return float("inf") if excess_return > 0 else 0.0
75+
76+
return excess_return / std_dev
77+
78+
79+
def annualized_sharpe_ratio(
80+
returns: list[float], risk_free_rate: float = 0.0, periods_per_year: int = 252
81+
) -> float:
82+
"""
83+
Calculate the annualized Sharpe Ratio for a series of periodic returns.
84+
85+
The annualized Sharpe Ratio accounts for the time period of returns:
86+
S_annual = S_periodic * sqrt(periods_per_year)
87+
88+
Common periods_per_year values:
89+
- Daily returns: 252 (trading days)
90+
- Weekly returns: 52
91+
- Monthly returns: 12
92+
- Quarterly returns: 4
93+
94+
:param returns: List of periodic returns
95+
:param risk_free_rate: Risk-free rate per period, default 0.0
96+
:param periods_per_year: Number of periods in a year, default 252 (daily)
97+
:return: Annualized Sharpe Ratio
98+
99+
>>> round(annualized_sharpe_ratio([0.001, 0.002, 0.0015, 0.0005, 0.0012], 0.0, 252), 4)
100+
35.1844
101+
>>> round(annualized_sharpe_ratio([0.01, 0.02, 0.015, 0.005, 0.012], 0.0, 12), 4)
102+
7.6779
103+
>>> round(annualized_sharpe_ratio([0.05, 0.06, 0.055, 0.045, 0.052], 0.0, 4), 4)
104+
18.7322
105+
>>> round(annualized_sharpe_ratio([0.001, 0.002, 0.0015], 0.0001, 252), 4)
106+
44.4486
107+
>>> round(annualized_sharpe_ratio([0.001, 0.002], 0.0, 252), 4)
108+
33.6749
109+
>>> annualized_sharpe_ratio([0.001, 0.002, 0.0015], 0.0, 0)
110+
Traceback (most recent call last):
111+
...
112+
ValueError: periods_per_year must be > 0
113+
>>> annualized_sharpe_ratio([0.001, 0.002, 0.0015], 0.0, -252)
114+
Traceback (most recent call last):
115+
...
116+
ValueError: periods_per_year must be > 0
117+
"""
118+
if periods_per_year <= 0:
119+
raise ValueError("periods_per_year must be > 0")
120+
121+
periodic_sharpe = sharpe_ratio(returns, risk_free_rate)
122+
123+
# Annualize by multiplying by square root of periods
124+
if periodic_sharpe == float("inf"):
125+
return float("inf")
126+
127+
return periodic_sharpe * (periods_per_year**0.5)
128+
129+
130+
if __name__ == "__main__":
131+
import doctest
132+
133+
doctest.testmod()
134+
135+
# Example: Calculate Sharpe Ratio for a series of monthly returns
136+
monthly_returns = [0.02, 0.03, -0.01, 0.04, 0.01, 0.02, -0.02, 0.03, 0.02, 0.01]
137+
risk_free = 0.002 # 0.2% monthly risk-free rate
138+
139+
sharpe = sharpe_ratio(monthly_returns, risk_free)
140+
annualized = annualized_sharpe_ratio(monthly_returns, risk_free, 12)
141+
142+
print(f"Monthly returns: {monthly_returns}")
143+
print(f"Risk-free rate: {risk_free:.2%}")
144+
print(f"Sharpe Ratio: {sharpe:.4f}")
145+
print(f"Annualized Sharpe Ratio: {annualized:.4f}")

0 commit comments

Comments
 (0)