Skip to content
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
61 changes: 45 additions & 16 deletions api/exchange_apis/kucoin/futures/futures_deal.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ def estimate_reversal_possible_for_new_bot(self) -> bool:
side=side,
size=1,
)
if estimated_price is None:
return False

estimated_contracts = self.calculate_contracts(
self.active_bot.fiat_order_size, estimated_price
)
Expand Down Expand Up @@ -573,6 +576,10 @@ def base_order(self) -> BotModel:
side=AddOrderReq.SideEnum.BUY,
size=available_balance,
)
if price is None:
raise BinbotErrors(
"matching_engine returned no price for sizing calculation — order book may be empty."
)

margin_sized_contracts = self.calculate_contracts(
self.active_bot.fiat_order_size, price
Expand Down Expand Up @@ -640,20 +647,39 @@ def base_order(self) -> BotModel:
# Kucoin only operates with contracts, not underlying asset (qty)
# so in Binbot we only care about that
self.active_bot.deal.base_order_size = contracts
self.active_bot.deal.opening_price = order.price
self.active_bot.deal.opening_qty = contracts
self.active_bot.deal.opening_timestamp = order.timestamp
self.active_bot.deal.current_price = position.mark_price
self.active_bot.status = Status.active

# Check if the order has already been filled on the exchange. Futures
# market orders settle quickly but not always before this code runs —
# the position endpoint can lag by several minutes. If unfilled, leave
# opening_price == 0 and do not activate; open_deal() will set the bot
# to pending and order_updates() will promote it once KuCoin confirms
# the fill. Only set status = active here on an instant fill.
system_order = self.kucoin_futures_api.retrieve_order(str(order.order_id))
filled_size = float(system_order.filled_size)
avg_price = float(system_order.avg_deal_price)
if filled_size > 0 and avg_price > 0:
order.status = OrderStatus.FILLED
order.qty = filled_size
order.price = avg_price
self.active_bot.deal.opening_price = avg_price
self.active_bot.deal.opening_qty = filled_size
self.active_bot.status = Status.active
# else: opening_price stays 0; open_deal() will set Status.pending

position_label = getattr(
self.active_bot.position,
"name",
self.active_bot.position,
)
if self.active_bot.deal.opening_price > 0:
log_message = f"Futures {position_label} opened @ {self.active_bot.deal.opening_price} with {int(self.active_bot.deal.opening_qty)} contracts"
else:
log_message = f"Futures {position_label} order submitted @ {position.mark_price} with {contracts} contracts (awaiting fill)"
self.controller.update_logs(
bot=self.active_bot,
log_message=f"Futures {position_label} opened @ {position.mark_price} with {order.qty} contracts",
log_message=log_message,
)

self.controller.save(self.active_bot)
Expand Down Expand Up @@ -863,18 +889,21 @@ def open_deal(self) -> BotModel:
self.controller.save(self.active_bot)
self.base_order()

if (
self.active_bot.status == Status.active
or self.active_bot.deal.opening_price > 0
):
# Update bot, no activation required. This path is the user-driven
# "Update Deal" flow — disarm any active trail since the parameters
# it was computed against may have just changed.
self.active_bot.deal.trailing_stop_loss_price = 0
self.active_bot = self.update_parameters()
else:
# Activation required
self.active_bot = self.update_parameters_with_activation()
# Entry not filled yet (opening_price == 0): leave the bot pending and
# return. order_updates() will promote it to active once KuCoin confirms
# the fill by calling open_deal() again, which will reach the branch below.
if self.active_bot.deal.opening_price == 0:
self.active_bot.status = Status.pending
self.active_bot.add_log(
"Entry order is live but not yet filled; bot set to pending."
)
self.controller.save(self.active_bot)
return self.active_bot

# Entry is filled (opening_price > 0): activate / reactivate.
# Disarm any stale trail — parameters may have changed (e.g. Update Deal).
self.active_bot.deal.trailing_stop_loss_price = 0
self.active_bot = self.update_parameters()
self.active_bot.status = Status.active
self.controller.save(self.active_bot)
return self.active_bot
73 changes: 55 additions & 18 deletions api/exchange_apis/kucoin/futures/position_deal.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@
from kucoin_universal_sdk.model.common import RestError
from pybinbot import (
BotBase,
DealType,
KucoinApi,
KucoinFutures,
MarketType,
OrderBase,
OrderSide,
OrderStatus,
OrderType,
Position,
Status,
convert_to_kucoin_symbol,
round_numbers,
round_timestamp,
DealType,
Position,
)
from streaming.futures_position import FuturesPosition
from streaming.spot_position import SpotPosition
Expand Down Expand Up @@ -54,6 +54,7 @@ def __init__(
# Inherited variables for mypy
self.api: KucoinApi | KucoinFutures
self.controller: BotTableCrud | PaperTradingTableCrud
self.klines: list | None

def place_reversal_reentry_order(
self,
Expand Down Expand Up @@ -198,13 +199,17 @@ def take_profit_order(self) -> BotModel:

return self.active_bot

def execute_stop_loss(self) -> BotModel:
def execute_stop_loss(self, reference_price: float | None = None) -> BotModel:
"""
Place a stop loss limit order, since we've hit the threshold

- Hard sell (order status="FILLED" immediately) initial amount crypto in deal
- Close current opened take profit order
- Deactivate bot

When ``reference_price`` is provided the close order is routed through the
anti-wick escalation path (band-capped IOC → market fallback) so the fill
stays within a sane slippage band off the last-closed-candle price.
"""
self.controller.update_logs("Placing Futures stop loss...", self.active_bot)

Expand All @@ -214,7 +219,13 @@ def execute_stop_loss(self) -> BotModel:
if qty <= 0:
return self.active_bot

price = float(self.active_bot.deal.current_price or 0)
# Use reference_price as the simulated fill price when available so
# paper-trade results reflect the anti-wick capped behaviour.
price = float(
reference_price
if reference_price is not None
else (self.active_bot.deal.current_price or 0)
)
close_side = (
OrderSide.buy
if self.active_bot.position == Position.short
Expand All @@ -240,13 +251,15 @@ def execute_stop_loss(self) -> BotModel:
symbol=self.kucoin_symbol,
qty=qty,
reduce_only=True,
reference_price=reference_price,
)
else:
order_base = self.kucoin_futures_api.sell(
symbol=self.kucoin_symbol,
qty=qty,
reduce_only=True,
leverage=self.symbol_info.futures_leverage,
reference_price=reference_price,
)

except RestError as e:
Expand All @@ -266,8 +279,8 @@ def execute_stop_loss(self) -> BotModel:
self.active_bot.status = Status.error
return self.active_bot

order_base.deal_type = DealType.stop_loss
stop_loss_order = OrderModel.model_construct(**order_base.model_dump())
order_base.deal_type = DealType.stop_loss
stop_loss_order = OrderModel.model_construct(**order_base.model_dump())

self.active_bot.orders.append(stop_loss_order)
self.active_bot.deal.closing_price = float(stop_loss_order.price)
Expand Down Expand Up @@ -469,12 +482,17 @@ def _prior_leg_was_loss(self) -> bool:
return True
return False

def reverse_position(self) -> BotModel:
def reverse_position(self, reference_price: float | None = None) -> BotModel:
"""
Close the current position with a reduce_only order, mark source bot as
completed, then create a new opposite-direction bot in Status.pending.
The next exit() tick promotes pending -> active via open_deal(), which
places the base_order at fresh market price.

When ``reference_price`` is provided the reduce-only close leg is routed
through the anti-wick escalation path so the reversal close doesn't fill
into a wick. The new bot's re-entry (open_deal) always uses fresh market
price and is unaffected.
"""
source_bot = self.active_bot
target_position = (
Expand All @@ -500,12 +518,14 @@ def reverse_position(self) -> BotModel:
qty=current_contracts,
reduce_only=True,
leverage=self.symbol_info.futures_leverage,
reference_price=reference_price,
)
else:
close_order = self.kucoin_futures_api.buy(
symbol=self.kucoin_symbol,
qty=current_contracts,
reduce_only=True,
reference_price=reference_price,
)
except RestError as kucoin_error:
msg = kucoin_error.response.message
Expand Down Expand Up @@ -582,6 +602,18 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel:
direction = self._direction_multiplier()
position_name = self.active_bot.position.value

# ---------------------------------------------------------------------------
# Reference price for anti-wick exit execution (Phase 1).
# Anchors the slippage band to the last *closed* candle close so that
# reduce-only limit orders don't chase a transient wick below the book.
# self.klines[-1] is the in-progress candle; [-2] is last fully closed.
# ---------------------------------------------------------------------------
exit_reference_price: float | None = None
if self.klines is not None and len(self.klines) >= 2:
closed_close = float(self.klines[-2][4])
if closed_close > 0:
exit_reference_price = closed_close

# panic close low activity assets
opening_price = float(self.active_bot.deal.opening_price)
bot_profit = (
Expand Down Expand Up @@ -634,7 +666,9 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel:
f"Margin short reversal enabled, opening {self.active_bot.position.value} position after stop loss...",
self.active_bot,
)
self.active_bot = self.reverse_position()
self.active_bot = self.reverse_position(
reference_price=exit_reference_price
)
else:
if self.active_bot.margin_short_reversal:
self.controller.update_logs(
Expand All @@ -646,7 +680,7 @@ def exit(self, close_price: float, _: float | None = None) -> BotModel:
f"Executing futures {position_name} stop_loss after hitting {self.active_bot.deal.stop_loss_price}",
self.active_bot,
)
self.execute_stop_loss()
self.execute_stop_loss(reference_price=exit_reference_price)

# Trailing profit (price going down)
if self.active_bot.trailing and self.active_bot.deal.opening_price > 0:
Expand Down Expand Up @@ -797,7 +831,6 @@ def deal_exit_orchestration(
self, close_price: float, open_price: float
) -> BotModel:
cls: Union[SpotPosition, FuturesPosition]
prefetched_position = None
if self.active_bot.market_type == MarketType.FUTURES:
cls = FuturesPosition(
base_streaming=self.base_streaming,
Expand All @@ -808,13 +841,6 @@ def deal_exit_orchestration(
)
cls.base_streaming.kucoin_benchmark_symbol = "XBTUSDTM"
self.api = self.base_streaming.kucoin_futures_api
prefetched_position = (
self.base_streaming.kucoin_futures_api.get_futures_position(
self.active_bot.pair
)
)
if prefetched_position is not None:
close_price = prefetched_position.mark_price
else:
cls = SpotPosition(
base_streaming=self.base_streaming,
Expand All @@ -836,7 +862,18 @@ def deal_exit_orchestration(

self.active_bot = cls.order_updates()
cls.active_bot = self.active_bot
self.active_bot = cls.position_updates(position=prefetched_position)

# Fetch position AFTER order_updates so any fill-promotion is already
# reflected. Same single call as before; close_price stays mark-price.
position = None
if self.active_bot.market_type == MarketType.FUTURES:
position = self.base_streaming.kucoin_futures_api.get_futures_position(
self.active_bot.pair
)
if position is not None:
close_price = position.mark_price

self.active_bot = cls.position_updates(position=position)
cls.active_bot = self.active_bot

open_price = float(self.klines[-1][1])
Expand Down
22 changes: 22 additions & 0 deletions api/exchange_apis/kucoin/futures/position_market.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from copy import deepcopy
from time import time
from typing import Type, Union
from bots.models import BotModel
from databases.tables.bot_table import BotTable, PaperTradingTable
Expand All @@ -16,6 +17,7 @@
KucoinFutures,
MarketType,
Position,
Status,
convert_to_kucoin_symbol,
round_numbers,
)
Expand Down Expand Up @@ -293,6 +295,26 @@ def position_updates(
)
self.controller.save(data=self.active_bot)
else:
# Only backfill for active bots — pending/inactive/completed bots
# have no live position to reconcile and must never be marked error
# here (e.g. an expired→inactive bot still has base_order_size > 0).
if self.active_bot.status != Status.active:
return self.active_bot
# Grace window: the position endpoint lags the order fill by up to
# one candle interval. Skipping backfill during this window prevents
# a false error on the same tick the entry fills.
now_ms = int(time() * 1000)
grace_ms = self.base_streaming.interval.get_ms()
if (
self.active_bot.deal.opening_timestamp > 0
and (now_ms - self.active_bot.deal.opening_timestamp) < grace_ms
Comment thread
carkod marked this conversation as resolved.
):
self.active_bot.add_log(
"Position not yet propagated to exchange endpoint; "
"within entry grace window. Skipping backfill."
)
self.controller.save(data=self.active_bot)
return self.active_bot
self.active_bot = self.backfill_position_from_fills()
self.controller.save(data=self.active_bot)

Expand Down
2 changes: 1 addition & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ dependencies = [
"alembic>=1.18.4",
"alembic-postgresql-enum",
"pydantic-settings>=2.10.1",
"pybinbot>=1.9.23",
"pybinbot>=1.9.25",
]

[project.urls]
Expand Down
13 changes: 9 additions & 4 deletions api/streaming/futures_position.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,17 @@ def order_updates(self) -> BotModel:
if (
order.deal_type == DealType.base_order
and self.active_bot.deal.opening_price == 0
and float(system_order.price) > 0
and (status == OrderStatus.FILLED or filled_size > 0)
):
self.active_bot.deal.opening_price = order.price
self.active_bot.deal.opening_qty = order.qty
# Entry fill confirmed: stamp deal fields then activate
# via open_deal() so SL/TP are armed on the same path
# as an instant-fill base order.
self.active_bot.deal.opening_price = (
order.price
) # avg_deal_price
self.active_bot.deal.opening_qty = order.qty # filled_size
self.active_bot.deal.opening_timestamp = order.timestamp
self.active_bot.status = Status.active
self.active_bot = self.open_deal()

if (
(
Expand Down
Loading
Loading