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

[Grid] add trailing option #1421

Open
wants to merge 3 commits into
base: dev
Choose a base branch
from
Open
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
8 changes: 8 additions & 0 deletions Trading/Mode/grid_trading_mode/config/GridTradingMode.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
"mirror_order_delay": 0,
"use_existing_orders_only": false,
"allow_funds_redispatch": false,
"enable_trailing_up": false,
"enable_trailing_down": false,
"funds_redispatch_interval": 24
},
{
Expand All @@ -34,6 +36,9 @@
"use_fixed_volume_for_mirror_orders": false,
"mirror_order_delay": 0,
"use_existing_orders_only": false,
"allow_funds_redispatch": false,
"enable_trailing_up": false,
"enable_trailing_down": false,
"funds_redispatch_interval": 24
},
{
Expand All @@ -51,6 +56,9 @@
"use_fixed_volume_for_mirror_orders": false,
"mirror_order_delay": 0,
"use_existing_orders_only": false,
"allow_funds_redispatch": false,
"enable_trailing_up": false,
"enable_trailing_down": false,
"funds_redispatch_interval": 24
}
]
Expand Down
126 changes: 81 additions & 45 deletions Trading/Mode/grid_trading_mode/grid_trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,25 @@ def init_user_inputs(self, inputs: dict) -> None:
"fill. OctoBot won't create orders at startup: it will use the ones already on exchange instead. "
"This mode allows grid orders to operate on user created orders. Can't work on trading simulator.",
)
self.UI.user_input(
self.UI.user_input(
self.CONFIG_ENABLE_TRAILING_UP, commons_enums.UserInputTypes.BOOLEAN,
default_config[self.CONFIG_ENABLE_TRAILING_UP], inputs,
parent_input_name=self.CONFIG_PAIR_SETTINGS,
title="Trailing up: when checked, the whole grid will be cancelled and recreated when price goes above the "
"highest selling price. This might require the grid to perform a buy market order to be "
"able to recreate the grid new sell orders at the updated price.",
)
self.UI.user_input(
self.CONFIG_ENABLE_TRAILING_DOWN, commons_enums.UserInputTypes.BOOLEAN,
default_config[self.CONFIG_ENABLE_TRAILING_DOWN], inputs,
parent_input_name=self.CONFIG_PAIR_SETTINGS,
title="Trailing down: when checked, the whole grid will be cancelled and recreated when price goes bellow"
" the lowest buying price. This might require the grid to perform a sell market order to be "
"able to recreate the grid new buy orders at the updated price. "
"Warning: when trailing down, the sell order required to recreate the buying side of the grid "
"might generate a loss.",
)
self.UI.user_input(
self.CONFIG_ALLOW_FUNDS_REDISPATCH, commons_enums.UserInputTypes.BOOLEAN,
default_config[self.CONFIG_ALLOW_FUNDS_REDISPATCH], inputs,
parent_input_name=self.CONFIG_PAIR_SETTINGS,
Expand Down Expand Up @@ -206,6 +224,8 @@ def get_default_pair_config(self, symbol, flat_spread, flat_increment) -> dict:
self.CONFIG_USE_FIXED_VOLUMES_FOR_MIRROR_ORDERS: False,
self.CONFIG_USE_EXISTING_ORDERS_ONLY: False,
self.CONFIG_ALLOW_FUNDS_REDISPATCH: False,
self.CONFIG_ENABLE_TRAILING_UP: False,
self.CONFIG_ENABLE_TRAILING_DOWN: False,
Comment on lines +227 to +228
Copy link
Member

Choose a reason for hiding this comment

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

Why False as default value?

Copy link
Member Author

Choose a reason for hiding this comment

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

trailing up could be true by default indeed.
However, trailing down will sell at a loss, I don't think it should be true by default

self.CONFIG_FUNDS_REDISPATCH_INTERVAL: 24,
}

Expand Down Expand Up @@ -317,6 +337,12 @@ def read_config(self):
self.compensate_for_missed_mirror_order = self.symbol_trading_config.get(
self.trading_mode.COMPENSATE_FOR_MISSED_MIRROR_ORDER, self.compensate_for_missed_mirror_order
)
self.enable_trailing_up = self.symbol_trading_config.get(
self.trading_mode.CONFIG_ENABLE_TRAILING_UP, self.enable_trailing_up
)
self.enable_trailing_down = self.symbol_trading_config.get(
self.trading_mode.CONFIG_ENABLE_TRAILING_DOWN, self.enable_trailing_down
)

async def _handle_staggered_orders(self, current_price, ignore_mirror_orders_only, ignore_available_funds):
self._init_allowed_price_ranges(current_price)
Expand Down Expand Up @@ -395,53 +421,63 @@ async def _generate_staggered_orders(self, current_price, ignore_available_funds
highest_buy = self.buy_price_range.higher_bound
lowest_sell = self.sell_price_range.lower_bound
highest_sell = self.sell_price_range.higher_bound
trigger_trailing = False
if sorted_orders:
buy_orders = [order for order in sorted_orders if order.side == trading_enums.TradeOrderSide.BUY]
highest_buy = current_price
sell_orders = [order for order in sorted_orders if order.side == trading_enums.TradeOrderSide.SELL]
lowest_sell = current_price
origin_created_buy_orders_count, origin_created_sell_orders_count = self._get_origin_orders_count(
sorted_orders, recently_closed_trades
)

min_max_total_order_price_delta = (
self.flat_increment * (origin_created_buy_orders_count - 1 + origin_created_sell_orders_count - 1)
+ self.flat_increment
)
if buy_orders:
lowest_buy = buy_orders[0].origin_price
if not sell_orders:
highest_buy = min(current_price, lowest_buy + min_max_total_order_price_delta)
# buy orders only
lowest_sell = highest_buy + self.flat_spread - self.flat_increment
highest_sell = lowest_buy + min_max_total_order_price_delta + self.flat_spread - self.flat_increment
else:
# use only open order prices when possible
_highest_sell = sell_orders[-1].origin_price
highest_buy = min(current_price, _highest_sell - self.flat_spread + self.flat_increment)
if sell_orders:
highest_sell = sell_orders[-1].origin_price
if not buy_orders:
lowest_sell = max(current_price, highest_sell - min_max_total_order_price_delta)
# sell orders only
lowest_buy = max(
0, highest_sell - min_max_total_order_price_delta - self.flat_spread + self.flat_increment
)
highest_buy = lowest_sell - self.flat_spread + self.flat_increment
else:
# use only open order prices when possible
_lowest_buy = buy_orders[0].origin_price
lowest_sell = max(current_price, _lowest_buy - self.flat_spread + self.flat_increment)

missing_orders, state, _ = self._analyse_current_orders_situation(
sorted_orders, recently_closed_trades, lowest_buy, highest_sell, current_price
)
if missing_orders:
self.logger.info(
f"{len(missing_orders)} missing {self.symbol} orders on {self.exchange_name}: {missing_orders}"
if self._should_trigger_trailing(sorted_orders):
trigger_trailing = True
else:
buy_orders = [order for order in sorted_orders if order.side == trading_enums.TradeOrderSide.BUY]
sell_orders = [order for order in sorted_orders if order.side == trading_enums.TradeOrderSide.SELL]
highest_buy = current_price
lowest_sell = current_price
origin_created_buy_orders_count, origin_created_sell_orders_count = self._get_origin_orders_count(
sorted_orders, recently_closed_trades
)

min_max_total_order_price_delta = (
self.flat_increment * (origin_created_buy_orders_count - 1 + origin_created_sell_orders_count - 1)
+ self.flat_increment
)
if buy_orders:
lowest_buy = buy_orders[0].origin_price
if not sell_orders:
highest_buy = min(current_price, lowest_buy + min_max_total_order_price_delta)
# buy orders only
lowest_sell = highest_buy + self.flat_spread - self.flat_increment
highest_sell = lowest_buy + min_max_total_order_price_delta + self.flat_spread - self.flat_increment
else:
# use only open order prices when possible
_highest_sell = sell_orders[-1].origin_price
highest_buy = min(current_price, _highest_sell - self.flat_spread + self.flat_increment)
if sell_orders:
highest_sell = sell_orders[-1].origin_price
if not buy_orders:
lowest_sell = max(current_price, highest_sell - min_max_total_order_price_delta)
# sell orders only
lowest_buy = max(
0, highest_sell - min_max_total_order_price_delta - self.flat_spread + self.flat_increment
)
highest_buy = lowest_sell - self.flat_spread + self.flat_increment
else:
# use only open order prices when possible
_lowest_buy = buy_orders[0].origin_price
lowest_sell = max(current_price, _lowest_buy - self.flat_spread + self.flat_increment)
if trigger_trailing:
await self._trigger_trailing(sorted_orders, current_price)
# trailing will cancel all orders: set state to NEW with no existing order
missing_orders, state, sorted_orders = None, self.NEW, []
else:
# no trailing, process normal analysis
missing_orders, state, _ = self._analyse_current_orders_situation(
sorted_orders, recently_closed_trades, lowest_buy, highest_sell, current_price
)
await self._handle_missed_mirror_orders_fills(recently_closed_trades, missing_orders, current_price)
if missing_orders:
self.logger.info(
f"{len(missing_orders)} missing {self.symbol} orders on {self.exchange_name}: {missing_orders}"
)
await self._handle_missed_mirror_orders_fills(recently_closed_trades, missing_orders, current_price)
try:
# apply state and (re)create missing orders
buy_orders = self._create_orders(lowest_buy, highest_buy,
trading_enums.TradeOrderSide.BUY, sorted_orders,
current_price, missing_orders, state, self.buy_funds, ignore_available_funds,
Expand Down
6 changes: 6 additions & 0 deletions Trading/Mode/grid_trading_mode/resources/GridTradingMode.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ You can customize the grid for each trading pair. To configure a pair, enter:
- The interval between each order (increment)
- The amount of initial buy and sell orders to create

#### Trailing options
A grid can only operate within its price range. However, when trailing options are enabled,
the whole grid can be automatically cancelled and recreated
when the traded asset's price moves beyond the grid range. In this case, a market order can be executed in order to
have the necessary funds to create the grid buy and sell orders.

#### Profits
Profits will be made from price movements within the covered price area.
It never "sells at a loss", but always at a profit, therefore OctoBot never cancels any orders when using the Grid Trading Mode.
Expand Down
107 changes: 107 additions & 0 deletions Trading/Mode/grid_trading_mode/tests/test_grid_trading_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,113 @@ def _get_fees_for_currency(fee, currency):
assert sorted(new_orders, key=lambda x: x.origin_price)[-1] is sorted(open_orders, key=lambda x: x.origin_price)[-1]


async def test_trailing_up():
symbol = "BTC/USDT"
async with _get_tools(symbol) as (producer, _, exchange_manager):
# first start: setup orders
producer.sell_funds = decimal.Decimal("1") # 25 sell orders
producer.buy_funds = decimal.Decimal("1") # 19 buy orders
orders_count = 19 + 25

price = 100
trading_api.force_set_mark_price(exchange_manager, producer.symbol, price)
await producer._ensure_staggered_orders()
await asyncio.create_task(_check_open_orders_count(exchange_manager, orders_count))
original_orders = copy.copy(trading_api.get_open_orders(exchange_manager))
assert len(original_orders) == orders_count
_check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 100)
# A. price moves up
pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available

# offline simulation: orders get filled but not replaced => price got up to more than the max price
open_orders = copy.copy(trading_api.get_open_orders(exchange_manager))
offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL]
for order in offline_filled:
await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer)
# simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS
staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None)
post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available
assert pre_portfolio < post_portfolio
assert len(trading_api.get_open_orders(exchange_manager)) == orders_count - len(offline_filled)
producer.enable_trailing_up = True

