Skip to content

Commit

Permalink
[Staggered] check trailing on filled order
Browse files Browse the repository at this point in the history
  • Loading branch information
GuillaumeDSM committed Jan 21, 2025
1 parent 3b56d09 commit afd7036
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 37 deletions.
21 changes: 7 additions & 14 deletions Trading/Mode/grid_trading_mode/grid_trading.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,20 +421,13 @@ 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_up = False
trigger_trailing_down = False
trigger_trailing = False
if sorted_orders:
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]
# 3 to allow trailing even if a few order from the other side have also been filled
one_sided_orders_trailing_threshold = self.operational_depth / 3
if self.enable_trailing_up and len(buy_orders) >= one_sided_orders_trailing_threshold and not sell_orders:
# only buy orders remaining: everything has been sold, trigger tailing up when enabled
trigger_trailing_up = True
elif self.enable_trailing_down and len(sell_orders) >= one_sided_orders_trailing_threshold and not buy_orders:
# only sell orders remaining: everything has been bought, trigger tailing up when enabled
trigger_trailing_down = True
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(
Expand Down Expand Up @@ -469,8 +462,8 @@ async def _generate_staggered_orders(self, current_price, ignore_available_funds
# 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_up or trigger_trailing_down:
await self._trigger_trailing(sorted_orders, current_price)
if trigger_trailing:
await self._prepare_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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import math
import asyncio
import decimal
import typing

import async_channel.constants as channel_constants
import octobot_commons.constants as commons_constants
Expand Down Expand Up @@ -247,8 +248,7 @@ async def _order_notification_callback(self, exchange, exchange_id, cryptocurren
)
and is_from_bot
):
async with self.producers[0].get_lock():
await self.producers[0].order_filled_callback(order)
await self.producers[0].order_filled_callback(order)

@classmethod
def get_is_symbol_wildcard(cls) -> bool:
Expand Down Expand Up @@ -769,13 +769,13 @@ async def order_filled_callback(self, filled_order):
new_order = OrderData(new_side, volume, price, self.symbol, False, associated_entry_id)
self.logger.debug(f"Creating mirror order: {new_order} after filled order: {filled_order}")
if self.mirror_order_delay == 0 or trading_api.get_is_backtesting(self.exchange_manager):
await self._lock_portfolio_and_create_order_when_possible(new_order, filled_price)
await self._ensure_trailing_and_create_order_when_possible(new_order, filled_price)
else:
# create order after waiting time
self.mirror_orders_tasks.append(asyncio.get_event_loop().call_later(
self.mirror_order_delay,
asyncio.create_task,
self._lock_portfolio_and_create_order_when_possible(new_order, filled_price)
self._ensure_trailing_and_create_order_when_possible(new_order, filled_price)
))

def _compute_mirror_order_volume(self, now_selling, filled_price, target_price, filled_volume, paid_fees: dict):
Expand Down Expand Up @@ -808,11 +808,36 @@ def _compute_mirror_order_volume(self, now_selling, filled_price, target_price,
fees_in_base = new_order_quantity * self.max_fees
return new_order_quantity - fees_in_base

async def _ensure_trailing_and_create_order_when_possible(self, new_order, filled_price):
if self._should_trigger_trailing(None):
await self._ensure_staggered_orders()
else:
async with self.get_lock():
await self._lock_portfolio_and_create_order_when_possible(new_order, filled_price)

async def _lock_portfolio_and_create_order_when_possible(self, new_order, filled_price):
await asyncio.wait_for(self.allowed_mirror_orders.wait(), timeout=None)
async with self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio.lock:
await self._create_order(new_order, filled_price)

def _should_trigger_trailing(self, orders: typing.Optional[list]):
if not (self.enable_trailing_up or self.enable_trailing_down):
return False
existing_orders = (
orders or self.exchange_manager.exchange_personal_data.orders_manager.get_open_orders(self.symbol)
)
buy_orders = [order for order in existing_orders if order.side == trading_enums.TradeOrderSide.BUY]
sell_orders = [order for order in existing_orders if order.side == trading_enums.TradeOrderSide.SELL]
# 3 to allow trailing even if a few order from the other side have also been filled
one_sided_orders_trailing_threshold = self.operational_depth / 3
if self.enable_trailing_up and len(buy_orders) >= one_sided_orders_trailing_threshold and not sell_orders:
# only buy orders remaining: everything has been sold, trigger tailing up when enabled
return True
if self.enable_trailing_down and len(sell_orders) >= one_sided_orders_trailing_threshold and not buy_orders:
# only sell orders remaining: everything has been bought, trigger tailing up when enabled
return True
return False

async def _handle_staggered_orders(self, current_price, ignore_mirror_orders_only, ignore_available_funds):
self._ensure_current_price_in_limit_parameters(current_price)
if not ignore_mirror_orders_only and self.use_existing_orders_only:
Expand Down Expand Up @@ -866,10 +891,16 @@ async def _generate_staggered_orders(self, current_price, ignore_available_funds
recently_closed_trades = trading_api.get_trade_history(self.exchange_manager, symbol=self.symbol,
since=recent_trades_time)
recently_closed_trades = sorted(recently_closed_trades, key=lambda trade: trade.executed_price)

missing_orders, state, candidate_flat_increment = self._analyse_current_orders_situation(
sorted_orders, recently_closed_trades, self.lowest_buy, self.highest_sell, current_price
)
candidate_flat_increment = None
trigger_trailing = sorted_orders and self._should_trigger_trailing(sorted_orders)
if trigger_trailing:
await self._prepare_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:
missing_orders, state, candidate_flat_increment = self._analyse_current_orders_situation(
sorted_orders, recently_closed_trades, self.lowest_buy, self.highest_sell, current_price
)
self._set_increment_and_spread(current_price, candidate_flat_increment)

highest_buy = min(current_price, self.highest_sell)
Expand Down Expand Up @@ -899,15 +930,7 @@ async def _reset_orders(
):
self.logger.info("Resetting orders")
await asyncio.gather(*(self.trading_mode.cancel_order(order) for order in sorted_orders))
base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote()
self._set_initially_available_funds(
base,
trading_api.get_portfolio_currency(self.exchange_manager, base).available,
)
self._set_initially_available_funds(
quote,
trading_api.get_portfolio_currency(self.exchange_manager, quote).available,
)
self._reset_available_funds()
state = self.NEW
buy_orders = self._create_orders(
lowest_buy, highest_buy, trading_enums.TradeOrderSide.BUY, sorted_orders,
Expand All @@ -919,6 +942,17 @@ async def _reset_orders(
)
return buy_orders, sell_orders, state

def _reset_available_funds(self):
base, quote = symbol_util.parse_symbol(self.symbol).base_and_quote()
self._set_initially_available_funds(
base,
trading_api.get_portfolio_currency(self.exchange_manager, base).available,
)
self._set_initially_available_funds(
quote,
trading_api.get_portfolio_currency(self.exchange_manager, quote).available,
)

def _ensure_used_funds(self, new_buy_orders, new_sell_orders, existing_orders, recently_closed_trades):
if not self.allow_order_funds_redispatch:
return
Expand Down Expand Up @@ -1193,7 +1227,7 @@ def _printable_trade(trade):
)
return trades_with_missing_mirror_order_fills

async def _trigger_trailing(self, open_orders: list, current_price: decimal.Decimal):
async def _prepare_trailing(self, open_orders: list, current_price: decimal.Decimal):
log_header = f"[{self.exchange_manager.exchange_name}] {self.symbol} trailing process: "
if current_price <= trading_constants.ZERO:
self.logger.error(
Expand All @@ -1202,7 +1236,7 @@ async def _trigger_trailing(self, open_orders: list, current_price: decimal.Deci
# 1. cancel all open orders
try:
cancelled_orders = []
self.logger.info(f"{log_header}cancelling existing open orders on {self.symbol}")
self.logger.info(f"{log_header}cancelling {len(open_orders)} open orders on {self.symbol}")
for order in open_orders:
if not (order.is_cancelled() or order.is_closed()):
await self.trading_mode.cancel_order(order)
Expand Down Expand Up @@ -1265,6 +1299,10 @@ async def _trigger_trailing(self, open_orders: list, current_price: decimal.Deci
self.logger.exception(
err, True, f"Error in {log_header}convert into target step: {err}"
)

# 3. reset available funds (free funds from cancelled orders)
self._reset_available_funds()

self.logger.info(
f"Completed {log_header} {len(cancelled_orders)} cancelled orders, {len(orders)} "
f"created conversion orders"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1491,7 +1491,7 @@ async def test_single_exchange_process_optimize_initial_portfolio():
assert final_portfolio["USD"].available == decimal.Decimal("5545")


async def test_trigger_trailing():
async def test_prepare_trailing():
symbol = "BTC/USD"
async with _get_tools(symbol) as tools:
producer, _, exchange_manager = tools
Expand All @@ -1507,7 +1507,7 @@ async def test_trigger_trailing():
# now has buy and sell orders
open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders()
# simulate price being stable
cancelled_orders, created_orders = await producer._trigger_trailing(open_orders, current_price=4000)
cancelled_orders, created_orders = await producer._prepare_trailing(open_orders, current_price=4000)
assert len(cancelled_orders) == len(open_orders)
# cancelled orders
updated_open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders()
Expand All @@ -1528,7 +1528,7 @@ async def test_trigger_trailing():

# price change (going down), no order to cancel: just adapt pf
trading_api.force_set_mark_price(exchange_manager, symbol, 3000)
cancelled_orders, created_orders = await producer._trigger_trailing(open_orders, current_price=3000)
cancelled_orders, created_orders = await producer._prepare_trailing(open_orders, current_price=3000)
# no order to cancel (orders are already cancelled)
assert len(cancelled_orders) == 0
# created order to balance BTC and USD (buy BTC)
Expand All @@ -1544,7 +1544,7 @@ async def test_trigger_trailing():

# price change (going up), no order to cancel: just adapt pf
trading_api.force_set_mark_price(exchange_manager, symbol, 8000)
cancelled_orders, created_orders = await producer._trigger_trailing([], current_price=8000)
cancelled_orders, created_orders = await producer._prepare_trailing([], current_price=8000)
# no order to cancel
assert len(cancelled_orders) == 0
# created order to balance BTC and USD (buy BTC)
Expand All @@ -1559,6 +1559,102 @@ async def test_trigger_trailing():
assert portfolio["USD"].available == decimal.Decimal('32849.20155208000')


async def test_should_trigger_trailing():
symbol = "BTC/USD"
async with _get_tools(symbol) as tools:
producer, _, exchange_manager = tools
# A. no open order: no trailing
assert producer._should_trigger_trailing([]) is False
producer.enable_trailing_up = producer.enable_trailing_down = True
assert producer._should_trigger_trailing([]) is False

# create orders
trading_api.force_set_mark_price(exchange_manager, symbol, 4000)
with mock.patch.object(producer, "_ensure_current_price_in_limit_parameters", mock.Mock()) \
as _ensure_current_price_in_limit_parameters_mock:
await producer._ensure_staggered_orders()
_ensure_current_price_in_limit_parameters_mock.assert_called_once()

assert producer.current_price == 4000
await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth))
# now has buy and sell orders

# B. trailing disabled
producer.enable_trailing_up = producer.enable_trailing_down = False
assert producer._should_trigger_trailing([]) is False
open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders()
buy_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY]
sell_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL]
assert len(buy_orders) > 10
assert len(sell_orders) > 10
assert producer._should_trigger_trailing(buy_orders) is False
assert producer._should_trigger_trailing(sell_orders) is False

# C. trailing enabled
producer.enable_trailing_up = True
assert producer._should_trigger_trailing(buy_orders) is True
assert producer._should_trigger_trailing(sell_orders) is False
producer.enable_trailing_down = True
assert producer._should_trigger_trailing(buy_orders) is True
assert producer._should_trigger_trailing(sell_orders) is True

# D. no trailing if at least 1 order on each side
assert producer._should_trigger_trailing(buy_orders + sell_orders) is False
assert producer._should_trigger_trailing([buy_orders[0]] + sell_orders) is False

# E. use open orders
assert producer._should_trigger_trailing([]) is False


async def test_order_notification_callback():
symbol = "BTC/USD"
async with _get_tools(symbol) as tools:
producer, _, exchange_manager = tools

# create orders
trading_api.force_set_mark_price(exchange_manager, symbol, 4000)
with mock.patch.object(producer, "_ensure_current_price_in_limit_parameters", mock.Mock()) \
as _ensure_current_price_in_limit_parameters_mock:
await producer._ensure_staggered_orders()
_ensure_current_price_in_limit_parameters_mock.assert_called_once()
await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth))

# cancel buy orders and change reference price to 2000: should trigger trailing
open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders()
buy_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.BUY]
sell_orders = [order for order in open_orders if order.side == trading_enums.TradeOrderSide.SELL]
for order in buy_orders:
await exchange_manager.trader.cancel_order(order)
trading_api.force_set_mark_price(exchange_manager, symbol, 2000)
filled_order = sell_orders[0]
producer.enable_trailing_up = producer.enable_trailing_down = False
with mock.patch.object(producer, "_lock_portfolio_and_create_order_when_possible", mock.AsyncMock()) as _lock_portfolio_and_create_order_when_possible:
await _fill_order(filled_order, exchange_manager, trigger_update_callback=True)
_lock_portfolio_and_create_order_when_possible.assert_called_once()
assert len(exchange_manager.exchange_personal_data.orders_manager.get_open_orders()) == len(sell_orders) - 1

