diff --git a/Trading/Mode/profile_copy_trading_mode/config/ProfileCopyTradingMode.json b/Trading/Mode/profile_copy_trading_mode/config/ProfileCopyTradingMode.json index 9cd899a59..f0dfb7b7b 100644 --- a/Trading/Mode/profile_copy_trading_mode/config/ProfileCopyTradingMode.json +++ b/Trading/Mode/profile_copy_trading_mode/config/ProfileCopyTradingMode.json @@ -4,5 +4,6 @@ "rebalance_trigger_min_percent": 5, "exchange_profile_ids": [], "per_exchange_profile_portfolio_ratio": 20, + "allocation_padding_ratio": 20, "new_position_only": false } \ No newline at end of file diff --git a/Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py b/Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py index 3116c1107..8719d70be 100644 --- a/Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py +++ b/Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py @@ -47,6 +47,7 @@ def __init__(self, config, exchange_manager): super().__init__(config, exchange_manager) self.exchange_profile_ids: list[str] = [] self.per_exchange_profile_portfolio_ratio: decimal.Decimal = trading_constants.ONE + self.allocation_padding_ratio: decimal.Decimal = trading_constants.ZERO self.new_position_only: bool = False self.min_unrealized_pnl_percent: typing.Optional[decimal.Decimal] = None self.max_unrealized_pnl_percent: typing.Optional[decimal.Decimal] = None @@ -73,6 +74,14 @@ def init_user_inputs(self, inputs: dict) -> None: min_val=0, max_val=100, title="Percentage of the portfolio to allocate to each exchange profile.", ))) / trading_constants.ONE_HUNDRED + self.allocation_padding_ratio = decimal.Decimal(str(self.UI.user_input( + ProfileCopyTradingModeProducer.ALLOCATION_PADDING_RATIO, commons_enums.UserInputTypes.FLOAT, + float(self.allocation_padding_ratio * trading_constants.ONE_HUNDRED), inputs, + min_val=0, max_val=100, + title="Allocation padding: Allow trading up to X% more than the configured portfolio ratio. " + "Useful when the copied profile increases its position count. " + "E.g., 20% padding on 50% allocation allows up to 60% usage.", + ))) / trading_constants.ONE_HUNDRED self.new_position_only = self.UI.user_input( ProfileCopyTradingModeProducer.NEW_POSITION_ONLY, commons_enums.UserInputTypes.BOOLEAN, self.new_position_only, inputs, @@ -130,7 +139,7 @@ def get_supported_exchange_types(cls) -> list: trading_enums.ExchangeTypes.OPTION, ] - def get_current_state(self) -> (str, float): + def get_current_state(self) -> typing.Tuple[str, float]: return super().get_current_state()[0] if self.producers[0].state is None else self.producers[0].state.name, self.producers[0].final_eval def get_mode_producer_classes(self) -> list: @@ -200,7 +209,8 @@ def update_global_distribution(self): global_distribution = profile_distribution.update_global_distribution( self.distribution_per_exchange_profile, self.per_exchange_profile_portfolio_ratio, - self.exchange_profile_ids + self.exchange_profile_ids, + self.allocation_padding_ratio ) self.ratio_per_asset = global_distribution[profile_distribution.RATIO_PER_ASSET] self.total_ratio_per_asset = global_distribution[profile_distribution.TOTAL_RATIO_PER_ASSET] @@ -215,6 +225,7 @@ class ProfileCopyTradingModeConsumer(index_trading_mode.IndexTradingModeConsumer class ProfileCopyTradingModeProducer(index_trading_mode.IndexTradingModeProducer): EXCHANGE_PROFILE_IDS = "exchange_profile_ids" PER_PROFILE_PORTFOLIO_RATIO = "per_exchange_profile_portfolio_ratio" + ALLOCATION_PADDING_RATIO = "allocation_padding_ratio" NEW_POSITION_ONLY = "new_position_only" MIN_UNREALIZED_PNL_PERCENT = "min_unrealized_pnl_percent" MAX_UNREALIZED_PNL_PERCENT = "max_unrealized_pnl_percent" diff --git a/Trading/Mode/profile_copy_trading_mode/profile_distribution.py b/Trading/Mode/profile_copy_trading_mode/profile_distribution.py index cb63a9b4b..f88e9ceb8 100644 --- a/Trading/Mode/profile_copy_trading_mode/profile_distribution.py +++ b/Trading/Mode/profile_copy_trading_mode/profile_distribution.py @@ -16,6 +16,7 @@ import decimal import typing import datetime +import enum import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import octobot_trading.enums as trading_enums @@ -24,11 +25,20 @@ if typing.TYPE_CHECKING: import tentacles.Services.Services_feeds.exchange_service_feed as exchange_service_feed + +class DistributionSource(enum.Enum): + POSITIONS = "positions" + PORTFOLIO = "portfolio" + + RATIO_PER_ASSET = "ratio_per_asset" TOTAL_RATIO_PER_ASSET = "total_ratio_per_asset" INDEXED_COINS = "indexed_coins" INDEXED_COINS_PRICES = "indexed_coins_prices" REFERENCE_MARKET_RATIO = "reference_market_ratio" +TRADABLE_RATIO = "tradable_ratio" +DISTRIBUTION_KEY = "distribution" +DISTRIBUTION_SOURCE = "distribution_source" def get_positions_to_consider( profile_positions: list[dict], @@ -83,27 +93,64 @@ def get_smoothed_distribution_from_profile_data( max_unrealized_pnl_percent: typing.Optional[float] = None, min_mark_price: typing.Optional[decimal.Decimal] = None, max_mark_price: typing.Optional[decimal.Decimal] = None -) -> typing.List: - profile_positions: list[dict] = get_positions_to_consider( +) -> typing.Tuple[typing.List, decimal.Decimal, str]: + # If profile has positions, use position-based distribution + if profile_data.positions: + return _get_distribution_from_positions( + profile_data, new_position_only, started_at, + min_unrealized_pnl_percent, max_unrealized_pnl_percent, + min_mark_price, max_mark_price + ) + + # If profile has portfolio but no positions, use portfolio-based distribution + if profile_data.portfolio is not None: + return _get_distribution_from_portfolio(profile_data.portfolio) + + return [], trading_constants.ZERO, DistributionSource.POSITIONS.value + + +def _get_distribution_from_positions( + profile_data: "exchange_service_feed.ExchangeProfile", + new_position_only: bool, + started_at: datetime.datetime, + min_unrealized_pnl_percent: typing.Optional[float] = None, + max_unrealized_pnl_percent: typing.Optional[float] = None, + min_mark_price: typing.Optional[decimal.Decimal] = None, + max_mark_price: typing.Optional[decimal.Decimal] = None +) -> typing.Tuple[typing.List, decimal.Decimal, str]: + # Calculate total_initial_margin from ALL positions (before filtering) + total_initial_margin = decimal.Decimal(sum( + decimal.Decimal(str(position.get( + trading_enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value, + 0 + ) or 0)) + for position in profile_data.positions + )) + + if total_initial_margin <= decimal.Decimal(0): + return [], trading_constants.ZERO, DistributionSource.POSITIONS.value + + tradable_positions: list[dict] = get_positions_to_consider( profile_data.positions, new_position_only, started_at, min_unrealized_pnl_percent, max_unrealized_pnl_percent, min_mark_price, max_mark_price ) - if not profile_positions: - return [] + if not tradable_positions: + return [], trading_constants.ZERO, DistributionSource.POSITIONS.value - total_initial_margin = decimal.Decimal(sum( + tradable_initial_margin = decimal.Decimal(sum( decimal.Decimal(str(position.get( trading_enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value, 0 ) or 0)) - for position in profile_positions + for position in tradable_positions )) - # Calculate weight for each position based on its initial margin percentage + tradable_ratio = tradable_initial_margin / total_initial_margin + # Sum initial margins per symbol in case multiple positions exist for the same symbol initial_margin_by_coin = {} price_by_coin = {} - for position in profile_positions: + for position in tradable_positions: symbol = position[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] initial_margin = decimal.Decimal(str(position.get( trading_enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value, @@ -119,36 +166,62 @@ def get_smoothed_distribution_from_profile_data( initial_margin_by_coin[symbol] = initial_margin weight_by_coin = {} - if total_initial_margin > decimal.Decimal(0): - for symbol, initial_margin in initial_margin_by_coin.items(): - weight_by_coin[symbol] = initial_margin / total_initial_margin - else: - # If no initial margin, fall back to uniform distribution - for symbol in initial_margin_by_coin.keys(): - weight_by_coin[symbol] = decimal.Decimal(1) - return index_distribution.get_smoothed_distribution(weight_by_coin, price_by_coin) + for symbol, initial_margin in initial_margin_by_coin.items(): + weight_by_coin[symbol] = initial_margin / tradable_initial_margin + + return index_distribution.get_smoothed_distribution(weight_by_coin, price_by_coin), tradable_ratio, DistributionSource.POSITIONS.value + + +def _get_distribution_from_portfolio( + portfolio +) -> typing.Tuple[typing.List, decimal.Decimal, str]: + if not portfolio.portfolio: + return [], trading_constants.ZERO, DistributionSource.PORTFOLIO.value + + total_value = trading_constants.ZERO + value_by_asset = {} + + for currency, asset in portfolio.portfolio.items(): + total_amount = asset.total + if total_amount > trading_constants.ZERO: + value_by_asset[currency] = total_amount + total_value += total_amount + + if total_value <= trading_constants.ZERO: + return [], trading_constants.ZERO, DistributionSource.PORTFOLIO.value + + weight_by_coin = {} + for currency, value in value_by_asset.items(): + weight_by_coin[currency] = value / total_value + + price_by_coin = {} + return index_distribution.get_smoothed_distribution(weight_by_coin, price_by_coin), trading_constants.ONE, DistributionSource.PORTFOLIO.value def update_distribution_based_on_profile_data( profile_data: "exchange_service_feed.ExchangeProfile", - distribution_per_exchange_profile: dict[str, list], + distribution_per_exchange_profile: dict[str, dict], new_position_only: bool, started_at: datetime.datetime, min_unrealized_pnl_percent: typing.Optional[float] = None, max_unrealized_pnl_percent: typing.Optional[float] = None, min_mark_price: typing.Optional[decimal.Decimal] = None, max_mark_price: typing.Optional[decimal.Decimal] = None -) -> dict[str, list]: - distribution = get_smoothed_distribution_from_profile_data( +) -> dict[str, dict]: + distribution, tradable_ratio, source = get_smoothed_distribution_from_profile_data( profile_data, new_position_only, started_at, min_unrealized_pnl_percent, max_unrealized_pnl_percent, min_mark_price, max_mark_price ) - distribution_per_exchange_profile[profile_data.profile_id] = distribution + distribution_per_exchange_profile[profile_data.profile_id] = { + DISTRIBUTION_KEY: distribution, + TRADABLE_RATIO: tradable_ratio, + DISTRIBUTION_SOURCE: source, + } return distribution_per_exchange_profile def has_distribution_for_all_exchange_profiles( - distribution_per_exchange_profile: dict[str, list], + distribution_per_exchange_profile: dict[str, dict], exchange_profile_ids: list[str] ) -> bool: return all( @@ -158,15 +231,26 @@ def has_distribution_for_all_exchange_profiles( def update_global_distribution( - distribution_per_exchange_profile: dict[str, list], + distribution_per_exchange_profile: dict[str, dict], per_exchange_profile_portfolio_ratio: decimal.Decimal, - exchange_profile_ids: list[str] + exchange_profile_ids: list[str], + allocation_padding_ratio: decimal.Decimal = trading_constants.ZERO ) -> dict: merged_ratio_per_asset = {} price_weighted_sum_per_asset = {} distribution_value_sum_per_asset = {} + total_effective_allocation = trading_constants.ZERO + max_profile_allocation = per_exchange_profile_portfolio_ratio * (trading_constants.ONE + allocation_padding_ratio) - for distribution in distribution_per_exchange_profile.values(): + for profile_data in distribution_per_exchange_profile.values(): + distribution = profile_data.get(DISTRIBUTION_KEY, []) + tradable_ratio = profile_data.get(TRADABLE_RATIO, trading_constants.ONE) + effective_profile_ratio = min( + per_exchange_profile_portfolio_ratio * tradable_ratio, + max_profile_allocation + ) + total_effective_allocation += effective_profile_ratio + ratio_per_asset = { asset[index_distribution.DISTRIBUTION_NAME]: asset for asset in distribution @@ -174,7 +258,7 @@ def update_global_distribution( for asset_name, asset_dict in ratio_per_asset.items(): distribution_value = decimal.Decimal(str(asset_dict[index_distribution.DISTRIBUTION_VALUE])) - weighted_value = distribution_value * per_exchange_profile_portfolio_ratio + weighted_value = distribution_value * effective_profile_ratio distribution_price = asset_dict.get(index_distribution.DISTRIBUTION_PRICE) if asset_name in merged_ratio_per_asset: @@ -195,7 +279,6 @@ def update_global_distribution( price_weighted_sum_per_asset[asset_name] = real_price * distribution_value distribution_value_sum_per_asset[asset_name] = distribution_value - # Compute weighted average prices merged_price_per_asset = {} for asset_name in price_weighted_sum_per_asset: if distribution_value_sum_per_asset[asset_name] > decimal.Decimal(0): @@ -210,11 +293,10 @@ def update_global_distribution( asset[index_distribution.DISTRIBUTION_NAME] for asset in ratio_per_asset.values() ] - - total_allocation = per_exchange_profile_portfolio_ratio * decimal.Decimal(len(exchange_profile_ids)) + reference_market_ratio = max( trading_constants.ZERO, - min(trading_constants.ONE, trading_constants.ONE - total_allocation) + min(trading_constants.ONE, trading_constants.ONE - total_effective_allocation) ) return { diff --git a/Trading/Mode/profile_copy_trading_mode/resources/ProfileCopyTradingMode.md b/Trading/Mode/profile_copy_trading_mode/resources/ProfileCopyTradingMode.md index 24cb19c08..7987b066a 100644 --- a/Trading/Mode/profile_copy_trading_mode/resources/ProfileCopyTradingMode.md +++ b/Trading/Mode/profile_copy_trading_mode/resources/ProfileCopyTradingMode.md @@ -39,11 +39,11 @@ The percentage of your total portfolio value to allocate to copying each exchang - 3 profiles × 30% each = 90% → **Valid** - 2 profiles × 50% each = 100% → **Valid** -#### Allocation Padding Ratio +#### Allocation Padding The percentage padding to allow on top of the configured portfolio allocation per profile. This allows the trading mode to use more of your portfolio than initially configured when the copied profile opens additional positions. -**Example**: If you set *Per Exchange Profile Portfolio Ratio* to 50% and *Allocation Padding Ratio* to 20%, the effective maximum allocation for that profile can grow up to 60% (50% × 1.2). This is useful when the copied profile increases its number of traded positions over time. +**Example**: If you set *Per Exchange Profile Portfolio Ratio* to 50% and *Allocation Padding* to 20%, the effective maximum allocation for that profile can grow up to 60% (50% × 1.2). This is useful when the copied profile increases its number of traded positions over time. **Use cases**: - Set to `0%` for strict allocation limits (recommended for conservative strategies) diff --git a/Trading/Mode/profile_copy_trading_mode/tests/test_profile_distribution.py b/Trading/Mode/profile_copy_trading_mode/tests/test_profile_distribution.py index e1d53775d..18f887d94 100644 --- a/Trading/Mode/profile_copy_trading_mode/tests/test_profile_distribution.py +++ b/Trading/Mode/profile_copy_trading_mode/tests/test_profile_distribution.py @@ -6,38 +6,114 @@ import tentacles.Trading.Mode.profile_copy_trading_mode.profile_distribution as profile_distribution import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution import octobot_trading.enums as trading_enums +import octobot_trading.constants as trading_constants if typing.TYPE_CHECKING: import tentacles.Services.Services_feeds.exchange_service_feed as exchange_service_feed class MockProfileData: - def __init__(self, profile_id: str, positions: list): + def __init__(self, profile_id: str, positions: list = None, portfolio=None): self.profile_id: str = profile_id - self.positions: list[dict] = positions + self.positions: list[dict] = positions if positions is not None else [] + self.portfolio = portfolio + + +class MockAsset: + def __init__(self, total: decimal.Decimal): + self.total = total + + +class MockPortfolio: + def __init__(self, assets: dict[str, decimal.Decimal]): + self.portfolio = { + currency: MockAsset(total) for currency, total in assets.items() + } + + +class PortfolioTestCase(typing.NamedTuple): + name: str + positions: typing.List[typing.Dict] + portfolio: typing.Optional[typing.Dict[str, decimal.Decimal]] + expected_source: str + expected_tradable_ratio: decimal.Decimal + expected_symbols: typing.List[str] + excluded_symbols: typing.List[str] + weight_assertion: typing.Optional[str] + + +class TimestampTestCase(typing.NamedTuple): + timestamp_offsets_and_symbols: typing.List[typing.Tuple[int, str]] + expected_symbols: typing.List[str] + + +class UnrealizedPnlTestCase(typing.NamedTuple): + min_unrealized_pnl_percent: typing.Optional[float] + max_unrealized_pnl_percent: typing.Optional[float] + positions: typing.List[typing.Dict] + expected_symbols: typing.List[str] + + +class MarkPriceTestCase(typing.NamedTuple): + min_mark_price: typing.Optional[decimal.Decimal] + max_mark_price: typing.Optional[decimal.Decimal] + positions: typing.List[typing.Dict] + expected_symbols: typing.List[str] + + +class TradableRatioTestCase(typing.NamedTuple): + margins: typing.List[float] + filtered_count: int + expected_tradable_ratio: decimal.Decimal + + +class NewPositionOnlyTestCase(typing.NamedTuple): + new_position_only: bool + expected_btc_present: bool + expected_eth_present: bool + btc_higher_than_eth: typing.Optional[bool] + + +def _make_distribution(assets: list[tuple]) -> list[dict]: + """Helper to create distribution list from (name, value, price) tuples.""" + return [ + { + index_distribution.DISTRIBUTION_NAME: name, + index_distribution.DISTRIBUTION_VALUE: value, + index_distribution.DISTRIBUTION_PRICE: price, + } + for name, value, price in assets + ] + + +def _make_profile_dist(distribution: list[dict], tradable_ratio: decimal.Decimal = trading_constants.ONE, source: str = profile_distribution.DistributionSource.POSITIONS.value) -> dict: + """Helper to create profile distribution dict.""" + return { + profile_distribution.DISTRIBUTION_KEY: distribution, + profile_distribution.TRADABLE_RATIO: tradable_ratio, + profile_distribution.DISTRIBUTION_SOURCE: source, + } def test_update_global_distribution_merges_overlapping_assets(): distribution_per_exchange_profile = { - "profile1": [ - {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 50.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000")}, - {index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 30.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("3000")}, - ], - "profile2": [ - {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 40.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("51000")}, - {index_distribution.DISTRIBUTION_NAME: "SOL", index_distribution.DISTRIBUTION_VALUE: 60.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100")}, - ], + "profile1": _make_profile_dist(_make_distribution([ + ("BTC", 50.0, decimal.Decimal("50000")), + ("ETH", 30.0, decimal.Decimal("3000")), + ])), + "profile2": _make_profile_dist(_make_distribution([ + ("BTC", 40.0, decimal.Decimal("51000")), + ("SOL", 60.0, decimal.Decimal("100")), + ])), } - per_exchange_profile_portfolio_ratio = decimal.Decimal("0.5") - exchange_profile_ids = ["profile1", "profile2"] result = profile_distribution.update_global_distribution( distribution_per_exchange_profile, - per_exchange_profile_portfolio_ratio, - exchange_profile_ids + decimal.Decimal("0.5"), + ["profile1", "profile2"] ) - # BTC should be merged: (50.0 * 0.5) + (40.0 * 0.5) = 45.0 + # BTC: (50.0 * 0.5) + (40.0 * 0.5) = 45.0, ETH: 30.0 * 0.5 = 15.0, SOL: 60.0 * 0.5 = 30.0 assert result[profile_distribution.RATIO_PER_ASSET]["BTC"][index_distribution.DISTRIBUTION_VALUE] == decimal.Decimal("45.0") # ETH should be weighted: 30.0 * 0.5 = 15.0 assert result[profile_distribution.RATIO_PER_ASSET]["ETH"][index_distribution.DISTRIBUTION_VALUE] == decimal.Decimal("15.0") @@ -45,58 +121,36 @@ def test_update_global_distribution_merges_overlapping_assets(): assert result[profile_distribution.RATIO_PER_ASSET]["SOL"][index_distribution.DISTRIBUTION_VALUE] == decimal.Decimal("30.0") # Total should be 45.0 + 15.0 + 30.0 = 90.0 assert result[profile_distribution.TOTAL_RATIO_PER_ASSET] == decimal.Decimal("90.0") - assert set(result[profile_distribution.INDEXED_COINS]) == {"BTC", "ETH", "SOL"} - # BTC price should be weighted average based on distribution values: - # (50000 * 50.0 + 51000 * 40.0) / (50.0 + 40.0) = 50444.444... - expected_btc_price = (decimal.Decimal("50000") * decimal.Decimal("50.0") + decimal.Decimal("51000") * decimal.Decimal("40.0")) / decimal.Decimal("90.0") - assert expected_btc_price >= decimal.Decimal("50444") and expected_btc_price <= decimal.Decimal("50445") - assert result[profile_distribution.INDEXED_COINS_PRICES]["BTC"] == expected_btc_price - # ETH price should be real price (only in one profile): 3000 - assert result[profile_distribution.INDEXED_COINS_PRICES]["ETH"] == decimal.Decimal("3000") - # SOL price should be real price (only in one profile): 100 - assert result[profile_distribution.INDEXED_COINS_PRICES]["SOL"] == decimal.Decimal("100") -def test_update_global_distribution_reference_market_ratio_calculation(): - distribution_per_exchange_profile = { - "profile1": [ - {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 100.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000")}, - ], - } +@pytest.mark.parametrize("allocation,profiles_count,tradable_ratios,expected_ref_market", [ + # Single profile, 50% allocation = 50% reserve + (decimal.Decimal("0.5"), 1, [decimal.Decimal("1")], decimal.Decimal("0.5")), + # Single profile, 100% allocation = 0% reserve + (decimal.Decimal("1.0"), 1, [decimal.Decimal("1")], decimal.Decimal("0")), + # Two profiles, 30% each = 40% reserve + (decimal.Decimal("0.3"), 2, [decimal.Decimal("1"), decimal.Decimal("1")], decimal.Decimal("0.4")), + # Over-allocation capped at 0% reserve + (decimal.Decimal("0.6"), 2, [decimal.Decimal("1"), decimal.Decimal("1")], decimal.Decimal("0")), + # Partial tradable ratio: 50% allocation * 50% tradable = 25% effective, 75% reserve + (decimal.Decimal("0.5"), 1, [decimal.Decimal("0.5")], decimal.Decimal("0.75")), +]) +def test_update_global_distribution_reference_market_ratio(allocation, profiles_count, tradable_ratios, expected_ref_market): + """Test reference market ratio calculation with various allocation scenarios.""" + distribution_per_exchange_profile = {} + profile_ids = [] + for i in range(profiles_count): + profile_id = f"profile{i+1}" + profile_ids.append(profile_id) + distribution_per_exchange_profile[profile_id] = _make_profile_dist( + _make_distribution([(f"ASSET{i}", 100.0, decimal.Decimal("1000"))]), + tradable_ratio=tradable_ratios[i] + ) - # Test case: 50% allocation per profile, 1 profile = 50% total, 50% reference market result = profile_distribution.update_global_distribution( - distribution_per_exchange_profile, - decimal.Decimal("0.5"), - ["profile1"] + distribution_per_exchange_profile, allocation, profile_ids ) - assert result[profile_distribution.REFERENCE_MARKET_RATIO] == decimal.Decimal("0.5") - assert result[profile_distribution.INDEXED_COINS_PRICES]["BTC"] == decimal.Decimal("50000") - - # Test case: 100% allocation per profile, 1 profile = 100% total, 0% reference market - result = profile_distribution.update_global_distribution( - distribution_per_exchange_profile, - decimal.Decimal("1.0"), - ["profile1"] - ) - assert result[profile_distribution.REFERENCE_MARKET_RATIO] == decimal.Decimal("0") - assert result[profile_distribution.INDEXED_COINS_PRICES]["BTC"] == decimal.Decimal("50000") - - # Test case: 30% allocation per profile, 2 profiles = 60% total, 40% reference market - result = profile_distribution.update_global_distribution( - distribution_per_exchange_profile, - decimal.Decimal("0.3"), - ["profile1", "profile2"] - ) - assert result[profile_distribution.REFERENCE_MARKET_RATIO] == decimal.Decimal("0.4") - - # Test case: Over-allocation (should cap at 0) - result = profile_distribution.update_global_distribution( - distribution_per_exchange_profile, - decimal.Decimal("0.6"), - ["profile1", "profile2"] - ) - assert result[profile_distribution.REFERENCE_MARKET_RATIO] == decimal.Decimal("0") + assert result[profile_distribution.REFERENCE_MARKET_RATIO] == expected_ref_market def test_get_smoothed_distribution_from_profile_data_aggregates_same_symbols(): @@ -119,10 +173,14 @@ def test_get_smoothed_distribution_from_profile_data_aggregates_same_symbols(): ]) started_at = datetime.datetime.now() - result = profile_distribution.get_smoothed_distribution_from_profile_data( + result, tradable_ratio, source = profile_distribution.get_smoothed_distribution_from_profile_data( profile_data, new_position_only=False, started_at=started_at ) + # All positions are tradable (no filters), so tradable_ratio should be 1.0 + assert tradable_ratio == decimal.Decimal("1") + assert source == profile_distribution.DistributionSource.POSITIONS.value + # BTC should have aggregated margin: 100 + 50 = 150 out of 200 total (75%) # ETH should have 50 out of 200 total (25%) btc_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "BTC/USDT"), None) @@ -139,7 +197,7 @@ def test_get_smoothed_distribution_from_profile_data_aggregates_same_symbols(): # without prices profile_data.positions[-1].pop(trading_enums.ExchangeConstantsPositionColumns.ENTRY_PRICE.value) - result = profile_distribution.get_smoothed_distribution_from_profile_data( + result, tradable_ratio, source = profile_distribution.get_smoothed_distribution_from_profile_data( profile_data, new_position_only=False, started_at=started_at ) @@ -156,12 +214,12 @@ def test_get_smoothed_distribution_from_profile_data_aggregates_same_symbols(): def test_update_global_distribution_merges_identical_assets_from_multiple_profiles(): distribution_per_exchange_profile = { - "profile1": [ - {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 100.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000")}, - ], - "profile2": [ - {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 100.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("51000")}, - ], + "profile1": _make_profile_dist(_make_distribution([ + ("BTC", 100.0, decimal.Decimal("50000")), + ])), + "profile2": _make_profile_dist(_make_distribution([ + ("BTC", 100.0, decimal.Decimal("51000")), + ])), } # Both profiles have same asset with same value, each gets 40% portfolio allocation @@ -174,30 +232,24 @@ def test_update_global_distribution_merges_identical_assets_from_multiple_profil # Both profiles contribute 100.0 * 0.4 = 40.0, merged = 80.0 assert result[profile_distribution.RATIO_PER_ASSET]["BTC"][index_distribution.DISTRIBUTION_VALUE] == decimal.Decimal("80.0") assert result[profile_distribution.TOTAL_RATIO_PER_ASSET] == decimal.Decimal("80.0") - # BTC price should be weighted average based on distribution values: - # (50000 * 100.0 + 51000 * 100.0) / (100.0 + 100.0) = 50500 - expected_btc_price = (decimal.Decimal("50000") * decimal.Decimal("100.0") + decimal.Decimal("51000") * decimal.Decimal("100.0")) / decimal.Decimal("200.0") - assert result[profile_distribution.INDEXED_COINS_PRICES]["BTC"] == expected_btc_price def test_update_global_distribution_handles_missing_prices(): distribution_per_exchange_profile = { - "profile1": [ + "profile1": _make_profile_dist([ {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 50.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("50000")}, {index_distribution.DISTRIBUTION_NAME: "ETH", index_distribution.DISTRIBUTION_VALUE: 30.0}, # No price - ], - "profile2": [ + ]), + "profile2": _make_profile_dist([ {index_distribution.DISTRIBUTION_NAME: "BTC", index_distribution.DISTRIBUTION_VALUE: 40.0}, # No price {index_distribution.DISTRIBUTION_NAME: "SOL", index_distribution.DISTRIBUTION_VALUE: 60.0, index_distribution.DISTRIBUTION_PRICE: decimal.Decimal("100")}, - ], + ]), } - per_exchange_profile_portfolio_ratio = decimal.Decimal("0.5") - exchange_profile_ids = ["profile1", "profile2"] result = profile_distribution.update_global_distribution( distribution_per_exchange_profile, - per_exchange_profile_portfolio_ratio, - exchange_profile_ids + decimal.Decimal("0.5"), + ["profile1", "profile2"] ) assert result[profile_distribution.INDEXED_COINS_PRICES]["BTC"] == decimal.Decimal("50000") @@ -206,36 +258,33 @@ def test_update_global_distribution_handles_missing_prices(): assert result[profile_distribution.INDEXED_COINS_PRICES]["SOL"] == decimal.Decimal("100") @pytest.mark.parametrize( - "timestamp_offsets_and_symbols,expected_symbols", + "test_case", [ - # Filter positions: only those after started_at - ( - [ + TimestampTestCase( + timestamp_offsets_and_symbols=[ (-3600, "BTC/USDT"), # 1 hour before - excluded (3600, "ETH/USDT"), # 1 hour after - included (7200, "SOL/USDT"), # 2 hours after - included ], - ["ETH/USDT", "SOL/USDT"], + expected_symbols=["ETH/USDT", "SOL/USDT"], ), - # No matching positions: all before started_at - ( - [ + TimestampTestCase( + timestamp_offsets_and_symbols=[ (-3600, "BTC/USDT"), # 1 hour before - excluded (-1800, "ETH/USDT"), # 30 minutes before - excluded ], - [], + expected_symbols=[], ), - # Edge case: position exactly at started_at (should be excluded, as filter uses >) - ( - [ + TimestampTestCase( + timestamp_offsets_and_symbols=[ (0, "BTC/USDT"), # Exactly at - excluded (1, "ETH/USDT"), # 1 second after - included ], - ["ETH/USDT"], + expected_symbols=["ETH/USDT"], ), ], ) -def test_get_positions_to_consider_filters_by_timestamp_when_new_position_only_true(timestamp_offsets_and_symbols, expected_symbols): +def test_get_positions_to_consider_filters_by_timestamp_when_new_position_only_true(test_case: TimestampTestCase): """Test that positions are filtered by timestamp when new_position_only is True""" started_at = datetime.datetime(2024, 1, 1, 12, 0, 0) profile_positions = [ @@ -243,7 +292,7 @@ def test_get_positions_to_consider_filters_by_timestamp_when_new_position_only_t trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value: symbol, trading_enums.ExchangeConstantsPositionColumns.TIMESTAMP.value: started_at.timestamp() + offset_seconds, } - for offset_seconds, symbol in timestamp_offsets_and_symbols + for offset_seconds, symbol in test_case.timestamp_offsets_and_symbols ] result = profile_distribution.get_positions_to_consider( @@ -253,18 +302,28 @@ def test_get_positions_to_consider_filters_by_timestamp_when_new_position_only_t result_symbols = [ pos[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] for pos in result ] - assert result_symbols == expected_symbols + assert result_symbols == test_case.expected_symbols @pytest.mark.parametrize( - "new_position_only,expected_btc_present,expected_eth_present,btc_higher_than_eth", + "test_case", [ - (True, False, True, None), # Only new positions (ETH) included - (False, True, True, True), # All positions included, BTC has higher margin + NewPositionOnlyTestCase( + new_position_only=True, + expected_btc_present=False, + expected_eth_present=True, + btc_higher_than_eth=None, + ), # Only new positions (ETH) included + NewPositionOnlyTestCase( + new_position_only=False, + expected_btc_present=True, + expected_eth_present=True, + btc_higher_than_eth=True, + ), # All positions included, BTC has higher margin ], ) def test_get_smoothed_distribution_from_profile_data_respects_new_position_only( - new_position_only, expected_btc_present, expected_eth_present, btc_higher_than_eth + test_case: NewPositionOnlyTestCase ): """Test that get_smoothed_distribution_from_profile_data respects new_position_only parameter""" started_at = datetime.datetime(2024, 1, 1, 12, 0, 0) @@ -283,21 +342,21 @@ def test_get_smoothed_distribution_from_profile_data_respects_new_position_only( }, ]) - result = profile_distribution.get_smoothed_distribution_from_profile_data( - profile_data, new_position_only=new_position_only, started_at=started_at + result, tradable_ratio, source = profile_distribution.get_smoothed_distribution_from_profile_data( + profile_data, new_position_only=test_case.new_position_only, started_at=started_at ) btc_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "BTC/USDT"), None) eth_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "ETH/USDT"), None) - assert (btc_dist is not None) == expected_btc_present - assert (eth_dist is not None) == expected_eth_present + assert (btc_dist is not None) == test_case.expected_btc_present + assert (eth_dist is not None) == test_case.expected_eth_present - if expected_eth_present: + if test_case.expected_eth_present: assert eth_dist[index_distribution.DISTRIBUTION_VALUE] > decimal.Decimal("0") assert eth_dist[index_distribution.DISTRIBUTION_PRICE] == decimal.Decimal("3000.0") - if btc_higher_than_eth and btc_dist is not None and eth_dist is not None: + if test_case.btc_higher_than_eth and btc_dist is not None and eth_dist is not None: assert btc_dist[index_distribution.DISTRIBUTION_VALUE] > eth_dist[index_distribution.DISTRIBUTION_VALUE] @@ -333,7 +392,8 @@ def test_update_distribution_based_on_profile_data_respects_new_position_only( ) assert "profile1" in result - distribution = result["profile1"] + profile_result = result["profile1"] + distribution = profile_result["distribution"] btc_dist = next((d for d in distribution if d[index_distribution.DISTRIBUTION_NAME] == "BTC/USDT"), None) eth_dist = next((d for d in distribution if d[index_distribution.DISTRIBUTION_NAME] == "ETH/USDT"), None) @@ -354,91 +414,300 @@ def _position(symbol: str, collateral: float, unrealized_pnl: float, initial_mar @pytest.mark.parametrize( - "min_unrealized_pnl_percent,max_unrealized_pnl_percent,positions,expected_symbols", + "test_case", [ - (None, None, [_position("A", 100.0, 5.0), _position("B", 100.0, 15.0)], ["A", "B"]), - (0.1, None, [_position("A", 100.0, 10.0), _position("B", 100.0, 5.0)], ["A"]), - (0.1, None, [_position("A", 0.0, 5.0)], ["A"]), - (None, 0.1, [_position("A", 100.0, 5.0), _position("B", 100.0, 10.0), _position("C", 100.0, 15.0)], ["A", "B"]), - (0.05, 0.15, [_position("A", 100.0, 5.0), _position("B", 100.0, 10.0), _position("C", 100.0, 15.0), _position("D", 100.0, 20.0)], ["A", "B", "C"]), + UnrealizedPnlTestCase( + min_unrealized_pnl_percent=None, + max_unrealized_pnl_percent=None, + positions=[_position("A", 100.0, 5.0), _position("B", 100.0, 15.0)], + expected_symbols=["A", "B"], + ), + UnrealizedPnlTestCase( + min_unrealized_pnl_percent=0.1, + max_unrealized_pnl_percent=None, + positions=[_position("A", 100.0, 10.0), _position("B", 100.0, 5.0)], + expected_symbols=["A"], + ), + UnrealizedPnlTestCase( + min_unrealized_pnl_percent=0.1, + max_unrealized_pnl_percent=None, + positions=[_position("A", 0.0, 5.0)], + expected_symbols=["A"], + ), + UnrealizedPnlTestCase( + min_unrealized_pnl_percent=None, + max_unrealized_pnl_percent=0.1, + positions=[_position("A", 100.0, 5.0), _position("B", 100.0, 10.0), _position("C", 100.0, 15.0)], + expected_symbols=["A", "B"], + ), + UnrealizedPnlTestCase( + min_unrealized_pnl_percent=0.05, + max_unrealized_pnl_percent=0.15, + positions=[_position("A", 100.0, 5.0), _position("B", 100.0, 10.0), _position("C", 100.0, 15.0), _position("D", 100.0, 20.0)], + expected_symbols=["A", "B", "C"], + ), ], ) -def test_get_positions_to_consider_min_max_unrealized_pnl_ratio(min_unrealized_pnl_percent, max_unrealized_pnl_percent, positions, expected_symbols): +def test_get_positions_to_consider_min_max_unrealized_pnl_ratio(test_case: UnrealizedPnlTestCase): started_at = datetime.datetime(2024, 1, 1, 12, 0, 0) result = profile_distribution.get_positions_to_consider( - positions, new_position_only=False, started_at=started_at, - min_unrealized_pnl_percent=min_unrealized_pnl_percent, - max_unrealized_pnl_percent=max_unrealized_pnl_percent, + test_case.positions, new_position_only=False, started_at=started_at, + min_unrealized_pnl_percent=test_case.min_unrealized_pnl_percent, + max_unrealized_pnl_percent=test_case.max_unrealized_pnl_percent, ) - assert [p[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] for p in result] == expected_symbols + assert [p[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] for p in result] == test_case.expected_symbols def test_get_smoothed_distribution_from_profile_data_respects_min_unrealized_pnl_ratio(): - """With min=0.1, only the 10% position is in the distribution; the 5% one is excluded.""" profile_data = MockProfileData("p1", [ _position("BTC/USDT", 100.0, 10.0), _position("ETH/USDT", 100.0, 5.0), ]) started_at = datetime.datetime(2024, 1, 1, 12, 0, 0) - result = profile_distribution.get_smoothed_distribution_from_profile_data( + result, tradable_ratio, source = profile_distribution.get_smoothed_distribution_from_profile_data( profile_data, new_position_only=False, started_at=started_at, min_unrealized_pnl_percent=0.1, ) symbols = [d[index_distribution.DISTRIBUTION_NAME] for d in result] assert "BTC/USDT" in symbols assert "ETH/USDT" not in symbols + assert tradable_ratio == decimal.Decimal("0.5") -def test_get_smoothed_distribution_from_profile_data_respects_max_unrealized_pnl_ratio(): - """With max=0.1, only 5% and 10% are in; 15% is excluded.""" - profile_data = MockProfileData("p1", [ - _position("BTC/USDT", 100.0, 5.0), - _position("ETH/USDT", 100.0, 10.0), - _position("SOL/USDT", 100.0, 15.0), - ]) +@pytest.mark.parametrize( + "test_case", + [ + MarkPriceTestCase( + min_mark_price=None, + max_mark_price=None, + positions=[_position("A", 100.0, 5.0, mark_price=100.0), _position("B", 100.0, 5.0, mark_price=200.0)], + expected_symbols=["A", "B"], + ), + MarkPriceTestCase( + min_mark_price=decimal.Decimal("150"), + max_mark_price=None, + positions=[_position("A", 100.0, 5.0, mark_price=100.0), _position("B", 100.0, 5.0, mark_price=200.0)], + expected_symbols=["B"], + ), + MarkPriceTestCase( + min_mark_price=None, + max_mark_price=decimal.Decimal("150"), + positions=[_position("A", 100.0, 5.0, mark_price=100.0), _position("B", 100.0, 5.0, mark_price=200.0)], + expected_symbols=["A"], + ), + MarkPriceTestCase( + min_mark_price=decimal.Decimal("150"), + max_mark_price=decimal.Decimal("250"), + positions=[_position("A", 100.0, 5.0, mark_price=100.0), _position("B", 100.0, 5.0, mark_price=200.0), _position("C", 100.0, 5.0, mark_price=300.0)], + expected_symbols=["B"], + ), + ], +) +def test_get_positions_to_consider_min_max_mark_price(test_case: MarkPriceTestCase): started_at = datetime.datetime(2024, 1, 1, 12, 0, 0) - result = profile_distribution.get_smoothed_distribution_from_profile_data( - profile_data, new_position_only=False, started_at=started_at, - max_unrealized_pnl_percent=0.1, + result = profile_distribution.get_positions_to_consider( + test_case.positions, new_position_only=False, started_at=started_at, + min_mark_price=test_case.min_mark_price, max_mark_price=test_case.max_mark_price, ) - symbols = [d[index_distribution.DISTRIBUTION_NAME] for d in result] - assert "BTC/USDT" in symbols - assert "ETH/USDT" in symbols - assert "SOL/USDT" not in symbols + assert [p[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] for p in result] == test_case.expected_symbols @pytest.mark.parametrize( - "min_mark_price,max_mark_price,positions,expected_symbols", + "test_case", [ - (None, None, [_position("A", 100.0, 5.0, mark_price=100.0), _position("B", 100.0, 5.0, mark_price=200.0)], ["A", "B"]), - (decimal.Decimal("150"), None, [_position("A", 100.0, 5.0, mark_price=100.0), _position("B", 100.0, 5.0, mark_price=200.0)], ["B"]), - (None, decimal.Decimal("150"), [_position("A", 100.0, 5.0, mark_price=100.0), _position("B", 100.0, 5.0, mark_price=200.0)], ["A"]), - (decimal.Decimal("150"), decimal.Decimal("250"), [_position("A", 100.0, 5.0, mark_price=100.0), _position("B", 100.0, 5.0, mark_price=200.0), _position("C", 100.0, 5.0, mark_price=300.0)], ["B"]), + TradableRatioTestCase( + margins=[100.0, 100.0, 100.0], + filtered_count=1, + expected_tradable_ratio=decimal.Decimal("2") / decimal.Decimal("3"), + ), + TradableRatioTestCase( + margins=[200.0, 100.0, 100.0], + filtered_count=1, + expected_tradable_ratio=decimal.Decimal("0.5"), + ), + TradableRatioTestCase( + margins=[100.0, 100.0, 100.0], + filtered_count=2, + expected_tradable_ratio=decimal.Decimal("1") / decimal.Decimal("3"), + ), ], ) -def test_get_positions_to_consider_min_max_mark_price(min_mark_price, max_mark_price, positions, expected_symbols): +def test_tradable_ratio_calculation(test_case: TradableRatioTestCase): + """Test tradable_ratio calculation with various margin and filter scenarios.""" + # Create positions where first `filtered_count` have low mark_price (will be filtered) + positions = [] + for i, margin in enumerate(test_case.margins): + mark_price = 100.0 if i < test_case.filtered_count else 200.0 + positions.append(_position(f"ASSET{i}/USDT", margin, 5.0, mark_price=mark_price)) + + profile_data = MockProfileData("p1", positions) started_at = datetime.datetime(2024, 1, 1, 12, 0, 0) - result = profile_distribution.get_positions_to_consider( - positions, new_position_only=False, started_at=started_at, - min_mark_price=min_mark_price, max_mark_price=max_mark_price, + + result, tradable_ratio, source = profile_distribution.get_smoothed_distribution_from_profile_data( + profile_data, new_position_only=False, started_at=started_at, + min_mark_price=decimal.Decimal("150"), # Filters positions with mark_price < 150 ) - assert [p[trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value] for p in result] == expected_symbols + + assert abs(tradable_ratio - test_case.expected_tradable_ratio) < decimal.Decimal("0.0001") -def test_get_smoothed_distribution_from_profile_data_respects_min_max_mark_price(): - """With min=150 and max=250, only the 200 mark_price position is in.""" +def test_tradable_ratio_with_new_position_only(): + """Test tradable_ratio when filtering by new_position_only.""" + started_at = datetime.datetime(2024, 1, 1, 12, 0, 0) profile_data = MockProfileData("p1", [ - _position("BTC/USDT", 100.0, 5.0, mark_price=100.0), - _position("ETH/USDT", 100.0, 5.0, mark_price=200.0), - _position("SOL/USDT", 100.0, 5.0, mark_price=300.0), + { + trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value: "BTC/USDT", + trading_enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value: 100.0, + trading_enums.ExchangeConstantsPositionColumns.ENTRY_PRICE.value: 50000.0, + trading_enums.ExchangeConstantsPositionColumns.TIMESTAMP.value: started_at.timestamp() - 3600, # old + }, + { + trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value: "ETH/USDT", + trading_enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value: 100.0, + trading_enums.ExchangeConstantsPositionColumns.ENTRY_PRICE.value: 3000.0, + trading_enums.ExchangeConstantsPositionColumns.TIMESTAMP.value: started_at.timestamp() + 3600, # new + }, ]) - started_at = datetime.datetime(2024, 1, 1, 12, 0, 0) - result = profile_distribution.get_smoothed_distribution_from_profile_data( - profile_data, new_position_only=False, started_at=started_at, - min_mark_price=decimal.Decimal("150"), max_mark_price=decimal.Decimal("250"), + + result, tradable_ratio, source = profile_distribution.get_smoothed_distribution_from_profile_data( + profile_data, new_position_only=True, started_at=started_at, ) + + # Only ETH should be in distribution (BTC is old), tradable_ratio = 100/200 = 0.5 symbols = [d[index_distribution.DISTRIBUTION_NAME] for d in result] assert "BTC/USDT" not in symbols assert "ETH/USDT" in symbols - assert "SOL/USDT" not in symbols + assert tradable_ratio == decimal.Decimal("0.5") + +@pytest.mark.parametrize( + "test_case", + [ + PortfolioTestCase( + name="portfolio_when_no_positions", + positions=[], # No positions + portfolio={"BTC": decimal.Decimal("1.0"), "ETH": decimal.Decimal("10.0"), "USDT": decimal.Decimal("1000.0")}, # Portfolio + expected_source=profile_distribution.DistributionSource.PORTFOLIO.value, + expected_tradable_ratio=trading_constants.ONE, + expected_symbols=["BTC", "ETH", "USDT"], + excluded_symbols=[], # No excluded symbols + weight_assertion=None, # No weight assertion + ), + PortfolioTestCase( + name="prefers_positions_over_portfolio", + positions=[ # Has positions + { + trading_enums.ExchangeConstantsPositionColumns.SYMBOL.value: "SOL/USDT", + trading_enums.ExchangeConstantsPositionColumns.INITIAL_MARGIN.value: 100.0, + trading_enums.ExchangeConstantsPositionColumns.ENTRY_PRICE.value: 100.0, + }, + ], + portfolio={"BTC": decimal.Decimal("1.0"), "ETH": decimal.Decimal("10.0")}, # Has portfolio too + expected_source=profile_distribution.DistributionSource.POSITIONS.value, + expected_tradable_ratio=trading_constants.ONE, + expected_symbols=["SOL/USDT"], + excluded_symbols=["BTC", "ETH"], # These should NOT be present + weight_assertion=None, # No weight assertion + ), + PortfolioTestCase( + name="empty_profile", + positions=[], # No positions + portfolio=None, # No portfolio + expected_source=profile_distribution.DistributionSource.POSITIONS.value, + expected_tradable_ratio=trading_constants.ZERO, + expected_symbols=[], # Empty result + excluded_symbols=[], # No excluded symbols + weight_assertion=None, # No weight assertion + ), + PortfolioTestCase( + name="portfolio_weights", + positions=[], # No positions + portfolio={"BTC": decimal.Decimal("2.0"), "ETH": decimal.Decimal("10.0")}, # Portfolio with different weights + expected_source=profile_distribution.DistributionSource.PORTFOLIO.value, + expected_tradable_ratio=trading_constants.ONE, + expected_symbols=["BTC", "ETH"], + excluded_symbols=[], # No excluded symbols + weight_assertion="ETH > BTC", # ETH should have higher weight than BTC + ), + ], +) +def test_get_smoothed_distribution_portfolio_scenarios(test_case: PortfolioTestCase): + portfolio_obj = MockPortfolio(test_case.portfolio) if test_case.portfolio is not None else None + profile_data = MockProfileData("profile1", positions=test_case.positions, portfolio=portfolio_obj) + + started_at = datetime.datetime.now() + result, tradable_ratio, source = profile_distribution.get_smoothed_distribution_from_profile_data( + profile_data, new_position_only=False, started_at=started_at + ) + + assert source == test_case.expected_source + assert tradable_ratio == test_case.expected_tradable_ratio + + symbols = [d[index_distribution.DISTRIBUTION_NAME] for d in result] + assert symbols == test_case.expected_symbols + + # Check excluded symbols are not present + for symbol in test_case.excluded_symbols: + assert symbol not in symbols + + # Check weight assertions + if test_case.weight_assertion == "ETH > BTC": + btc_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "BTC"), None) + eth_dist = next((d for d in result if d[index_distribution.DISTRIBUTION_NAME] == "ETH"), None) + assert eth_dist[index_distribution.DISTRIBUTION_VALUE] > btc_dist[index_distribution.DISTRIBUTION_VALUE] + +@pytest.mark.parametrize("allocation_ratio,padding_ratio,expected_ref_market", [ + # No padding: 50% allocation = 50% reference market + (decimal.Decimal("0.5"), decimal.Decimal("0"), decimal.Decimal("0.5")), + # 20% padding on 50% allocation, but still only 100% tradable means effective = 50% + (decimal.Decimal("0.5"), decimal.Decimal("0.2"), decimal.Decimal("0.5")), + # Full allocation: 0% reference market + (decimal.Decimal("1.0"), decimal.Decimal("0"), decimal.Decimal("0")), +]) +def test_update_global_distribution_allocation_padding_ratio(allocation_ratio, padding_ratio, expected_ref_market): + """Test that allocation_padding_ratio is correctly applied.""" + distribution_per_exchange_profile = { + "profile1": _make_profile_dist(_make_distribution([ + ("BTC", 100.0, decimal.Decimal("50000")), + ]), tradable_ratio=trading_constants.ONE), + } + + result = profile_distribution.update_global_distribution( + distribution_per_exchange_profile, + allocation_ratio, + ["profile1"], + allocation_padding_ratio=padding_ratio + ) + + assert result[profile_distribution.REFERENCE_MARKET_RATIO] == expected_ref_market + +def test_update_global_distribution_allocation_padding_allows_more_usage(): + # Profile with 50% tradable ratio + distribution_per_exchange_profile = { + "profile1": _make_profile_dist(_make_distribution([ + ("BTC", 100.0, decimal.Decimal("50000")), + ]), tradable_ratio=decimal.Decimal("0.5")), + } + + # 50% allocation with 50% tradable = 25% effective allocation + result_no_padding = profile_distribution.update_global_distribution( + distribution_per_exchange_profile, + decimal.Decimal("0.5"), + ["profile1"], + allocation_padding_ratio=decimal.Decimal("0") + ) + + # 75% reference market (1 - 0.25) + assert result_no_padding[profile_distribution.REFERENCE_MARKET_RATIO] == decimal.Decimal("0.75") + + # With padding, the effective allocation is still capped by tradable_ratio + # 50% * 0.5 tradable = 25%, capped at 50% * 1.2 = 60% max + result_with_padding = profile_distribution.update_global_distribution( + distribution_per_exchange_profile, + decimal.Decimal("0.5"), + ["profile1"], + allocation_padding_ratio=decimal.Decimal("0.2") + ) + + # Same result since tradable_ratio < 1 anyway + assert result_with_padding[profile_distribution.REFERENCE_MARKET_RATIO] == decimal.Decimal("0.75")