# top filled sell order price = 225
assert max(o.origin_price for o in offline_filled) == decimal.Decimal("225")
new_price = decimal.Decimal(250)
trading_api.force_set_mark_price(exchange_manager, producer.symbol, new_price)
# will trail up
await producer._ensure_staggered_orders()
await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth))
_check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 250)

# B. orders get filled but not enough to trigger a trailing reset
# offline simulation: orders get filled but not replaced => price got up to more than the max price
open_orders = trading_api.get_open_orders(exchange_manager)
# all but 1 sell orders is filled
offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.SELL][:-1]
for order in offline_filled:
await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer)
# simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS
staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None)
post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "USDT").available
assert pre_portfolio < post_portfolio
assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth - len(offline_filled)
producer.enable_trailing_up = True
producer.enable_trailing_down = True
# doesn't trail up: a sell order still remains
await producer._ensure_staggered_orders()
await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth))
_check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 250)
# all buy orders are still here
# not cancelled sell order is still here
offline_filled_ids = [o.order_id for o in offline_filled]
for order in open_orders:
if order.order_id in offline_filled_ids:
assert order.is_closed()
else:
assert order.is_open()

# C. price moves down, trailing down is disabled
pre_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available

# offline simulation: orders get filled but not replaced => price got up to more than the max price
open_orders = copy.copy(trading_api.get_open_orders(exchange_manager))
offline_filled = [o for o in open_orders if o.side == trading_enums.TradeOrderSide.BUY]
for order in offline_filled:
await _fill_order(order, exchange_manager, trigger_update_callback=False, producer=producer)
# simulate a start without StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS
staggered_orders_trading.StaggeredOrdersTradingModeProducer.AVAILABLE_FUNDS.pop(exchange_manager.id, None)
post_portfolio = trading_api.get_portfolio_currency(exchange_manager, "BTC").available
assert pre_portfolio < post_portfolio
assert len(trading_api.get_open_orders(exchange_manager)) == producer.operational_depth - len(offline_filled)
producer.enable_trailing_down = False

# top filled sell order price = 125
assert min(o.origin_price for o in offline_filled) == decimal.Decimal("125")
new_price = decimal.Decimal(125)
trading_api.force_set_mark_price(exchange_manager, producer.symbol, new_price)
# will not trail down
await producer._ensure_staggered_orders()
await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth))
_check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 250)
# only contains sell orders
open_orders = copy.copy(trading_api.get_open_orders(exchange_manager))
assert all (order.side == trading_enums.TradeOrderSide.SELL for order in open_orders)

# D. price is still down, trailing down is enabled
producer.enable_trailing_down = True

# will trail down
await producer._ensure_staggered_orders()
await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth - 1)) # -1 because the very first order can't be at a price <0
# orders are recreated around 125
_check_created_orders(producer, trading_api.get_open_orders(exchange_manager), 125)
# now contains buy and sell orders
Copy link
Member

Choose a reason for hiding this comment

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

👍

open_orders = trading_api.get_open_orders(exchange_manager)
assert len([order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL]) == producer.sell_orders_count
assert len([order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY]) == producer.buy_orders_count - 1


@contextlib.contextmanager
def _assert_adapt_order_quantity_because_fees(get_fees_for_currency=False):
_origin_decimal_adapt_order_quantity_because_fees = trading_personal_data.decimal_adapt_order_quantity_because_fees
Expand Down
Loading
Loading