# will trail
filled_order = sell_orders[1]
producer.enable_trailing_up = producer.enable_trailing_down = True
with mock.patch.object(producer, "_lock_portfolio_and_create_order_when_possible", mock.AsyncMock()) as _lock_portfolio_and_create_order_when_possible:
await _fill_order(filled_order, exchange_manager, trigger_update_callback=True)
# trailing trigger
await asyncio.create_task(_check_open_orders_count(exchange_manager, producer.operational_depth))
updated_open_orders = exchange_manager.exchange_personal_data.orders_manager.get_open_orders()
buy_orders = [order for order in updated_open_orders if order.side == trading_enums.TradeOrderSide.BUY]
sell_orders = [order for order in updated_open_orders if order.side == trading_enums.TradeOrderSide.SELL]
assert len(buy_orders) == 13 # can't create more due to min price
assert len(sell_orders) == 37 # 50 - 13
# trailed instead
_lock_portfolio_and_create_order_when_possible.assert_not_called()

filled_order = buy_orders[0]
with mock.patch.object(producer, "_lock_portfolio_and_create_order_when_possible", mock.AsyncMock()) as _lock_portfolio_and_create_order_when_possible:
await _fill_order(filled_order, exchange_manager, trigger_update_callback=True)
# do not trail again, create mirror order instead
_lock_portfolio_and_create_order_when_possible.assert_called_once()


async def _wait_for_orders_creation(orders_count=1):
for _ in range(orders_count):
await asyncio_tools.wait_asyncio_next_cycle()
Expand Down

0 comments on commit afd7036

Please sign in to comment.