From 9fecd2d1ffae0a36aba7fa5781f3e3c22a268f18 Mon Sep 17 00:00:00 2001 From: Samrat Chowdhury Date: Sat, 7 Mar 2026 19:04:56 +0530 Subject: [PATCH 1/3] feat: add Kelly Criterion and Sharpe Ratio to financial algorithms --- financial/kelly_criterion.py | 201 +++++++++++++++++++++++++++++++++++ financial/sharpe_ratio.py | 145 +++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 financial/kelly_criterion.py create mode 100644 financial/sharpe_ratio.py diff --git a/financial/kelly_criterion.py b/financial/kelly_criterion.py new file mode 100644 index 000000000000..076f6ef58927 --- /dev/null +++ b/financial/kelly_criterion.py @@ -0,0 +1,201 @@ +""" +Kelly Criterion for optimal position sizing in betting and trading. + +The Kelly Criterion is a formula used to determine the optimal size of a series of bets +or investments to maximize logarithmic wealth over time. It was developed by John L. +Kelly Jr. in 1956. + +Wikipedia Reference: https://en.wikipedia.org/wiki/Kelly_criterion +Investopedia: https://www.investopedia.com/articles/trading/04/091504.asp + +The Kelly Criterion is widely used in: +- Sports betting and gambling to determine optimal bet sizes +- Investment portfolio management to size positions +- Trading strategies to manage risk and maximize growth +""" + +from __future__ import annotations + + +def kelly_criterion(win_probability: float, win_loss_ratio: float) -> float: + """ + Calculate the optimal fraction of bankroll to bet using the Kelly Criterion. + + The Kelly Criterion formula: + f* = (p * b - q) / b + + Where: + f* = fraction of bankroll to bet (Kelly fraction) + p = probability of winning + q = probability of losing (1 - p) + b = win/loss ratio (amount won per unit staked / amount lost per unit staked) + + :param win_probability: Probability of winning (0 < p < 1) + :param win_loss_ratio: Ratio of win amount to loss amount (b > 0) + :return: Optimal fraction of bankroll to bet + + >>> round(kelly_criterion(0.6, 2.0), 4) + 0.4 + >>> round(kelly_criterion(0.55, 1.0), 4) + 0.1 + >>> kelly_criterion(0.5, 1.0) + 0.0 + >>> round(kelly_criterion(0.7, 3.0), 4) + 0.6 + >>> round(kelly_criterion(0.3, 2.0), 4) + -0.05 + >>> kelly_criterion(0.0, 1.0) + Traceback (most recent call last): + ... + ValueError: win_probability must be between 0 and 1 (exclusive) + >>> kelly_criterion(1.0, 1.0) + Traceback (most recent call last): + ... + ValueError: win_probability must be between 0 and 1 (exclusive) + >>> kelly_criterion(0.5, 0.0) + Traceback (most recent call last): + ... + ValueError: win_loss_ratio must be > 0 + >>> kelly_criterion(0.5, -1.0) + Traceback (most recent call last): + ... + ValueError: win_loss_ratio must be > 0 + """ + if win_probability <= 0 or win_probability >= 1: + raise ValueError("win_probability must be between 0 and 1 (exclusive)") + if win_loss_ratio <= 0: + raise ValueError("win_loss_ratio must be > 0") + + loss_probability = 1 - win_probability + kelly_fraction = (win_probability * win_loss_ratio - loss_probability) / ( + win_loss_ratio + ) + + return kelly_fraction + + +def kelly_criterion_extended( + win_probability: float, win_amount: float, loss_amount: float +) -> float: + """ + Calculate the Kelly fraction using explicit win and loss amounts. + + This is a more general form of the Kelly Criterion that accepts + absolute win and loss amounts rather than a ratio. + + Formula: + f* = (p * W - q * L) / (W * L) + + Where: + p = probability of winning + q = probability of losing (1 - p) + W = amount won per unit bet + L = amount lost per unit bet (positive value) + + :param win_probability: Probability of winning (0 < p < 1) + :param win_amount: Amount won per unit bet (W > 0) + :param loss_amount: Amount lost per unit bet (L > 0) + :return: Optimal fraction of bankroll to bet + + >>> round(kelly_criterion_extended(0.6, 2.0, 1.0), 4) + 0.4 + >>> round(kelly_criterion_extended(0.55, 1.5, 1.5), 4) + 0.1 + >>> kelly_criterion_extended(0.5, 1.0, 1.0) + 0.0 + >>> round(kelly_criterion_extended(0.7, 3.0, 1.0), 4) + 0.6 + >>> kelly_criterion_extended(0.0, 1.0, 1.0) + Traceback (most recent call last): + ... + ValueError: win_probability must be between 0 and 1 (exclusive) + >>> kelly_criterion_extended(0.5, 0.0, 1.0) + Traceback (most recent call last): + ... + ValueError: win_amount must be > 0 + >>> kelly_criterion_extended(0.5, 1.0, 0.0) + Traceback (most recent call last): + ... + ValueError: loss_amount must be > 0 + """ + if win_probability <= 0 or win_probability >= 1: + raise ValueError("win_probability must be between 0 and 1 (exclusive)") + if win_amount <= 0: + raise ValueError("win_amount must be > 0") + if loss_amount <= 0: + raise ValueError("loss_amount must be > 0") + + loss_probability = 1 - win_probability + # Convert to win/loss ratio format: b = win_amount / loss_amount + # Then apply Kelly formula: (p * b - q) / b + win_loss_ratio = win_amount / loss_amount + kelly_fraction = (win_probability * win_loss_ratio - loss_probability) / ( + win_loss_ratio + ) + + return kelly_fraction + + +def fractional_kelly( + win_probability: float, win_loss_ratio: float, fraction: float = 0.5 +) -> float: + """ + Calculate a fractional Kelly bet size to reduce volatility. + + Many practitioners use a fraction of the Kelly Criterion (e.g., half-Kelly) + to reduce risk and volatility while still achieving good growth. This is + because the full Kelly can lead to large drawdowns. + + Formula: + f*_fractional = fraction * f* + + Where f* is the Kelly Criterion optimal fraction. + + :param win_probability: Probability of winning (0 < p < 1) + :param win_loss_ratio: Ratio of win amount to loss amount (b > 0) + :param fraction: Fraction of Kelly to use (0 < fraction <= 1), default 0.5 + :return: Fractional Kelly bet size + + >>> round(fractional_kelly(0.6, 2.0, 0.5), 4) + 0.2 + >>> round(fractional_kelly(0.55, 1.0, 0.25), 4) + 0.025 + >>> round(fractional_kelly(0.7, 3.0, 1.0), 4) + 0.6 + >>> fractional_kelly(0.6, 2.0, 0.0) + Traceback (most recent call last): + ... + ValueError: fraction must be between 0 and 1 (exclusive for 0, inclusive for 1) + >>> fractional_kelly(0.6, 2.0, 1.5) + Traceback (most recent call last): + ... + ValueError: fraction must be between 0 and 1 (exclusive for 0, inclusive for 1) + >>> fractional_kelly(0.0, 2.0, 0.5) + Traceback (most recent call last): + ... + ValueError: win_probability must be between 0 and 1 (exclusive) + """ + if fraction <= 0 or fraction > 1: + raise ValueError( + "fraction must be between 0 and 1 (exclusive for 0, inclusive for 1)" + ) + + full_kelly = kelly_criterion(win_probability, win_loss_ratio) + return fraction * full_kelly + + +if __name__ == "__main__": + import doctest + + doctest.testmod() + + # Example: A bet with 60% win probability and 2:1 odds + win_prob = 0.6 + odds = 2.0 + full_kelly = kelly_criterion(win_prob, odds) + half_kelly = fractional_kelly(win_prob, odds, 0.5) + + print(f"Win probability: {win_prob}") + print(f"Win/loss ratio: {odds}") + print(f"Full Kelly fraction: {full_kelly:.2%}") + print(f"Half Kelly fraction: {half_kelly:.2%}") diff --git a/financial/sharpe_ratio.py b/financial/sharpe_ratio.py new file mode 100644 index 000000000000..f6f50e04642a --- /dev/null +++ b/financial/sharpe_ratio.py @@ -0,0 +1,145 @@ +""" +Sharpe Ratio for measuring risk-adjusted returns in investment portfolios. + +The Sharpe Ratio is a measure of risk-adjusted return developed by Nobel laureate +William F. Sharpe. It calculates the excess return per unit of risk (standard deviation) +and is widely used to compare the performance of investment portfolios. + +Wikipedia Reference: https://en.wikipedia.org/wiki/Sharpe_ratio +Investopedia: https://www.investopedia.com/terms/s/sharperatio.asp + +The Sharpe Ratio is used for: +- Comparing performance of different investment strategies +- Evaluating mutual funds and hedge funds +- Portfolio optimization and risk management +- Assessing risk-adjusted returns in trading strategies +""" + +from __future__ import annotations + + +def sharpe_ratio(returns: list[float], risk_free_rate: float = 0.0) -> float: + """ + Calculate the Sharpe Ratio for a series of returns. + + The Sharpe Ratio formula: + S = (R - Rf) / σ + + Where: + S = Sharpe Ratio + R = Average return of the investment + Rf = Risk-free rate of return + σ = Standard deviation of returns (volatility) + + :param returns: List of periodic returns (e.g., daily, monthly) + :param risk_free_rate: Risk-free rate of return per period, default 0.0 + :return: Sharpe Ratio + + >>> round(sharpe_ratio([0.1, 0.2, 0.15, 0.05, 0.12]), 4) + 2.2164 + >>> sharpe_ratio([0.05, 0.05, 0.05, 0.05, 0.05]) + inf + >>> round(sharpe_ratio([0.1, 0.2, 0.15, 0.05, 0.12], 0.02), 4) + 1.8589 + >>> sharpe_ratio([0.0, 0.0, 0.0, 0.0, 0.0]) + 0.0 + >>> round(sharpe_ratio([-0.05, -0.1, -0.08, -0.12, -0.15]), 4) + -2.6261 + >>> sharpe_ratio([]) + Traceback (most recent call last): + ... + ValueError: returns list must not be empty + >>> sharpe_ratio([0.1]) + Traceback (most recent call last): + ... + ValueError: returns list must contain at least 2 values + """ + if not returns: + raise ValueError("returns list must not be empty") + if len(returns) < 2: + raise ValueError("returns list must contain at least 2 values") + + # Calculate mean return + mean_return = sum(returns) / len(returns) + + # Calculate excess return + excess_return = mean_return - risk_free_rate + + # Calculate standard deviation (using sample standard deviation with n-1) + variance = sum((r - mean_return) ** 2 for r in returns) / (len(returns) - 1) + std_dev = variance**0.5 + + # Handle zero volatility case + if std_dev == 0: + return float("inf") if excess_return > 0 else 0.0 + + return excess_return / std_dev + + +def annualized_sharpe_ratio( + returns: list[float], risk_free_rate: float = 0.0, periods_per_year: int = 252 +) -> float: + """ + Calculate the annualized Sharpe Ratio for a series of periodic returns. + + The annualized Sharpe Ratio accounts for the time period of returns: + S_annual = S_periodic * sqrt(periods_per_year) + + Common periods_per_year values: + - Daily returns: 252 (trading days) + - Weekly returns: 52 + - Monthly returns: 12 + - Quarterly returns: 4 + + :param returns: List of periodic returns + :param risk_free_rate: Risk-free rate per period, default 0.0 + :param periods_per_year: Number of periods in a year, default 252 (daily) + :return: Annualized Sharpe Ratio + + >>> round(annualized_sharpe_ratio([0.001, 0.002, 0.0015, 0.0005, 0.0012], 0.0, 252), 4) + 35.1844 + >>> round(annualized_sharpe_ratio([0.01, 0.02, 0.015, 0.005, 0.012], 0.0, 12), 4) + 7.6779 + >>> round(annualized_sharpe_ratio([0.05, 0.06, 0.055, 0.045, 0.052], 0.0, 4), 4) + 18.7322 + >>> round(annualized_sharpe_ratio([0.001, 0.002, 0.0015], 0.0001, 252), 4) + 44.4486 + >>> round(annualized_sharpe_ratio([0.001, 0.002], 0.0, 252), 4) + 33.6749 + >>> annualized_sharpe_ratio([0.001, 0.002, 0.0015], 0.0, 0) + Traceback (most recent call last): + ... + ValueError: periods_per_year must be > 0 + >>> annualized_sharpe_ratio([0.001, 0.002, 0.0015], 0.0, -252) + Traceback (most recent call last): + ... + ValueError: periods_per_year must be > 0 + """ + if periods_per_year <= 0: + raise ValueError("periods_per_year must be > 0") + + periodic_sharpe = sharpe_ratio(returns, risk_free_rate) + + # Annualize by multiplying by square root of periods + if periodic_sharpe == float("inf"): + return float("inf") + + return periodic_sharpe * (periods_per_year**0.5) + + +if __name__ == "__main__": + import doctest + + doctest.testmod() + + # Example: Calculate Sharpe Ratio for a series of monthly returns + monthly_returns = [0.02, 0.03, -0.01, 0.04, 0.01, 0.02, -0.02, 0.03, 0.02, 0.01] + risk_free = 0.002 # 0.2% monthly risk-free rate + + sharpe = sharpe_ratio(monthly_returns, risk_free) + annualized = annualized_sharpe_ratio(monthly_returns, risk_free, 12) + + print(f"Monthly returns: {monthly_returns}") + print(f"Risk-free rate: {risk_free:.2%}") + print(f"Sharpe Ratio: {sharpe:.4f}") + print(f"Annualized Sharpe Ratio: {annualized:.4f}") From a78b2614a723e5917411afb580bd34c04a063899 Mon Sep 17 00:00:00 2001 From: Samrat Chowdhury Date: Sat, 7 Mar 2026 19:10:06 +0530 Subject: [PATCH 2/3] fix: replace ambiguous unicode sigma and shorten long docstring line --- financial/sharpe_ratio.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/financial/sharpe_ratio.py b/financial/sharpe_ratio.py index f6f50e04642a..688e12048418 100644 --- a/financial/sharpe_ratio.py +++ b/financial/sharpe_ratio.py @@ -23,13 +23,13 @@ def sharpe_ratio(returns: list[float], risk_free_rate: float = 0.0) -> float: Calculate the Sharpe Ratio for a series of returns. The Sharpe Ratio formula: - S = (R - Rf) / σ + S = (R - Rf) / std_dev Where: S = Sharpe Ratio R = Average return of the investment Rf = Risk-free rate of return - σ = Standard deviation of returns (volatility) + std_dev = Standard deviation of returns (volatility) :param returns: List of periodic returns (e.g., daily, monthly) :param risk_free_rate: Risk-free rate of return per period, default 0.0 @@ -96,7 +96,8 @@ def annualized_sharpe_ratio( :param periods_per_year: Number of periods in a year, default 252 (daily) :return: Annualized Sharpe Ratio - >>> round(annualized_sharpe_ratio([0.001, 0.002, 0.0015, 0.0005, 0.0012], 0.0, 252), 4) + >>> round(annualized_sharpe_ratio( + ... [0.001, 0.002, 0.0015, 0.0005, 0.0012], 0.0, 252), 4) 35.1844 >>> round(annualized_sharpe_ratio([0.01, 0.02, 0.015, 0.005, 0.012], 0.0, 12), 4) 7.6779 From 18fdba855c143cf0f5f251f46cf274346ed4cab5 Mon Sep 17 00:00:00 2001 From: Samrat Chowdhury Date: Sat, 7 Mar 2026 19:16:16 +0530 Subject: [PATCH 3/3] fix: resolve pre-existing ruff errors in hashing, jump_search, and lda --- data_structures/hashing/hash_table_with_linked_list.py | 2 +- machine_learning/linear_discriminant_analysis.py | 2 +- searches/jump_search.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data_structures/hashing/hash_table_with_linked_list.py b/data_structures/hashing/hash_table_with_linked_list.py index f404c5251246..c8dffa30b8e8 100644 --- a/data_structures/hashing/hash_table_with_linked_list.py +++ b/data_structures/hashing/hash_table_with_linked_list.py @@ -8,7 +8,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def _set_value(self, key, data): - self.values[key] = deque([]) if self.values[key] is None else self.values[key] + self.values[key] = deque() if self.values[key] is None else self.values[key] self.values[key].appendleft(data) self._keys[key] = self.values[key] diff --git a/machine_learning/linear_discriminant_analysis.py b/machine_learning/linear_discriminant_analysis.py index 8528ccbbae51..af5248c4dbc4 100644 --- a/machine_learning/linear_discriminant_analysis.py +++ b/machine_learning/linear_discriminant_analysis.py @@ -252,7 +252,7 @@ def accuracy(actual_y: list, predicted_y: list) -> float: num = TypeVar("num") -def valid_input( +def valid_input[num]( input_type: Callable[[object], num], # Usually float or int input_msg: str, err_msg: str, diff --git a/searches/jump_search.py b/searches/jump_search.py index e72d85e8a868..b443d09283b0 100644 --- a/searches/jump_search.py +++ b/searches/jump_search.py @@ -20,7 +20,7 @@ def __lt__(self, other: Any, /) -> bool: ... T = TypeVar("T", bound=Comparable) -def jump_search(arr: Sequence[T], item: T) -> int: +def jump_search[T: Comparable](arr: Sequence[T], item: T) -> int: """ Python implementation of the jump search algorithm. Return the index if the `item` is found, otherwise return -1.