Created
October 5, 2024 13:24
-
-
Save nilsFK/6f81231e2b9782445af79c28a959cfbd to your computer and use it in GitHub Desktop.
Stock percentage distribution
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
from typing import List, Dict | |
class AllocationError(Exception): | |
"""Custom exception for allocation errors.""" | |
pass | |
def validate_parameters( | |
num_stocks: int, | |
factor: float, | |
last_stock_cap: float, | |
min_allocation: float, | |
bonuses: Dict[int, float] | |
) -> None: | |
"""Validate input parameters before processing the allocation.""" | |
if num_stocks < 2: | |
raise AllocationError("There must be at least two stocks for allocation.") | |
if not (0 < factor): | |
raise AllocationError("The exponential factor must be a positive number.") | |
if not (0 < last_stock_cap <= 100): | |
raise AllocationError("The last_stock_cap must be between 0 and 100.") | |
if not (0 < min_allocation <= 100): | |
raise AllocationError("The min_allocation must be between 0 and 100.") | |
if any(bonus < 0 for bonus in bonuses.values()): | |
raise AllocationError("Bonuses cannot be negative.") | |
if len(bonuses) >= num_stocks: | |
raise AllocationError("Bonuses cannot be applied to all stocks.") | |
if any(stock_idx < 1 or stock_idx > num_stocks for stock_idx in bonuses.keys()): | |
raise AllocationError("Bonus stock indices must be between 1 and num_stocks.") | |
if sum(bonuses.values()) > 100: | |
raise AllocationError("The total bonuses exceed 100%.") | |
def round_percentages(percentages: List[float], decimal_places: int) -> List[float]: | |
"""Round the percentages to a specified number of decimal places.""" | |
return [round(pct, decimal_places) for pct in percentages] | |
def calculate_constrained_exponential_distribution_with_bonus( | |
num_stocks: int, | |
factor: float, | |
last_stock_cap: float, | |
min_allocation: float, | |
bonuses: Dict[int, float], | |
decimal_places: int = 2 | |
) -> List[float]: | |
""" | |
Calculate an exponential distribution of stock allocations, | |
ensuring that the last stock is capped at a given percentage, | |
all stocks meet a minimum allocation threshold, and some stocks | |
receive a bonus. | |
:param num_stocks: The total number of stocks. | |
:param factor: The exponential growth factor. | |
:param last_stock_cap: The maximum percentage allocation for the last stock (before bonuses). | |
:param min_allocation: The minimum percentage allocation for each stock. | |
:param bonuses: A dictionary where the keys are stock numbers (1-indexed) | |
and the values are the bonuses to be applied to those stocks. | |
:param decimal_places: The number of decimal places to round each stock percentage. | |
:return: A list of percentages corresponding to each stock's allocation. | |
""" | |
# Validate parameters before proceeding | |
validate_parameters(num_stocks, factor, last_stock_cap, min_allocation, bonuses) | |
# Step 1: Calculate the base allocations using the exponential factor | |
allocations = [factor ** i for i in range(num_stocks)] | |
# Step 2: Cap Stock #12 to the specified percentage | |
last_stock_allocation = allocations[-1] | |
scaling_factor = last_stock_cap / last_stock_allocation | |
scaled_allocations = [allocation * scaling_factor for allocation in allocations] | |
# Step 3: Ensure Stock #1 is at least the minimum allocation | |
total_scaled_allocation = sum(scaled_allocations) | |
stock_1_percentage = (scaled_allocations[0] / total_scaled_allocation) * 100 | |
if stock_1_percentage < min_allocation: | |
adjustment_factor = min_allocation / stock_1_percentage | |
scaled_allocations[0] *= adjustment_factor | |
# Recalculate total allocation after adjustment | |
total_adjusted_allocation = sum(scaled_allocations) | |
# Normalize to get percentages | |
percentages = [(allocation / total_adjusted_allocation) * 100 for allocation in scaled_allocations] | |
# Step 4: Apply bonuses to specific stocks | |
for stock_idx, bonus in bonuses.items(): | |
percentages[stock_idx - 1] += bonus # stock_idx is 1-indexed, convert to 0-indexed | |
# Step 5: Recalculate the total and adjust stocks below the minimum and non-bonused ones | |
total_percentage_with_bonus = sum(percentages) | |
# Find stocks that fall below the minimum allocation | |
below_minimum_stocks = [i for i, pct in enumerate(percentages) if pct < min_allocation] | |
# Ensure stocks below minimum have at least `min_allocation` | |
for i in below_minimum_stocks: | |
percentages[i] = min_allocation | |
# Recalculate total after enforcing the minimum allocation | |
total_percentage_after_min = sum(percentages) | |
# Check if we still exceed 100% | |
if total_percentage_after_min > 100: | |
# The sum is greater than 100%, scale back non-bonus, non-min stocks proportionally | |
excess_percentage = total_percentage_after_min - 100 | |
stocks_to_reduce = [i for i in range(num_stocks) | |
if i not in bonuses and percentages[i] > min_allocation] | |
# Calculate the total percentage of the stocks to reduce | |
total_reduction_pool = sum(percentages[i] for i in stocks_to_reduce) | |
# Reduce each stock's percentage proportionally | |
for i in stocks_to_reduce: | |
reduction_ratio = percentages[i] / total_reduction_pool | |
percentages[i] -= reduction_ratio * excess_percentage | |
# Step 6: Round the percentages | |
percentages = round_percentages(percentages, decimal_places) | |
# Step 7: Adjust if the sum isn't exactly 100% | |
total_percentage = sum(percentages) | |
if total_percentage != 100.0: | |
difference = 100.0 - total_percentage | |
# Adjust the last stock by the difference (could also spread the adjustment across multiple stocks) | |
percentages[-1] += difference | |
percentages[-1] = round(percentages[-1], decimal_places) | |
# Ensure the final sum is exactly 100% | |
assert round(sum(percentages), decimal_places) == 100.0, ( | |
f"Error: The total allocation does not add up to 100%. " | |
f"Input: factor={factor}, last_stock_cap={last_stock_cap}, min_allocation={min_allocation}, bonuses={bonuses}. " | |
f"Output: {percentages}. Total = {sum(percentages):.2f}%" | |
) | |
return percentages | |
def distribute_to_balance(percentages: List[float], balance: int) -> List[int]: | |
""" | |
Distribute a given balance according to percentages, ensuring no decimals in the final result. | |
:param percentages: A list of percentages for each stock. | |
:param balance: The total balance to be distributed (e.g., 10,000). | |
:return: A list of integer allocations for each stock. | |
""" | |
# Step 1: Calculate the initial integer allocations | |
raw_allocations = [balance * (pct / 100) for pct in percentages] | |
int_allocations = [int(allocation) for allocation in raw_allocations] | |
# Step 2: Track the remainders (fractional parts) to distribute later | |
remainders = [(i, raw_allocations[i] - int_allocations[i]) for i in range(len(percentages))] | |
# Step 3: Calculate the total amount allocated and the remaining balance | |
total_allocated = sum(int_allocations) | |
remaining_balance = balance - total_allocated | |
# Step 4: Distribute the remaining balance based on the highest remainders | |
# Sort remainders by the largest fractional part | |
remainders.sort(key=lambda x: x[1], reverse=True) | |
# Distribute the remaining units to stocks with the largest remainders | |
for i in range(remaining_balance): | |
stock_index = remainders[i][0] | |
int_allocations[stock_index] += 1 | |
# Ensure the final distribution adds up to the total balance | |
assert sum(int_allocations) == balance, ( | |
f"Error: The total allocation does not match the balance. " | |
f"Balance = {balance}, Allocations = {int_allocations}, Total Allocated = {sum(int_allocations)}" | |
) | |
return int_allocations |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Example usage: