Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[DCA][Staggered] init health check #1114

Merged
merged 2 commits into from
Dec 9, 2023
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 @@ -28,7 +28,7 @@ def init_user_inputs(self, inputs: dict) -> None:
"""
super().init_user_inputs(inputs)
self.UI.user_input(common_constants.CONFIG_TENTACLES_REQUIRED_CANDLES_COUNT, common_enums.UserInputTypes.INT,
200, inputs, min_val=200,
200, inputs, min_val=1,
title="Initialization candles count: the number of historical candles to fetch from "
"exchanges when OctoBot is starting.")

Expand Down
4 changes: 3 additions & 1 deletion Trading/Mode/dca_trading_mode/config/DCATradingMode.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,7 @@
"use_secondary_exit_orders": false,
"use_stop_losses": false,
"use_take_profit_exit_orders": false,
"cancel_open_orders_at_each_entry": true
"cancel_open_orders_at_each_entry": true,
"enable_health_check": false,
"health_check_orphan_funds_threshold": 15
}
122 changes: 121 additions & 1 deletion Trading/Mode/dca_trading_mode/dca_trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import octobot_trading.util as trading_util
import octobot_trading.errors as trading_errors
import octobot_trading.personal_data as trading_personal_data
import octobot_trading.exchanges as trading_exchanges
import octobot_trading.modes.script_keywords as script_keywords


Expand Down Expand Up @@ -341,6 +342,7 @@ class DCATradingModeProducer(trading_modes.AbstractTradingModeProducer):
MINUTES_BEFORE_NEXT_BUY = "minutes_before_next_buy"
TRIGGER_MODE = "trigger_mode"
CANCEL_OPEN_ORDERS_AT_EACH_ENTRY = "cancel_open_orders_at_each_entry"
HEALTH_CHECK_ORPHAN_FUNDS_THRESHOLD = "health_check_orphan_funds_threshold"

def __init__(self, channel, config, trading_mode, exchange_manager):
super().__init__(channel, config, trading_mode, exchange_manager)
Expand Down Expand Up @@ -499,6 +501,9 @@ class DCATradingMode(trading_modes.AbstractTradingMode):
MODE_PRODUCER_CLASSES = [DCATradingModeProducer]
MODE_CONSUMER_CLASSES = [DCATradingModeConsumer]
SUPPORTS_INITIAL_PORTFOLIO_OPTIMIZATION = True
SUPPORTS_HEALTH_CHECK = True
DEFAULT_HEALTH_CHECK_SELL_ORPHAN_FUNDS_RATIO_THRESHOLD = decimal.Decimal("0.15") # 15%
HEALTH_CHECK_FILL_ORDERS_TIMEOUT = 20

def __init__(self, config, exchange_manager):
super().__init__(config, exchange_manager)
Expand All @@ -523,6 +528,7 @@ def __init__(self, config, exchange_manager):
self.stop_loss_price_multiplier = DCATradingModeConsumer.DEFAULT_STOP_LOSS_ORDERS_PRICE_MULTIPLIER

self.cancel_open_orders_at_each_entry = True
self.health_check_orphan_funds_threshold = self.DEFAULT_HEALTH_CHECK_SELL_ORPHAN_FUNDS_RATIO_THRESHOLD

# enable initialization orders
self.are_initialization_orders_pending = True
Expand Down Expand Up @@ -702,10 +708,36 @@ def init_user_inputs(self, inputs: dict) -> None:
)) / trading_constants.ONE_HUNDRED

self.cancel_open_orders_at_each_entry = self.UI.user_input(
DCATradingModeProducer.CANCEL_OPEN_ORDERS_AT_EACH_ENTRY, commons_enums.UserInputTypes.BOOLEAN, self.cancel_open_orders_at_each_entry, inputs,
DCATradingModeProducer.CANCEL_OPEN_ORDERS_AT_EACH_ENTRY, commons_enums.UserInputTypes.BOOLEAN,
self.cancel_open_orders_at_each_entry, inputs,
title="Cancel open orders on each entry: Cancel existing orders from previous iteration on each entry.",
)

self.is_health_check_enabled = self.UI.user_input(
self.ENABLE_HEALTH_CHECK, commons_enums.UserInputTypes.BOOLEAN,
self.is_health_check_enabled, inputs,
title="Health check: when enabled, OctoBot will automatically sell traded assets that are not associated "
"to a sell order and that represent at least the 'Health check threshold' part of the "
"portfolio. Health check can be useful to avoid inactive funds, for example if a buy order got "
"filled but no sell order was created. Requires a common quote market for each traded pair. "
"Warning: will sell any asset associated to a trading pair that is not covered by a sell order, "
"even if not bought by OctoBot or this trading mode.",
Comment on lines +719 to +724
Copy link
Member

Choose a reason for hiding this comment

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

👍

)
self.health_check_orphan_funds_threshold = decimal.Decimal(str(
self.UI.user_input(
DCATradingModeProducer.HEALTH_CHECK_ORPHAN_FUNDS_THRESHOLD, commons_enums.UserInputTypes.FLOAT,
float(self.health_check_orphan_funds_threshold * trading_constants.ONE_HUNDRED), inputs,
title="Health check threshold: Minimum % of the portfolio taken by a traded asset that is not in "
"sell orders. Assets above this threshold will be sold for the common quote market during "
"Health check.",
editor_options={
commons_enums.UserInputOtherSchemaValuesTypes.DEPENDENCIES.value: {
self.ENABLE_HEALTH_CHECK: True
}
}
)
)) / trading_constants.ONE_HUNDRED

@classmethod
def get_is_symbol_wildcard(cls) -> bool:
return False
Expand Down Expand Up @@ -734,3 +766,91 @@ async def single_exchange_process_optimize_initial_portfolio(
return await trading_modes.convert_assets_to_target_asset(
self, sellable_assets, target_asset, tickers
)

async def single_exchange_process_health_check(self, chained_orders: list, tickers: dict) -> list:
common_quote = trading_exchanges.get_common_traded_quote(self.exchange_manager)
if (
self.exchange_manager.is_backtesting
or common_quote is None
or not (self.use_take_profit_exit_orders or self.use_stop_loss)
):
# skipped when:
# - backtesting
# - common_quote is unset
# - not using take profit or stop losses, health check should not be used
return []
Comment on lines +772 to +781
Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't we override should_trigger_health_check?

Copy link
Member Author

Choose a reason for hiding this comment

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

should_trigger_health_check renamed in Drakkar-Software/OctoBot-Trading#1006

created_orders = []
for asset, amount in self._get_lost_funds_to_sell(common_quote, chained_orders):
# sell lost funds
self.logger.info(
f"Health check: selling {amount} {asset} into {common_quote} on {self.exchange_manager.exchange_name}"
)
try:
asset_orders = await trading_modes.convert_asset_to_target_asset(
self, asset, common_quote, tickers, asset_amount=amount
)
if not asset_orders:
self.logger.info(
f"Health check: Not enough funds to create an order according to exchanges rules using "
f"{amount} {asset} into {common_quote} on {self.exchange_manager.exchange_name}"
)
else:
created_orders.extend(asset_orders)
except Exception as err:
self.logger.exception(
err, True, f"Error when creating order to sell {asset} into {common_quote}: {err}"
)
if created_orders:
await asyncio.gather(
*[
trading_personal_data.wait_for_order_fill(
order, self.HEALTH_CHECK_FILL_ORDERS_TIMEOUT, True
) for order in created_orders
]
)
return created_orders

def _get_lost_funds_to_sell(self, common_quote: str, chained_orders: list) -> list[(str, decimal.Decimal)]:
asset_and_amount = []
value_holder = self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder
traded_base_assets = set(
symbol.base
for symbol in self.exchange_manager.exchange_config.traded_symbols
)
sell_orders = [
order
for order in self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders() + chained_orders
if order.side is trading_enums.TradeOrderSide.SELL
]
orphan_asset_values_by_asset = {}
total_traded_assets_value = value_holder.value_converter.evaluate_value(
common_quote,
self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio(
common_quote
).total,
target_currency=common_quote,
init_price_fetchers=False
)
for asset in traded_base_assets:
holdings = self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.get_currency_portfolio(
asset
).total
holdings_value = value_holder.value_converter.evaluate_value(
asset, holdings, target_currency=common_quote, init_price_fetchers=False
)
total_traded_assets_value += holdings_value
holdings_in_sell_orders = sum(
order.origin_quantity
for order in sell_orders
if symbol_util.parse_symbol(order.symbol).base == asset
)
orphan_amount = holdings - holdings_in_sell_orders
if orphan_amount and orphan_amount > 0:
orphan_asset_values_by_asset[asset] = (holdings_value * orphan_amount / holdings, orphan_amount)

for asset, value_and_orphan_amount in orphan_asset_values_by_asset.items():
value, orphan_amount = value_and_orphan_amount
ratio = value / total_traded_assets_value
if ratio > self.health_check_orphan_funds_threshold:
asset_and_amount.append((asset, orphan_amount))
return asset_and_amount
109 changes: 109 additions & 0 deletions Trading/Mode/dca_trading_mode/tests/test_dca_trading_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,115 @@ async def test_single_exchange_process_optimize_initial_portfolio(tools):
assert orders == ["order_1"]


async def test_single_exchange_process_health_check(tools):
mode, producer, consumer, trader = await _init_mode(tools, _get_config(tools, {}))
exchange_manager = trader.exchange_manager
with mock.patch.object(producer, "dca_task", mock.AsyncMock()): # prevent auto dca task

portfolio = trader.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.portfolio
converter = trader.exchange_manager.exchange_personal_data.portfolio_manager.\
portfolio_value_holder.value_converter
converter.update_last_price(mode.symbol, decimal.Decimal("1000"))

origin_portfolio_USDT = portfolio["USDT"].total

# no traded symbols: no orders
exchange_manager.exchange_config.traded_symbols = []
assert await mode.single_exchange_process_health_check([], {}) == []
assert portfolio["USDT"].total == origin_portfolio_USDT

# with traded symbols: 1 order as BTC is not already in a sell order
exchange_manager.exchange_config.traded_symbols = [commons_symbols.parse_symbol(mode.symbol)]

# no self.use_take_profit_exit_orders or self.use_stop_loss
mode.use_take_profit_exit_orders = False
mode.use_stop_loss = False
assert await mode.single_exchange_process_health_check([], {}) == []

# no health check in backtesting
exchange_manager.is_backtesting = True
assert await mode.single_exchange_process_health_check([], {}) == []
exchange_manager.is_backtesting = False

# use_take_profit_exit_orders is True: health check can proceed
mode.use_take_profit_exit_orders = True
orders = await mode.single_exchange_process_health_check([], {})
assert len(orders) == 1
sell_order = orders[0]
assert isinstance(sell_order, trading_personal_data.SellMarketOrder)
assert sell_order.symbol == mode.symbol
assert sell_order.origin_quantity == decimal.Decimal(10)
assert portfolio["BTC"].total == trading_constants.ZERO
after_btc_usdt_portfolio = portfolio["USDT"].total
assert after_btc_usdt_portfolio > origin_portfolio_USDT

# now that BTC is sold, calling it again won't create any order
assert await mode.single_exchange_process_health_check([], {}) == []

# add ETH in portfolio: will also be sold but is bellow threshold
converter.update_last_price("ETH/USDT", decimal.Decimal("100"))
exchange_manager.client_symbols.append("ETH/USDT")
exchange_manager.exchange_config.traded_symbols.append(commons_symbols.parse_symbol("ETH/USDT"))
eth_holdings = decimal.Decimal(2)
portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings)
assert await mode.single_exchange_process_health_check([], {}) == []

# more ETH: sell
eth_holdings = decimal.Decimal(200)
portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings)
orders = await mode.single_exchange_process_health_check([], {})
assert len(orders) == 1
sell_order = orders[0]
assert isinstance(sell_order, trading_personal_data.SellMarketOrder)
assert sell_order.symbol == "ETH/USDT"
assert sell_order.origin_quantity == eth_holdings
assert portfolio["ETH"].total == trading_constants.ZERO
after_eth_usdt_portfolio = portfolio["USDT"].total
assert after_eth_usdt_portfolio > after_btc_usdt_portfolio
Copy link
Member

Choose a reason for hiding this comment

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

👍


# add ETH to be sold but already in sell order: do not sell the part in sell orders
eth_holdings = decimal.Decimal(200)
portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings)
existing_sell_order = trading_personal_data.SellLimitOrder(trader)
existing_sell_order.origin_quantity = decimal.Decimal(45)
existing_sell_order.symbol = "ETH/USDT"
await exchange_manager.exchange_personal_data.orders_manager.upsert_order_instance(existing_sell_order)
orders = await mode.single_exchange_process_health_check([], {})
assert len(orders) == 1
sell_order = orders[0]
assert isinstance(sell_order, trading_personal_data.SellMarketOrder)
assert sell_order.symbol == "ETH/USDT"
assert sell_order.origin_quantity == eth_holdings - decimal.Decimal(45)
assert portfolio["ETH"].total == decimal.Decimal(45)
after_eth_usdt_portfolio = portfolio["USDT"].total
assert after_eth_usdt_portfolio > after_btc_usdt_portfolio

# add ETH to be sold but already in chained sell order: do not sell the part in chained sell orders
eth_holdings = decimal.Decimal(200)
portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings)
chained_sell_order = trading_personal_data.SellLimitOrder(trader)
chained_sell_order.origin_quantity = decimal.Decimal(10)
chained_sell_order.symbol = "ETH/USDT"
orders = await mode.single_exchange_process_health_check([chained_sell_order], {})
assert len(orders) == 1
sell_order = orders[0]
assert isinstance(sell_order, trading_personal_data.SellMarketOrder)
assert sell_order.symbol == "ETH/USDT"
assert sell_order.origin_quantity == eth_holdings - decimal.Decimal(45) - decimal.Decimal(10)
assert portfolio["ETH"].total == decimal.Decimal(45) + decimal.Decimal(10)
after_eth_usdt_portfolio = portfolio["USDT"].total
assert after_eth_usdt_portfolio > after_btc_usdt_portfolio

# add ETH to be sold but already in chained sell order: do not sell the part in chained sell orders:
# sell orders make it bellow threshold: no market sell created
eth_holdings = decimal.Decimal(200)
portfolio["ETH"] = trading_personal_data.SpotAsset("ETH", eth_holdings, eth_holdings)
chained_sell_order = trading_personal_data.SellLimitOrder(trader)
chained_sell_order.origin_quantity = decimal.Decimal(55)
chained_sell_order.symbol = "ETH/USDT"
assert await mode.single_exchange_process_health_check([chained_sell_order], {}) == []


async def _check_open_orders_count(trader, count):
assert len(trading_api.get_open_orders(trader.exchange_manager)) == count

Expand Down
1 change: 1 addition & 0 deletions Trading/Mode/grid_trading_mode/grid_trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class GridTradingMode(staggered_orders_trading.StaggeredOrdersTradingMode):
USER_COMMAND_PAUSE_ORDER_MIRRORING = "pause orders mirroring"
USER_COMMAND_TRADING_PAIR = "trading pair"
USER_COMMAND_PAUSE_TIME = "pause length in seconds"
SUPPORTS_HEALTH_CHECK = False # WIP # set True when self.health_check is implemented
Copy link
Member

Choose a reason for hiding this comment

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

Why isn't it the same implementation as staggered?

Copy link
Member Author

Choose a reason for hiding this comment

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

staggered have min and max prices boundaries, it makes orders "grid" translation much more complicated

Copy link
Member

Choose a reason for hiding this comment

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

Oh right, I see


def init_user_inputs(self, inputs: dict) -> None:
"""
Expand Down
Loading