Skip to content
This repository was archived by the owner on Feb 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
15 changes: 13 additions & 2 deletions Trading/Mode/profile_copy_trading_mode/profile_copy_trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

))) / 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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand All @@ -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"
Expand Down
140 changes: 111 additions & 29 deletions Trading/Mode/profile_copy_trading_mode/profile_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -158,23 +231,34 @@ 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
}

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:
Expand All @@ -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):
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading