-
Notifications
You must be signed in to change notification settings - Fork 75
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
||
|
||
|
@@ -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) | ||
|
@@ -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) | ||
|
@@ -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 | ||
|
@@ -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.", | ||
) | ||
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 | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't we override There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why isn't it the same implementation as staggered? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh right, I see |
||
|
||
def init_user_inputs(self, inputs: dict) -> None: | ||
""" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