From 7adc9feafa51aa3fc623ffdab4af6e8feaebdbed Mon Sep 17 00:00:00 2001 From: carkod Date: Sat, 24 Oct 2026 20:24:14 +0100 Subject: [PATCH 1/7] Fix debugger streaming worker --- .vscode/launch.json | 3 ++- compose.yml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 3ffa4fd9a..0361fcc8e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -31,7 +31,8 @@ "name": "Python: streaming", "type": "debugpy", "request": "launch", - "program": "${workspaceFolder}/api/market_updates.py", + "module": "streaming.main", + "cwd": "${workspaceFolder}", "console": "internalConsole", "envFile": "${workspaceFolder}/.env", "env": { diff --git a/compose.yml b/compose.yml index f1c895e02..0a908720a 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ networks: services: api_db: - image: postgres + image: postgres:latest container_name: binbot_api_db restart: on-failure networks: From 35c7b975ef74c23ac8251b12d7f04b102fff7b82 Mon Sep 17 00:00:00 2001 From: carkod Date: Sun, 25 Oct 2026 23:48:27 +0000 Subject: [PATCH 2/7] Extend price crossings exit so it doesn't close too soon --- api/exchange_apis/kucoin/futures/futures_deal.py | 1 + streaming/lifecycle.py | 3 +++ streaming/strategies/base.py | 1 + streaming/strategies/top_gainer_short.py | 1 + 4 files changed, 6 insertions(+) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index 15be294ae..6825671cb 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -2680,6 +2680,7 @@ def _close_with_market_fallback( depth); whatever doesn't fill immediately is guaranteed closed with a market order for the residual qty, so this always fully closes. + Market-only exits skip the IOC leg and use the same fill confirmation. Returns every leg that actually filled as (order, filled_qty, avg_price) — both legs when the IOC only partially fills — so the caller can record every fill and compute a true weighted-average diff --git a/streaming/lifecycle.py b/streaming/lifecycle.py index 56ce65243..46d178dac 100644 --- a/streaming/lifecycle.py +++ b/streaming/lifecycle.py @@ -471,6 +471,9 @@ def exit(self, close_price: float) -> BotModel: """ Exit logic for futures positions. """ + strategy_exit_before_sizing = self.context_evaluator.resolve( + self.execution.active_bot.name + ).policy.strategy_exit_before_sizing if ( self.execution.active_bot.deal.closing_qty > 0 and self.execution.active_bot.deal.current_position_qty == 0 diff --git a/streaming/strategies/base.py b/streaming/strategies/base.py index 3b9026ff5..97d17ec8d 100644 --- a/streaming/strategies/base.py +++ b/streaming/strategies/base.py @@ -21,6 +21,7 @@ class LifecyclePolicy: block_reversal_after_loss: bool = False recovery_enabled: bool = True stale_position_close_enabled: bool = True + strategy_exit_before_sizing: bool = False class LifecycleExitKind(str, Enum): diff --git a/streaming/strategies/top_gainer_short.py b/streaming/strategies/top_gainer_short.py index ba107e804..3e44d75ef 100644 --- a/streaming/strategies/top_gainer_short.py +++ b/streaming/strategies/top_gainer_short.py @@ -33,6 +33,7 @@ class TopGainerShortLifecycleStrategy(DefaultLifecycleStrategy): policy = LifecyclePolicy( low_price_stop_floor_pct=None, stale_position_close_enabled=False, + strategy_exit_before_sizing=True, ) def signal(self, context: LifecycleContext) -> LifecycleSignal: From 39f5a9c23e90d57d7c28ce151895e7d1906ec95d Mon Sep 17 00:00:00 2001 From: carkod Date: Mon, 26 Oct 2026 01:20:00 +0000 Subject: [PATCH 3/7] Simplify entry gate --- .../kucoin/futures/futures_deal.py | 347 +++------------- .../test_kucoin_futures_contract_sizing.py | 389 ++++-------------- api/tests/test_streaming_controller.py | 2 +- streaming/futures_position.py | 14 +- streaming/lifecycle.py | 15 + 5 files changed, 159 insertions(+), 608 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index 6825671cb..02be435b6 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -24,7 +24,6 @@ round_numbers, round_numbers_ceiling, round_timestamp, - supress_notation, ) from api.databases.crud.bot_crud import BotTableCrud @@ -35,12 +34,10 @@ from api.exchange_apis.kucoin.deals.base import KucoinBaseBalance from api.exchange_apis.kucoin.futures.balance import KucoinFuturesBalance from api.exchange_apis.kucoin.futures.liquidity import ( - calculate_liquidity_snapshot, ceil_price_to_tick, floor_price_to_tick, load_futures_order_book, ) -from api.exchange_apis.kucoin.futures.models import LiquiditySnapshot from api.tools.constants import ( RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO, RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES, @@ -99,16 +96,13 @@ class KucoinPositionDeal(KucoinBaseBalance): TOP_GAINER_PULLBACK_MAX_DISPLACEMENT_PCT = 1.0 TOP_GAINER_ENTRY_MIN_MICRO_REGIME_STRENGTH = 0.52 TOP_GAINER_ENTRY_MAX_UPPER_WICK_FRACTION = 0.30 - ENTRY_LIQUIDITY_PRICE_BAND_BPS = 50.0 - # Keep size-aware execution costs small relative to the configured stop. - # This also bounds the spread: crossing the best quote costs half the - # spread from midpoint, and walking the book can only increase that cost. - ENTRY_LIQUIDITY_MAX_SLIPPAGE_BPS = 15.0 + ENTRY_BOOK_MAX_ATTEMPTS = 3 + ENTRY_BOOK_RETRY_SECONDS = 1.0 # Thin books can go many seconds between quote updates without being # genuinely stale (KuCoin's ts reflects the last book change, not "now"), # confirmed against live low-cap futures books (~15s observed on a quiet # book). 20s comfortably clears that while still catching a hung fetch. - ENTRY_LIQUIDITY_MAX_DATA_AGE_MS = 20_000 + ENTRY_BOOK_MAX_DATA_AGE_MS = 20_000 ENTRY_DISPLACEMENT_RETRY_MARKER = "Entry displacement retry 1/1:" # How long to wait after the first (limit) close leg before checking its # fill status — mirrors pybinbot's _EXIT_ESCALATION_SLEEP_S for the same @@ -149,8 +143,8 @@ def __init__( ) self.price_precision = self.symbol_info.price_precision # Set by body_capped_entry_limit_price(); reused by - # liquidity_gated_contracts() so the live executable price must remain - # inside the same candle-reference band without a second klines fetch. + # prepare_entry_limit_price() keeps the submitted limit inside the + # same candle-reference band without a second klines fetch. self._entry_reference_price: float | None = None self._entry_allowance_pct: float | None = None self._top_gainer_volatility_discount_pct: float | None = None @@ -767,79 +761,34 @@ def max_contracts_for_margin(self, available_balance: float, price: float) -> in return int(contracts) - @staticmethod - def liquidity_snapshot_summary( - snapshot: LiquiditySnapshot, - approved_contracts: int, - ) -> str: - expected_average = ( - f"{snapshot.expected_average_fill_price:.8g}" - if snapshot.expected_average_fill_price is not None - else "unavailable" - ) - expected_slippage = ( - f"{snapshot.expected_slippage_bps:.2f}" - if snapshot.expected_slippage_bps is not None - else "unavailable" - ) - imbalance = ( - f"{snapshot.book_imbalance:.3f}" - if snapshot.book_imbalance is not None - else "unavailable" - ) - return ( - f"spread={snapshot.spread_bps:.2f}bps, " - f"depth10(bid/ask)={snapshot.bid_depth_10_bps:g}/{snapshot.ask_depth_10_bps:g}, " - f"depth25(bid/ask)={snapshot.bid_depth_25_bps:g}/{snapshot.ask_depth_25_bps:g}, " - f"depth50(bid/ask)={snapshot.bid_depth_50_bps:g}/{snapshot.ask_depth_50_bps:g}, " - f"fillable={snapshot.contracts_fillable:g} contracts within " - f"{snapshot.permitted_price_band_bps:g}bps, " - f"requested={snapshot.requested_contracts:g}, approved={approved_contracts}, " - f"expected_average_fill={expected_average}, " - f"expected_slippage={expected_slippage}bps, " - f"imbalance={imbalance}, data_age={snapshot.data_age_ms}ms" - ) - - def liquidity_gated_contracts( - self, requested_contracts: int, candidate_limit_price: float - ) -> tuple[int, float]: + def prepare_entry_limit_price(self, candidate_limit_price: float) -> float: + """Prepare a bounded limit price; the entry lifecycle owns fill decisions.""" self._entry_post_only = False - side = ( - AddOrderReq.SideEnum.SELL - if self.active_bot.position == Position.short - else AddOrderReq.SideEnum.BUY - ) - slippage_threshold_bps = self.ENTRY_LIQUIDITY_MAX_SLIPPAGE_BPS - try: - order_book = load_futures_order_book( - self.kucoin_futures_api, - self.kucoin_symbol, - ) - requested_snapshot = calculate_liquidity_snapshot( - order_book, - side, - requested_contracts, - self.ENTRY_LIQUIDITY_PRICE_BAND_BPS, - ) - except Exception as error: - message = ( - "Entry rejected: unable to load a reliable KuCoin futures " - f"order-book liquidity snapshot ({error})." - ) - self.reject_entry_for_liquidity(message, cause=error) - - summary = self.liquidity_snapshot_summary( - requested_snapshot, - requested_contracts, - ) - - if requested_snapshot.data_age_ms > self.ENTRY_LIQUIDITY_MAX_DATA_AGE_MS: - message = ( - "Entry rejected: KuCoin futures order book is stale; " - f"maximum age is {self.ENTRY_LIQUIDITY_MAX_DATA_AGE_MS}ms. {summary}." - ) - self.reject_entry_for_liquidity(message) + for attempt in range(self.ENTRY_BOOK_MAX_ATTEMPTS): + try: + order_book = load_futures_order_book( + self.kucoin_futures_api, self.kucoin_symbol + ) + if order_book.data_age_ms > self.ENTRY_BOOK_MAX_DATA_AGE_MS: + raise ValueError( + f"order book is stale ({order_book.data_age_ms}ms)" + ) + break + except Exception as error: + if attempt + 1 == self.ENTRY_BOOK_MAX_ATTEMPTS: + self.reject_entry_for_liquidity( + f"Entry rejected: unable to load a reliable KuCoin futures order book after {self.ENTRY_BOOK_MAX_ATTEMPTS} attempts ({error}).", + cause=error, + ) + self.active_bot.add_log( + f"Entry order-book retry {attempt + 1}/{self.ENTRY_BOOK_MAX_ATTEMPTS - 1}: {error}" + ) + sleep(self.ENTRY_BOOK_RETRY_SECONDS) + best_bid = order_book.bids[0].price + best_ask = order_book.asks[0].price + midpoint = (best_bid + best_ask) / 2 + spread_bps = (best_ask - best_bid) / midpoint * 10000 if self.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: volatility_discount_pct = self._top_gainer_volatility_discount_pct if volatility_discount_pct is None or self._entry_reference_price is None: @@ -859,9 +808,7 @@ def liquidity_gated_contracts( * 100 ) spread_distance_pct = ( - requested_snapshot.spread_bps - * self.TOP_GAINER_RETEST_SPREAD_MULTIPLIER - / 100 + spread_bps * self.TOP_GAINER_RETEST_SPREAD_MULTIPLIER / 100 ) discount_pct = min( max( @@ -924,25 +871,10 @@ def liquidity_gated_contracts( f"limit={candidate_limit_price}." ) - if self.active_bot.name == TOP_GAINER_FAILURE_REVERSAL_ALGO: - if side != AddOrderReq.SideEnum.SELL: - self.reject_entry_for_liquidity( - "Entry rejected: top-gainer failure reversal must open short." - ) - candidate_limit_price = ( - requested_snapshot.worst_fill_price or requested_snapshot.best_bid - ) - self.active_bot.add_log( - "Top-gainer failure-reversal entry routed as a marketable limit: " - f"live_bid={requested_snapshot.best_bid}, " - f"requested_depth_limit={candidate_limit_price}." - ) - if self._entry_reference_price is None or self._entry_allowance_pct is None: self.reject_entry_for_liquidity( "Entry rejected: candle-reference displacement band is unavailable." ) - lower_reference_bound = float( round_numbers( self._entry_reference_price * (1 - self._entry_allowance_pct / 100), @@ -955,11 +887,17 @@ def liquidity_gated_contracts( self.price_precision, ) ) - if self._is_sustained_top_gainer_signal(): - sustained_best_bid = floor_price_to_tick( - requested_snapshot.best_bid, - tick_size, + + if self.active_bot.name == TOP_GAINER_FAILURE_REVERSAL_ALGO: + if self.active_bot.position != Position.short: + self.reject_entry_for_liquidity( + "Entry rejected: top-gainer failure reversal must open short." + ) + candidate_limit_price = min( + upper_reference_bound, max(lower_reference_bound, best_bid) ) + elif self._is_sustained_top_gainer_signal(): + sustained_best_bid = floor_price_to_tick(best_bid, tick_size) if ( sustained_best_bid > candidate_limit_price and lower_reference_bound <= sustained_best_bid <= upper_reference_bound @@ -967,167 +905,32 @@ def liquidity_gated_contracts( candidate_limit_price = sustained_best_bid self._entry_post_only = True self.active_bot.add_log( - "Sustained top-gainer maker entry joined the current best bid: " - f"best_bid={sustained_best_bid}, permitted_band=" - f"{lower_reference_bound}-{upper_reference_bound}." - ) - - # Current-book VWAP describes an order that crosses now. A resting limit - # can only fill at its candidate price or better. - marketable = ( - candidate_limit_price >= requested_snapshot.best_ask - if side == AddOrderReq.SideEnum.BUY - else candidate_limit_price <= requested_snapshot.best_bid - ) - if not marketable: - quantized_candidate_limit_price = float( - supress_notation(candidate_limit_price, self.price_precision) - ) - if not self._entry_price_is_within_displacement_band( - quantized_candidate_limit_price, - lower_reference_bound, - upper_reference_bound, - ): - displacement_pct = ( - candidate_limit_price / self._entry_reference_price - 1 - ) * 100 - displacement_direction = "above" if displacement_pct > 0 else "below" - message = ( - "Entry rejected: passive KuCoin futures limit price " - f"{candidate_limit_price:.8g} is {abs(displacement_pct):.2f}% " - f"{displacement_direction} candle reference " - f"{self._entry_reference_price:.8g}; maximum allowed " - f"displacement is {self._entry_allowance_pct:.2f}%. {summary}." + f"Sustained top-gainer maker entry joined the current best bid: best_bid={sustained_best_bid}, permitted_band={lower_reference_bound}-{upper_reference_bound}." ) - self.reject_entry_for_displacement(message) - - self.active_bot.add_log( - f"Futures passive entry liquidity snapshot: {summary}. " - f"Candidate limit {candidate_limit_price:.8g} is not marketable; " - "current expected-fill slippage and displacement checks do not apply." - ) - return requested_contracts, candidate_limit_price - - lot_size = float(self.kucoin_symbol_data.lot_size or 1) - fillable_lots = int(requested_snapshot.contracts_fillable // lot_size) - fillable_contracts = int(fillable_lots * lot_size) - approved_contracts = min(requested_contracts, fillable_contracts) - summary = self.liquidity_snapshot_summary( - requested_snapshot, - approved_contracts, - ) - - if approved_contracts <= 0: - message = ( - "Entry rejected: no complete contract lot is executable within " - f"the permitted {self.ENTRY_LIQUIDITY_PRICE_BAND_BPS:g}bps band. " - f"{summary}." - ) - self.reject_entry_for_liquidity(message) - approved_snapshot = calculate_liquidity_snapshot( - order_book, - side, - approved_contracts, - self.ENTRY_LIQUIDITY_PRICE_BAND_BPS, - ) - summary = self.liquidity_snapshot_summary( - approved_snapshot, - approved_contracts, - ) - if ( - approved_snapshot.expected_slippage_bps is None - or approved_snapshot.expected_slippage_bps > slippage_threshold_bps - ): - message = ( - "Entry rejected: expected KuCoin futures slippage exceeds " - f"{slippage_threshold_bps:.2f}bps. {summary}." + try: + tick_size = float(self.kucoin_symbol_data.tick_size) + candidate_limit_price = ( + ceil_price_to_tick(candidate_limit_price, tick_size) + if self.active_bot.position == Position.short + else floor_price_to_tick(candidate_limit_price, tick_size) ) - self.reject_entry_for_liquidity(message) - - expected_fill_price = approved_snapshot.expected_average_fill_price - if expected_fill_price is None: + except (TypeError, ValueError) as error: self.reject_entry_for_liquidity( - "Entry rejected: expected KuCoin futures fill price is unavailable. " - f"{summary}." + f"Entry rejected: invalid futures limit price or tick size ({error}).", + cause=error, ) - quantized_expected_fill_price = float( - supress_notation(expected_fill_price, self.price_precision) - ) - displacement_pct = (expected_fill_price / self._entry_reference_price - 1) * 100 if not self._entry_price_is_within_displacement_band( - quantized_expected_fill_price, - lower_reference_bound, - upper_reference_bound, + candidate_limit_price, lower_reference_bound, upper_reference_bound ): - displacement_direction = "above" if displacement_pct > 0 else "below" - message = ( - "Entry rejected: expected KuCoin futures fill price " - f"{expected_fill_price:.8g} is {abs(displacement_pct):.2f}% " - f"{displacement_direction} candle reference " - f"{self._entry_reference_price:.8g}; maximum allowed displacement " - f"is {self._entry_allowance_pct:.2f}%. {summary}." + self.reject_entry_for_displacement( + f"Entry rejected: KuCoin futures limit price {candidate_limit_price:.8g} is outside the candle-reference band {lower_reference_bound:.8g}-{upper_reference_bound:.8g}." ) - self.reject_entry_for_displacement(message) - - entry_limit_price = candidate_limit_price - if ( - self.active_bot.name == TOP_GAINER_FAILURE_REVERSAL_ALGO - and approved_snapshot.worst_fill_price is not None - ): - entry_limit_price = approved_snapshot.worst_fill_price - quantized_entry_limit_price = float( - supress_notation(entry_limit_price, self.price_precision) - ) - if not self._entry_price_is_within_displacement_band( - quantized_entry_limit_price, - lower_reference_bound, - upper_reference_bound, - ): - displacement_pct = ( - entry_limit_price / self._entry_reference_price - 1 - ) * 100 - displacement_direction = "above" if displacement_pct > 0 else "below" - message = ( - "Entry rejected: marketable top-gainer failure-reversal limit " - f"{entry_limit_price:.8g} is {abs(displacement_pct):.2f}% " - f"{displacement_direction} candle reference " - f"{self._entry_reference_price:.8g}; maximum allowed " - f"displacement is {self._entry_allowance_pct:.2f}%. {summary}." - ) - self.reject_entry_for_displacement(message) - elif approved_snapshot.worst_fill_price is not None: - if side == AddOrderReq.SideEnum.BUY: - entry_limit_price = min( - candidate_limit_price, approved_snapshot.worst_fill_price - ) - else: - entry_limit_price = max( - candidate_limit_price, approved_snapshot.worst_fill_price - ) - # Both candidates are already exchange-valid prices: the candle-based - # limit was quantized when it was built, and worst_fill_price came - # directly from the live book. Flooring the selected book price again - # can move a BUY below the ask level whose depth we just approved. - self.active_bot.add_log( - f"Futures entry liquidity snapshot: {summary}. " - f"threshold: slippage<={slippage_threshold_bps:.2f}bps." + f"Futures entry limit prepared at {candidate_limit_price:.8g}; best bid/ask={best_bid:.8g}/{best_ask:.8g}. Fill confirmation is deferred to lifecycle order updates." ) - if approved_contracts < requested_contracts: - self.active_bot.add_log( - f"Futures order downsized from {requested_contracts} to " - f"{approved_contracts} contracts because only that amount was " - "executable within the liquidity price band." - ) - if entry_limit_price != candidate_limit_price: - self.active_bot.add_log( - f"Entry limit price tightened from {candidate_limit_price} to " - f"{entry_limit_price} based on order-book depth " - f"(worst_fill_price={approved_snapshot.worst_fill_price})." - ) - return approved_contracts, entry_limit_price + return candidate_limit_price def reject_entry_for_liquidity( self, @@ -1805,11 +1608,9 @@ def base_order(self) -> BotModel: order = OrderModel(**order.model_dump()) self.active_bot.orders.append(order) - mark_price = self.kucoin_futures_api.get_mark_price(self.kucoin_symbol) - - # For Futures, base_order_size is contracts - # Kucoin only operates with contracts, not underlying asset (qty) - # so in Binbot we only care about that + # Keep acknowledgements nonterminal until streaming verifies the fills. + order.status = OrderStatus.NEW + order.qty = contracts self.active_bot.deal.base_order_size = contracts self.active_bot.deal.opening_timestamp = order.timestamp self.active_bot.deal.current_price = mark_price @@ -1882,38 +1683,12 @@ def base_order(self) -> BotModel: "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: - pending_entry_minutes = 5 - if self.active_bot.name == RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO: - pending_entry_minutes = ( - self.interval_ms - * RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES - // 60_000 - ) - elif self.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: - pending_entry_minutes = ( - self.interval_ms - * TOP_GAINER_EARLY_MOMENTUM_PENDING_ENTRY_CANDLES - // 60_000 - ) - elif self.active_bot.name == TOP_GAINER_FAILURE_REVERSAL_ALGO: - pending_entry_minutes = ( - TOP_GAINER_FAILURE_REVERSAL_PENDING_ENTRY_TTL_MS // 60_000 - ) - log_message = ( - f"Futures {position_label} entry limit order {order.order_id} submitted " - f"at {entry_limit_price} with {contracts} contracts. Bot is pending " - f"for up to {pending_entry_minutes} minutes awaiting fill." - ) self.controller.update_logs( bot=self.active_bot, log_message=log_message, ) - if self.active_bot.deal.opening_price == 0: - self.controller.save(self.active_bot) + self.controller.save(self.active_bot) return self.active_bot def current_position_quantity(self) -> float: diff --git a/api/tests/test_kucoin_futures_contract_sizing.py b/api/tests/test_kucoin_futures_contract_sizing.py index eb47eeb1d..8edda8510 100644 --- a/api/tests/test_kucoin_futures_contract_sizing.py +++ b/api/tests/test_kucoin_futures_contract_sizing.py @@ -53,6 +53,7 @@ def make_sizing_deal( price_precision=5, ) deal.price_precision = 5 + deal.kucoin_symbol = "TESTUSDTM" deal.kucoin_symbol_data = types.SimpleNamespace( multiplier=multiplier, taker_fee_rate=0.0006, @@ -225,270 +226,68 @@ def get_full_order_book(self, request): deal.kucoin_symbol = "TESTUSDTM" -def test_entry_liquidity_gate_downsizes_to_contracts_fillable_inside_price_band(): - deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - attach_order_book( - deal, - bids=[[99.99, 100]], - asks=[[100.01, 3], [100.6, 100]], - ) - - contracts, _ = deal.liquidity_gated_contracts(10, 100.5) - - assert contracts == 3 - assert any( - "Futures order downsized from 10 to 3 contracts" in log - for log in deal.active_bot.logs - ) - assert any("depth50(bid/ask)" in log for log in deal.active_bot.logs) - - -def test_entry_liquidity_gate_preserves_exchange_book_price_when_tightening_buy(): - deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - deal.price_precision = 2 - attach_order_book( - deal, - bids=[[99.99, 100]], - asks=[[100.005, 100]], - ) - - contracts, entry_limit_price = deal.liquidity_gated_contracts(10, 100.50) - - assert contracts == 10 - assert entry_limit_price == 100.005 - - -def test_entry_liquidity_gate_logs_spread_without_a_spread_threshold(): - deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - attach_order_book( - deal, - bids=[[99.875, 100]], - asks=[[100.125, 100]], - ) - - contracts, entry_limit_price = deal.liquidity_gated_contracts(10, 100.5) - - assert contracts == 10 - assert entry_limit_price == 100.125 - assert any( - "spread=25.00bps" in log and "threshold: slippage<=15.00bps" in log - for log in deal.active_bot.logs - ) - - -def test_entry_liquidity_gate_rejects_wide_spread_through_expected_slippage(): - deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - saved: list[BotModel] = [] - deal.controller = types.SimpleNamespace(save=lambda bot: saved.append(bot)) - attach_order_book( - deal, - bids=[[99.8, 100]], - asks=[[100.2, 100]], - ) - - with pytest.raises(BinbotErrors, match="expected KuCoin futures slippage"): - deal.liquidity_gated_contracts(10, 101.0) - - assert deal.active_bot.status == Status.error - assert saved == [deal.active_bot] - assert "spread=40.00bps" in deal.active_bot.logs[-1] - assert "expected_slippage=20.00bps" in deal.active_bot.logs[-1] - assert "exceeds 15.00bps" in deal.active_bot.logs[-1] - - -def test_entry_liquidity_gate_rejects_excessive_expected_slippage(): +@pytest.mark.parametrize( + "bids,asks", + [ + ([[99.8, 100]], [[100.2, 100]]), + ([[99.99, 1]], [[100.01, 1], [100.49, 9]]), + ([[99.99, 1]], [[102, 1]]), + ], +) +def test_limit_entry_accepts_wide_spread_and_thin_depth_without_bps_gate(bids, asks): deal = make_sizing_deal(multiplier=1) deal.active_bot.position = Position.long - attach_order_book( - deal, - bids=[[99.99, 100]], - asks=[[100.01, 1], [100.49, 9]], - ) - - with pytest.raises(BinbotErrors, match="expected KuCoin futures slippage"): - deal.liquidity_gated_contracts(10, 101.0) - - assert "expected_slippage=44.20bps" in deal.active_bot.logs[-1] - assert "exceeds 15.00bps" in deal.active_bot.logs[-1] + attach_order_book(deal, bids=bids, asks=asks) + assert deal.prepare_entry_limit_price(100.5) == 100.5 + assert deal.active_bot.status != Status.error + assert not any("slippage" in line for line in deal.active_bot.logs) -def test_passive_entry_uses_candidate_instead_of_current_expected_fill(): +@pytest.mark.parametrize( + "position,candidate", [(Position.long, 99.5), (Position.short, 100.5)] +) +def test_limit_entry_preserves_candle_bounded_price(position, candidate): deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - deal.price_precision = 2 - deal._entry_reference_price = 100.0 + deal.active_bot.position = position deal._entry_allowance_pct = 0.5 - attach_order_book( - deal, - bids=[[101.8, 100]], - asks=[[102.2, 3]], - ) + attach_order_book(deal, bids=[[97.8, 3]], asks=[[98.2, 100]]) + assert deal.prepare_entry_limit_price(candidate) == candidate - contracts, entry_limit_price = deal.liquidity_gated_contracts(10, 99.5) - - assert contracts == 10 - assert entry_limit_price == 99.5 - assert any( - "Futures passive entry liquidity snapshot" in log - and "current expected-fill slippage and displacement checks do not apply" in log - for log in deal.active_bot.logs - ) +def test_limit_entry_retries_stale_book_then_uses_fresh_snapshot(monkeypatch): + from api.exchange_apis.kucoin.futures.liquidity import load_futures_order_book -def test_passive_short_entry_uses_candidate_instead_of_current_expected_fill(): deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.short - deal.price_precision = 2 - deal._entry_reference_price = 100.0 - deal._entry_allowance_pct = 0.5 - attach_order_book( - deal, - bids=[[97.8, 3]], - asks=[[98.2, 100]], - ) - - contracts, entry_limit_price = deal.liquidity_gated_contracts(10, 100.5) - - assert contracts == 10 - assert entry_limit_price == 100.5 - assert any( - "Futures passive entry liquidity snapshot" in log - and "current expected-fill slippage and displacement checks do not apply" in log - for log in deal.active_bot.logs + attach_order_book(deal, bids=[[99.99, 100]], asks=[[100.01, 100]]) + fresh = load_futures_order_book(deal.kucoin_futures_api, deal.kucoin_symbol) + stale = fresh.model_copy( + update={"exchange_timestamp_ms": fresh.received_timestamp_ms - 21000} ) - - -def test_passive_entry_errors_after_candidate_exceeds_displacement_twice(): - deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - deal._entry_reference_price = 100.0 - deal._entry_allowance_pct = 0.5 - saved: list[BotModel] = [] - deal.controller = types.SimpleNamespace(save=lambda bot: saved.append(bot)) - attach_order_book( - deal, - bids=[[101.8, 100]], - asks=[[102.2, 100]], + loader = Mock(side_effect=[stale, TimeoutError("transient timeout"), fresh]) + sleeper = Mock() + monkeypatch.setattr( + "api.exchange_apis.kucoin.futures.futures_deal.load_futures_order_book", loader ) + monkeypatch.setattr("api.exchange_apis.kucoin.futures.futures_deal.sleep", sleeper) + assert deal.prepare_entry_limit_price(100) == 100 + assert loader.call_count == 3 + assert sleeper.call_count == 2 - with pytest.raises(BinbotErrors, match="passive KuCoin futures limit price"): - deal.liquidity_gated_contracts(10, 99.0) - - assert deal.active_bot.status == Status.pending - assert saved == [deal.active_bot] - assert "Entry displacement retry 1/1:" in deal.active_bot.logs[-1] - assert "1.00% below candle reference 100" in deal.active_bot.logs[-1] - with pytest.raises(BinbotErrors, match="passive KuCoin futures limit price"): - deal.liquidity_gated_contracts(10, 99.0) - - assert deal.active_bot.status == Status.error - assert len(saved) == 2 - assert "1.00% below candle reference 100" in deal.active_bot.logs[-1] - - -def test_passive_entry_accepts_directionally_floored_reference_boundary(): +def test_limit_entry_stops_after_three_failed_book_reads(monkeypatch): deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - deal.price_precision = 5 - deal._entry_reference_price = 0.03636 - deal._entry_allowance_pct = 0.5 - attach_order_book( - deal, - bids=[[0.03656, 100]], - asks=[[0.03658, 100]], + loader = Mock(side_effect=TimeoutError("timeout")) + monkeypatch.setattr( + "api.exchange_apis.kucoin.futures.futures_deal.load_futures_order_book", loader ) - - contracts, entry_limit_price = deal.liquidity_gated_contracts(10, 0.03617) - - assert contracts == 10 - assert entry_limit_price == 0.03617 - - -def test_entry_liquidity_gate_errors_after_long_displacement_twice(): - deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - deal.price_precision = 7 - deal._entry_reference_price = 0.0008543 - deal._entry_allowance_pct = 1.37 - saved: list[BotModel] = [] - deal.controller = types.SimpleNamespace(save=lambda bot: saved.append(bot)) - attach_order_book( - deal, - bids=[[0.0008056, 25_000]], - asks=[[0.0008059, 25_000]], + monkeypatch.setattr( + "api.exchange_apis.kucoin.futures.futures_deal.sleep", lambda _: None ) - - with pytest.raises(BinbotErrors, match="below candle reference"): - deal.liquidity_gated_contracts(92, 0.000866) - - assert deal.active_bot.status == Status.pending - assert saved == [deal.active_bot] - assert "Entry displacement retry 1/1:" in deal.active_bot.logs[-1] - assert "5.67% below candle reference 0.0008543" in deal.active_bot.logs[-1] - - with pytest.raises(BinbotErrors, match="below candle reference"): - deal.liquidity_gated_contracts(92, 0.000866) - + with pytest.raises(BinbotErrors, match="after 3 attempts"): + deal.prepare_entry_limit_price(100) + assert loader.call_count == 3 assert deal.active_bot.status == Status.error - assert len(saved) == 2 - assert "5.67% below candle reference 0.0008543" in deal.active_bot.logs[-1] - assert "maximum allowed displacement is 1.37%" in deal.active_bot.logs[-1] - - -def test_entry_liquidity_gate_retries_short_displacement_then_accepts_next_tick(): - deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.short - deal._entry_reference_price = 100.0 - deal._entry_allowance_pct = 1.0 - saved: list[BotModel] = [] - deal.controller = types.SimpleNamespace(save=lambda bot: saved.append(bot)) - attach_order_book( - deal, - bids=[[105.0, 100]], - asks=[[105.02, 100]], - ) - - with pytest.raises(BinbotErrors, match="above candle reference"): - deal.liquidity_gated_contracts(10, 99.0) - - assert deal.active_bot.status == Status.pending - assert saved == [deal.active_bot] - assert "Entry displacement retry 1/1:" in deal.active_bot.logs[-1] - assert "5.00% above candle reference 100" in deal.active_bot.logs[-1] - assert "maximum allowed displacement is 1.00%" in deal.active_bot.logs[-1] - - attach_order_book( - deal, - bids=[[99.99, 100]], - asks=[[100.01, 100]], - ) - - contracts, entry_limit_price = deal.liquidity_gated_contracts(10, 99.0) - - assert contracts == 10 - assert entry_limit_price == 99.99 - assert deal.active_bot.status == Status.pending - - -def test_entry_liquidity_gate_rejects_stale_book_data(): - deal = make_sizing_deal(multiplier=1) - deal.active_bot.position = Position.long - attach_order_book( - deal, - bids=[[99.99, 100]], - asks=[[100.01, 100]], - age_ms=21_000, - ) - - with pytest.raises(BinbotErrors, match="order book is stale"): - deal.liquidity_gated_contracts(10, 100.0) - - assert "maximum age is 20000ms" in deal.active_bot.logs[-1] + assert deal.active_bot.orders == [] def test_base_order_downsizes_when_margin_size_exceeds_available_balance(): @@ -559,18 +358,19 @@ def retrieve_order(self, order_id): ) deal.compute_available_balance = lambda: 1000 deal.body_capped_entry_limit_price = lambda: 10 - deal.liquidity_gated_contracts = lambda contracts, price: (contracts, price) + deal.prepare_entry_limit_price = lambda price: price opened_bot = KucoinPositionDeal.base_order(deal) assert deal.kucoin_futures_api.sell_calls == [{"qty": 9, "entry_limit_price": 10}] assert opened_bot.deal.base_order_size == 9 - assert opened_bot.deal.opening_qty == 9 + assert opened_bot.deal.opening_qty == 0 + assert opened_bot.status == Status.pending assert any("Futures order downsized from 15 to 9" in log for log in opened_bot.logs) assert any("underpowered_recovery" in log for log in opened_bot.logs) -def test_liquidity_downsized_entry_is_revalidated_with_required_margin(): +def test_limit_entry_keeps_margin_sized_quantity_and_rechecks_required_margin(): order = OrderBase( order_id="liquidity-sized-entry", order_type="limit", @@ -608,7 +408,7 @@ def test_liquidity_downsized_entry_is_revalidated_with_required_margin(): deal.compute_available_balance = lambda: 1_000 deal.body_capped_entry_limit_price = lambda: 10 deal.max_contracts_for_margin = lambda available_balance, price: 10 - deal.liquidity_gated_contracts = lambda contracts, price: (4, price) + deal.prepare_entry_limit_price = lambda price: price margin_checks: list[tuple[float, float]] = [] required_margin_for_contracts = deal.required_margin_for_contracts @@ -663,11 +463,11 @@ def test_sustained_top_gainer_base_order_is_submitted_post_only(): deal.compute_available_balance = lambda: 100 deal.body_capped_entry_limit_price = lambda: 10 - def maker_liquidity_gate(contracts, price): + def maker_entry_limit(price): deal._entry_post_only = True - return contracts, price + return price - deal.liquidity_gated_contracts = maker_liquidity_gate + deal.prepare_entry_limit_price = maker_entry_limit KucoinPositionDeal.base_order(deal) @@ -677,7 +477,7 @@ def maker_liquidity_gate(contracts, price): assert request["post_only"] is True -def test_base_order_cancels_partial_remainder_before_freezing_entry_quantity(): +def test_base_order_defers_partial_fill_and_cancellation_to_lifecycle(): entry_order = OrderBase( order_id="partial-entry", order_type="limit", @@ -725,7 +525,7 @@ def test_base_order_cancels_partial_remainder_before_freezing_entry_quantity(): deal.compute_available_balance = lambda: 100_000 deal.body_capped_entry_limit_price = lambda: 10 deal.max_contracts_for_margin = lambda available_balance, price: 5000 - deal.liquidity_gated_contracts = lambda contracts, price: (contracts, price) + deal.prepare_entry_limit_price = lambda price: price deal.required_margin_for_contracts = lambda contracts, price: contracts deal.notional_for_contracts = lambda contracts, price: contracts * price saved: list[BotModel] = [] @@ -736,24 +536,14 @@ def test_base_order_cancels_partial_remainder_before_freezing_entry_quantity(): opened_bot = KucoinPositionDeal.base_order(deal) - futures_api.cancel_futures_order.assert_called_once_with("partial-entry") - assert opened_bot.deal.opening_qty == 4522 - assert opened_bot.deal.current_position_qty == 4522 - assert opened_bot.deal.base_order_size == 4522 - assert opened_bot.orders[0].qty == 4522 + futures_api.retrieve_order.assert_not_called() + futures_api.cancel_futures_order.assert_not_called() assert opened_bot.status == Status.pending assert saved and saved[0].deal.entry_client_oid is not None -def test_base_order_affordability_check_uses_post_gate_price(): - """ - liquidity_gated_contracts() can move entry_limit_price away from the - pre-gate candidate (e.g. tightening a short's floor up to the book's - higher worst_fill_price). required_margin_for_contracts() must be - checked against that final price, not the stale candidate, or an - account near its balance limit could pass the check and submit - contracts it can't actually afford at entry_limit_price. - """ +def test_base_order_affordability_check_uses_final_limit_price(): + """Recheck affordability after tick rounding or strategy price adjustments.""" order = OrderBase( order_id="tightened-price-entry", order_type="limit", @@ -786,7 +576,7 @@ def test_base_order_affordability_check_uses_post_gate_price(): deal.max_contracts_for_margin = lambda available_balance, price: 10 # Simulates the gate tightening a short's price up from the affordable # candidate (10) to the book's worst_fill_price (20). - deal.liquidity_gated_contracts = lambda contracts, price: (1, 20) + deal.prepare_entry_limit_price = lambda price: 20 with pytest.raises(BinbotErrors, match="Required futures margin"): KucoinPositionDeal.base_order(deal) @@ -980,7 +770,7 @@ def retrieve_order(self, order_id): ) deal.compute_available_balance = lambda: 100 deal.body_capped_entry_limit_price = lambda: 10 - deal.liquidity_gated_contracts = lambda contracts, price: (contracts, price) + deal.prepare_entry_limit_price = lambda price: price opened_bot = KucoinPositionDeal.base_order(deal) @@ -1213,7 +1003,7 @@ def test_top_gainer_failure_reversal_anchors_prompt_entry_to_current_close( ) -def test_top_gainer_failure_reversal_crosses_approved_bid_depth(monkeypatch): +def test_top_gainer_failure_reversal_uses_bounded_best_bid_limit(monkeypatch): deal = prepare_recovery_entry_deal( monkeypatch, position=Position.short, @@ -1229,17 +1019,10 @@ def test_top_gainer_failure_reversal_crosses_approved_bid_depth(monkeypatch): asks=[[99.0, 100]], ) - contracts, entry_limit_price = deal.liquidity_gated_contracts( - 10, - candidate_limit_price, - ) + entry_limit_price = deal.prepare_entry_limit_price(candidate_limit_price) - assert contracts == 10 - assert entry_limit_price == 98.98 - assert any( - "failure-reversal entry routed as a marketable limit" in log - for log in deal.active_bot.logs - ) + assert entry_limit_price == 98.99 + assert any("Futures entry limit prepared" in log for log in deal.active_bot.logs) def test_top_gainer_early_momentum_waits_for_half_percent_retest(monkeypatch): @@ -1280,11 +1063,8 @@ def test_top_gainer_expected_fill_at_exact_retest_boundary_is_allowed(monkeypatc asks=[[99.5, 100]], ) - contracts, entry_limit_price = deal.liquidity_gated_contracts( - 10, candidate_limit_price - ) + entry_limit_price = deal.prepare_entry_limit_price(candidate_limit_price) - assert contracts == 10 assert candidate_limit_price == 99.5 assert entry_limit_price == 99.5 @@ -1326,16 +1106,10 @@ def test_top_gainer_accepts_shallow_downward_displacement_during_valid_uptrend( asks=[[99.40, 100]], ) - contracts, entry_limit_price = deal.liquidity_gated_contracts(10, 99.5) + entry_limit_price = deal.prepare_entry_limit_price(99.5) - assert contracts == 10 - assert entry_limit_price == 99.4 - assert any( - "Top-gainer shallow pullback accepted" in log - and "displacement=0.60%" in log - and "soft_limit=0.70%" in log - for log in deal.active_bot.logs - ) + assert entry_limit_price == 99.5 + assert not any("slippage" in line for line in deal.active_bot.logs) def test_top_gainer_shallow_pullback_grace_never_exceeds_one_percent(monkeypatch): @@ -1397,12 +1171,8 @@ def test_sustained_top_gainer_joins_best_bid_inside_candle_band(monkeypatch): asks=[[100.10, 100]], ) - contracts, entry_limit_price = deal.liquidity_gated_contracts( - 10, - provisional_limit_price, - ) + entry_limit_price = deal.prepare_entry_limit_price(provisional_limit_price) - assert contracts == 10 assert entry_limit_price == 99.8 assert deal._entry_post_only assert any( @@ -1429,12 +1199,8 @@ def test_top_gainer_retest_uses_spread_when_it_exceeds_atr_component(monkeypatch asks=[[100.05, 100]], ) - contracts, entry_limit_price = deal.liquidity_gated_contracts( - 10, - provisional_limit_price, - ) + entry_limit_price = deal.prepare_entry_limit_price(provisional_limit_price) - assert contracts == 10 assert provisional_limit_price == 99.9 assert entry_limit_price == 99.8 assert deal._entry_allowance_pct == pytest.approx(0.2) @@ -1465,10 +1231,7 @@ def test_top_gainer_retest_uses_exact_tick_distance(monkeypatch): asks=[[100.1, 100]], ) - _, entry_limit_price = deal.liquidity_gated_contracts( - 10, - provisional_limit_price, - ) + entry_limit_price = deal.prepare_entry_limit_price(provisional_limit_price) assert entry_limit_price == 99.5 assert deal._entry_allowance_pct == pytest.approx(0.5) @@ -1510,12 +1273,8 @@ def test_top_gainer_retest_uses_cap_safe_tick_for_low_priced_contract(monkeypatc asks=[[0.01682, 100]], ) - contracts, entry_limit_price = deal.liquidity_gated_contracts( - 10, - provisional_limit_price, - ) + entry_limit_price = deal.prepare_entry_limit_price(provisional_limit_price) - assert contracts == 10 assert provisional_limit_price == 0.01673 assert entry_limit_price == 0.01666 assert deal._entry_allowance_pct == pytest.approx(0.9512485136741997) @@ -1545,7 +1304,7 @@ def test_top_gainer_retest_rejects_when_minimum_ticks_cannot_fit_within_cap( ) with pytest.raises(BinbotErrors, match="tick-size quantization"): - deal.liquidity_gated_contracts(10, provisional_limit_price) + deal.prepare_entry_limit_price(provisional_limit_price) assert deal.active_bot.status == Status.error assert saved == [deal.active_bot] diff --git a/api/tests/test_streaming_controller.py b/api/tests/test_streaming_controller.py index a8ec47f18..214fb0256 100644 --- a/api/tests/test_streaming_controller.py +++ b/api/tests/test_streaming_controller.py @@ -979,7 +979,7 @@ def test_futures_order_updates_expires_aged_pending_base_order(self, monkeypatch FuturesPosition.order_updates(fp) - assert bot.status == Status.inactive + assert bot.status == Status.error assert canceled_futures_orders == ["455559324140359680"] assert canceled_stop_orders == [] assert deleted == [] diff --git a/streaming/futures_position.py b/streaming/futures_position.py index 9f0e23a1e..f2dd7e070 100644 --- a/streaming/futures_position.py +++ b/streaming/futures_position.py @@ -28,6 +28,7 @@ RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO, RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES, TOP_GAINER_EARLY_MOMENTUM_ALGO, + TOP_GAINER_SHORT_ALGO, TOP_GAINER_EARLY_MOMENTUM_PENDING_ENTRY_CANDLES, TOP_GAINER_FAILURE_REVERSAL_ALGO, TOP_GAINER_FAILURE_REVERSAL_PENDING_ENTRY_TTL_MS, @@ -175,7 +176,8 @@ def _activate_filled_base_order( self.execution.active_bot.deal.base_order_size = order.qty self.execution.active_bot.deal.opening_timestamp = ( self.execution.matching_exchange_fill_timestamp(order) - if self.execution.active_bot.name == RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO + if self.execution.active_bot.name + in {RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO, TOP_GAINER_SHORT_ALGO} else order.timestamp ) if log_message: @@ -322,7 +324,7 @@ def _reprice_pending_top_gainer_entry( ) return False - if order_book.data_age_ms > KucoinPositionDeal.ENTRY_LIQUIDITY_MAX_DATA_AGE_MS: + if order_book.data_age_ms > KucoinPositionDeal.ENTRY_BOOK_MAX_DATA_AGE_MS: logging.warning( "Skipping pending top-gainer entry reprice for %s because the " "order book is stale (age=%sms)", @@ -567,7 +569,7 @@ def _expire_unfilled_base_order( order.status = OrderStatus.EXPIRED order.qty = 0 self.execution.controller.update_order(order) - self.execution.active_bot.status = Status.inactive + self.execution.active_bot.status = Status.error pending_entry_minutes = self.PENDING_ENTRY_TTL_MS // 60_000 if self.execution.active_bot.name == RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO: pending_entry_minutes = ( @@ -587,7 +589,7 @@ def _expire_unfilled_base_order( ) self.execution.active_bot.add_log( f"Entry limit order {order.order_id} expired after {pending_entry_minutes} minutes without fill. " - "Order cancelled and bot set to inactive." + "Order cancelled, no position or fills remain, and bot set to error." ) self.execution.controller.save(data=self.execution.active_bot) @@ -936,10 +938,10 @@ def order_updates(self) -> BotModel: } ): self.execution.controller.update_order(order) - self.execution.active_bot.status = Status.inactive + self.execution.active_bot.status = Status.error self.execution.active_bot.add_log( f"Entry limit order {order.order_id} ended with status {status.value} before fill. " - "Bot set to inactive." + "Bot set to error." ) self.execution.controller.save(data=self.execution.active_bot) continue diff --git a/streaming/lifecycle.py b/streaming/lifecycle.py index 46d178dac..4d7d17aaa 100644 --- a/streaming/lifecycle.py +++ b/streaming/lifecycle.py @@ -8,6 +8,8 @@ BinanceKlineIntervals, BotBase, BotModel, + DealType, + OrderStatus, Candles, DealType, ExchangeId, @@ -509,6 +511,13 @@ def exit(self, close_price: float) -> BotModel: self.execution.controller.save(self.execution.active_bot) if self.execution.active_bot.status == Status.pending: + if self.execution.active_bot.deal.opening_price == 0 and any( + order.deal_type == DealType.base_order + and order.status + not in {OrderStatus.CANCELED, OrderStatus.EXPIRED, OrderStatus.REJECTED} + for order in self.execution.active_bot.orders + ): + return self.execution.active_bot self.execution.active_bot.add_log( "Pending bot detected on exit tick; calling open_deal to place base_order and activate." ) @@ -845,6 +854,12 @@ def process_tick(self) -> BotModel: self.btc_df = cls.btc_df self.bb_metrics = cls.build_bb_metrics() self.execution.active_bot = cls.order_updates() + if self.execution.active_bot.status in { + Status.error, + Status.inactive, + Status.completed, + }: + return self.execution.active_bot # Fetch position AFTER order_updates so any fill-promotion is already # reflected. Same single call as before; close_price stays mark-price. From 5f32658cadb70a2c8dfc99838a04e530617b35ac Mon Sep 17 00:00:00 2001 From: carkod Date: Mon, 26 Oct 2026 21:39:08 +0000 Subject: [PATCH 4/7] Fix merge --- .../kucoin/futures/futures_deal.py | 107 ++++-------------- .../test_kucoin_futures_contract_sizing.py | 14 ++- api/tests/test_kucoin_futures_stop_loss.py | 6 +- api/tests/test_native_recovery.py | 17 ++- .../test_streaming_lifecycle_strategies.py | 17 ++- streaming/lifecycle.py | 9 +- streaming/strategies/top_gainer_short.py | 56 +++++---- terminal/package.json | 2 +- 8 files changed, 97 insertions(+), 131 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index 02be435b6..5a9b4fddf 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -1541,16 +1541,8 @@ def base_order(self) -> BotModel: "margin exceeded available balance." ) - contracts, entry_limit_price = self.liquidity_gated_contracts( - affordability_capped_contracts, candidate_limit_price - ) - # liquidity_gated_contracts() can move entry_limit_price away from - # the pre-gate `price` (e.g. tightening a short's floor up to the - # book's worst_fill_price raises the price actually sent to - # KuCoin) — margin/notional must be checked against the price that - # will actually be submitted, not the stale candidate, or an - # account near its balance limit could pass this check and then - # submit contracts it can't actually afford at entry_limit_price. + contracts = affordability_capped_contracts + entry_limit_price = self.prepare_entry_limit_price(candidate_limit_price) required_margin = self.required_margin_for_contracts( contracts, entry_limit_price ) @@ -1613,79 +1605,31 @@ def base_order(self) -> BotModel: order.qty = contracts self.active_bot.deal.base_order_size = contracts self.active_bot.deal.opening_timestamp = order.timestamp - self.active_bot.deal.current_price = mark_price - - # 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. A confirmed fill remains pending in memory until - # open_deal() has reconciled its initial protective order. - system_order = self.kucoin_futures_api.retrieve_order(str(order.order_id)) - filled_size = float(system_order.filled_size or 0) - avg_price = float(system_order.avg_deal_price or 0) - - # A non-zero fill is not necessarily the final fill. KuCoin can return - # an intermediate snapshot for a marketable order and continue filling - # it afterwards. Freeze the entry by cancelling the remainder, then - # read the cumulative fill again before sizing any protection. - if 0 < filled_size < contracts: - self.active_bot.add_log( - f"Entry order {order.order_id} partially filled {filled_size} of " - f"{contracts} contracts; cancelling the remainder before activation." + self.active_bot.status = Status.pending + pending_entry_minutes = 5 + if self.active_bot.name == RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO: + pending_entry_minutes = ( + self.interval_ms + * RELATIVE_STRENGTH_IMPULSE_RIDER_PENDING_ENTRY_CANDLES + // 60_000 + ) + elif self.active_bot.name == TOP_GAINER_EARLY_MOMENTUM_ALGO: + pending_entry_minutes = ( + self.interval_ms + * TOP_GAINER_EARLY_MOMENTUM_PENDING_ENTRY_CANDLES + // 60_000 + ) + elif self.active_bot.name == TOP_GAINER_FAILURE_REVERSAL_ALGO: + pending_entry_minutes = ( + TOP_GAINER_FAILURE_REVERSAL_PENDING_ENTRY_TTL_MS // 60_000 ) - cancel_error: RestError | None = None - try: - self.kucoin_futures_api.cancel_futures_order(str(order.order_id)) - except RestError as exc: - # A marketable order can finish between the partial snapshot - # and cancellation. Re-read it before deciding that cancellation - # failed; a terminal cumulative fill is still safe to finalize. - cancel_error = exc - system_order = self.kucoin_futures_api.retrieve_order(str(order.order_id)) - filled_size = float(system_order.filled_size or 0) - avg_price = float(system_order.avg_deal_price or 0) - if system_order.is_active: - if cancel_error is not None: - raise cancel_error - self.active_bot.deal.current_position_qty = filled_size - filled_size = 0 - actual_filled_size, actual_avg_price = self._actual_fill(order) - if ( - not system_order.is_active - and actual_filled_size > 0 - and actual_avg_price > 0 - ): - filled_size = actual_filled_size - avg_price = actual_avg_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.deal.current_position_qty = filled_size - self.active_bot.deal.base_order_size = filled_size - if self.active_bot.name == RELATIVE_STRENGTH_IMPULSE_RIDER_ALGO: - self.active_bot.deal.opening_timestamp = ( - self.matching_exchange_fill_timestamp(order) - ) - # open_deal() publishes Status.active only after the initial - # protective order has been reconciled. - self.active_bot.status = Status.pending - else: - self.active_bot.status = Status.pending - - position_label = getattr( - self.active_bot.position, - "name", - self.active_bot.position, - ) self.controller.update_logs( bot=self.active_bot, - log_message=log_message, + log_message=( + f"Futures entry limit order {order.order_id} submitted at {entry_limit_price} " + f"with {contracts} contracts. Bot is pending for up to " + f"{pending_entry_minutes} minutes awaiting fill confirmation by lifecycle order updates." + ), ) self.controller.save(self.active_bot) @@ -2409,8 +2353,7 @@ def _actual_fill(self, order: OrderBase | None) -> tuple[float, float]: """Best-effort real (filled_qty, avg_price) for a just-placed order. place_futures_order()'s own response doesn't reliably carry fill - details (same reasoning as base_order()'s post-place retrieve_order - check) — query the order and then its fills to get the real numbers. + details, so query the order and then its fills to get the real numbers. Returns zero filled quantity when confirmation is unavailable; callers decide how to handle an unconfirmed fill. """ diff --git a/api/tests/test_kucoin_futures_contract_sizing.py b/api/tests/test_kucoin_futures_contract_sizing.py index 8edda8510..93140300e 100644 --- a/api/tests/test_kucoin_futures_contract_sizing.py +++ b/api/tests/test_kucoin_futures_contract_sizing.py @@ -330,7 +330,7 @@ def get_mark_price(self, symbol): return 10 def retrieve_order(self, order_id): - # Simulate an already-filled entry so base_order() activates immediately. + # Fill confirmation belongs to streaming after base_order() returns. return types.SimpleNamespace(filled_size="9", avg_deal_price="10") # margin_sized at 1x: 1500 / (10*10) = 15 contracts (notional 1500, margin 1500). @@ -377,9 +377,9 @@ def test_limit_entry_keeps_margin_sized_quantity_and_rechecks_required_margin(): pair="TESTUSDTM", timestamp=1775008219262, order_side="buy", - qty=4, + qty=10, price=10, - status=OrderStatus.FILLED, + status=OrderStatus.NEW, time_in_force="GTC", deal_type=DealType.base_order, ) @@ -420,12 +420,14 @@ def record_required_margin(contracts: float, price: float) -> float: opened_bot = KucoinPositionDeal.base_order(deal) - assert margin_checks == [(4, 10)] + assert margin_checks == [(10, 10)] request = futures_api.place_futures_order.call_args.kwargs - assert request["size"] == 4 + assert request["size"] == 10 assert request["price"] == 10 assert request["leverage"] == 1 - assert opened_bot.deal.base_order_size == 4 + assert opened_bot.deal.base_order_size == 10 + assert opened_bot.status == Status.pending + futures_api.retrieve_order.assert_not_called() def test_sustained_top_gainer_base_order_is_submitted_post_only(): diff --git a/api/tests/test_kucoin_futures_stop_loss.py b/api/tests/test_kucoin_futures_stop_loss.py index a2180241b..bfe7abed7 100644 --- a/api/tests/test_kucoin_futures_stop_loss.py +++ b/api/tests/test_kucoin_futures_stop_loss.py @@ -1153,7 +1153,7 @@ def test_top_gainer_short_ignores_recovery_configuration_for_native_stop( assert calls == ["cancel", "place"] -def test_top_gainer_short_with_recovery_waits_for_native_stop_confirmation(): +def test_top_gainer_short_ignores_legacy_recovery_settings_at_stop(): deal = _make_lifecycle( stop_loss=2.0, stop_loss_price=102.0, @@ -1189,8 +1189,8 @@ def reconcile_exchange_sl() -> None: Lifecycle.exit(deal, 103.0) - assert stop_calls == [] - assert reconcile_calls == [True] + assert stop_calls == [None] + assert reconcile_calls == [] def test_top_gainer_short_low_price_stop_is_not_widened(): diff --git a/api/tests/test_native_recovery.py b/api/tests/test_native_recovery.py index b55998dc6..e21b0da0b 100644 --- a/api/tests/test_native_recovery.py +++ b/api/tests/test_native_recovery.py @@ -380,7 +380,7 @@ def test_complete_native_fill_rechecks_lagging_position_before_handoff(): @pytest.mark.parametrize("name", ["terminal", "top_gainer_short"]) @pytest.mark.parametrize("fill_price", [0, 98]) -def test_persisted_full_fill_recovers_after_worker_stops_before_saving_deal( +def test_persisted_full_fill_respects_recovery_policy_after_worker_restart( create_test_tables, monkeypatch, name, fill_price ): with Session(create_test_tables) as session: @@ -476,11 +476,16 @@ def commit_order_then_stop(order): get_settings=lambda: SimpleNamespace(base_order_size=20) ), ) - child = lifecycle.exit(98) - assert child.status == Status.pending - assert child.position == Position.short - assert child.recovery_params is not None - assert child.recovery_params.source_bot_id == row.id + result = lifecycle.exit(98) + if name == "top_gainer_short": + assert result.id == row.id + assert result.status == Status.completed + assert result.position == Position.long + else: + assert result.status == Status.pending + assert result.position == Position.short + assert result.recovery_params is not None + assert result.recovery_params.source_bot_id == row.id def test_repeated_cumulative_partial_close_does_not_reduce_remaining_quantity_twice(): diff --git a/api/tests/test_streaming_lifecycle_strategies.py b/api/tests/test_streaming_lifecycle_strategies.py index 142d93a1d..ad58d1927 100644 --- a/api/tests/test_streaming_lifecycle_strategies.py +++ b/api/tests/test_streaming_lifecycle_strategies.py @@ -619,7 +619,7 @@ def test_top_gainer_short_only_applies_default_protection(monkeypatch) -> None: assert signal.parameter_update.trailing_profit <= 3.5 assert signal.parameter_update.trailing_deviation <= 2.5 assert TopGainerShortLifecycleStrategy.policy.low_price_stop_floor_pct is None - assert TopGainerShortLifecycleStrategy.policy.recovery_enabled is True + assert TopGainerShortLifecycleStrategy.policy.recovery_enabled is False def test_top_gainer_short_lifecycle_uses_15m_candles_when_default_is_5m() -> None: @@ -693,11 +693,24 @@ def _crossing_context(crossings: int) -> LifecycleContext: name="top_gainer_short", position=Position.short, now_ms=now_ms, - opening_timestamp=now_ms - 60_000, + opening_timestamp=now_ms + - TopGainerShortLifecycleStrategy.MIN_CROSSING_EXIT_HOLD_MS, ) return replace(context, df=frame) +@pytest.mark.parametrize("elapsed_ms", [None, 60_000, 6 * 60 * 60 * 1000 - 1]) +def test_top_gainer_short_crossing_exit_waits_six_elapsed_hours(elapsed_ms): + context = _crossing_context(3) + context.bot.deal.opening_timestamp = ( + 0 if elapsed_ms is None else context.now_ms - elapsed_ms + ) + + signal = TopGainerShortLifecycleStrategy().signal(context) + + assert signal.exit_intent is None + + @pytest.mark.parametrize("crossings", [0, 1, 2, 3, 4, 23]) def test_top_gainer_short_market_exits_at_three_crossings_in_rolling_six_hours( crossings, diff --git a/streaming/lifecycle.py b/streaming/lifecycle.py index 4d7d17aaa..20d862d22 100644 --- a/streaming/lifecycle.py +++ b/streaming/lifecycle.py @@ -8,10 +8,9 @@ BinanceKlineIntervals, BotBase, BotModel, - DealType, - OrderStatus, Candles, DealType, + OrderStatus, ExchangeId, KucoinApi, KucoinFutures, @@ -473,9 +472,6 @@ def exit(self, close_price: float) -> BotModel: """ Exit logic for futures positions. """ - strategy_exit_before_sizing = self.context_evaluator.resolve( - self.execution.active_bot.name - ).policy.strategy_exit_before_sizing if ( self.execution.active_bot.deal.closing_qty > 0 and self.execution.active_bot.deal.current_position_qty == 0 @@ -572,7 +568,8 @@ def exit(self, close_price: float) -> BotModel: ) exit_intent = evaluation.signal.exit_intent if ( - exit_intent is not None + evaluation.policy.strategy_exit_before_sizing + and exit_intent is not None and exit_intent.kind == LifecycleExitKind.algorithmic_close and exit_intent.market_only ): diff --git a/streaming/strategies/top_gainer_short.py b/streaming/strategies/top_gainer_short.py index 3e44d75ef..c3f6b5a98 100644 --- a/streaming/strategies/top_gainer_short.py +++ b/streaming/strategies/top_gainer_short.py @@ -1,10 +1,7 @@ from typing import ClassVar from pybinbot import BinanceKlineIntervals -from pybinbot.shared.price_crossings import ( - CHOP_LOOKBACK_HOURS, - price_crossings_six_hours, -) +from pybinbot.shared.price_crossings import price_crossings_six_hours from streaming.strategies.base import ( LifecycleContext, @@ -19,40 +16,49 @@ class TopGainerShortLifecycleStrategy(DefaultLifecycleStrategy): """Manage an open top-gainer short with standard protection. - Keep standard protection and market-close choppy positions after three - current-price crossings in the last six hours of completed candles. - Position increases belong to dynamic sizing. Recovery follows the bot's - configuration and cannot be combined with dynamic sizing. + After six hours from entry, market-close positions with at least three + current-price crossings over six hours. Standard protection stays active + throughout the holding period; entry eligibility belongs to binquant. """ algorithm_names = frozenset({"top_gainer_short"}) PRICE_CROSSING_EXIT_THRESHOLD = 3 + MIN_CROSSING_EXIT_HOLD_MS = 6 * 60 * 60 * 1000 candlestick_interval: ClassVar[BinanceKlineIntervals | None] = ( BinanceKlineIntervals.fifteen_minutes ) policy = LifecyclePolicy( low_price_stop_floor_pct=None, + recovery_enabled=False, stale_position_close_enabled=False, strategy_exit_before_sizing=True, ) def signal(self, context: LifecycleContext) -> LifecycleSignal: - crossings = price_crossings_six_hours( - context.df, - context.current_price, - now_ms=context.now_ms, - interval_ms=context.interval_ms, - ) - if crossings is not None and crossings >= self.PRICE_CROSSING_EXIT_THRESHOLD: - return LifecycleSignal( - exit_intent=LifecycleExitIntent( - kind=LifecycleExitKind.algorithmic_close, - market_only=True, - log_message=( - f"[top_gainer_short] {crossings} current-price crossings " - f"in the last {CHOP_LOOKBACK_HOURS} hours; " - "market-closing the full position." - ), - ) + opening_timestamp = context.bot.deal.opening_timestamp + if ( + opening_timestamp > 0 + and context.now_ms - opening_timestamp >= self.MIN_CROSSING_EXIT_HOLD_MS + ): + crossings = price_crossings_six_hours( + context.df, + context.current_price, + now_ms=context.now_ms, + interval_ms=context.interval_ms, ) + if ( + crossings is not None + and crossings >= self.PRICE_CROSSING_EXIT_THRESHOLD + ): + return LifecycleSignal( + exit_intent=LifecycleExitIntent( + kind=LifecycleExitKind.algorithmic_close, + market_only=True, + log_message=( + f"[top_gainer_short] {crossings} current-price crossings " + "in the last six hours after at least six hours open; " + "market-closing the full position." + ), + ) + ) return super().signal(context) diff --git a/terminal/package.json b/terminal/package.json index aa2386c30..3f6c16a5b 100644 --- a/terminal/package.json +++ b/terminal/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@reduxjs/toolkit": "^2.0.1", - "binbot-charts": "^0.15.0", + "binbot-charts": "^0.16.0", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", "moment": "^2.29.1", From 42c824730e78b836dfc22fd58238433002f8264d Mon Sep 17 00:00:00 2001 From: carkod Date: Mon, 26 Oct 2026 22:20:05 +0000 Subject: [PATCH 5/7] Update binbot-charts library --- terminal/package-lock.json | 9 +++++---- terminal/package.json | 2 +- terminal/src/app/pages/tests/BotDetail.test.tsx | 10 +++++----- terminal/src/app/pages/tests/FuturesBotDetail.test.tsx | 10 +++++----- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/terminal/package-lock.json b/terminal/package-lock.json index c645356dd..b0ff00326 100644 --- a/terminal/package-lock.json +++ b/terminal/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.0", "dependencies": { "@reduxjs/toolkit": "^2.0.1", - "binbot-charts": "^0.15.0", + "binbot-charts": "^0.16.1", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", "moment": "^2.29.1", @@ -2725,9 +2725,10 @@ "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==" }, "node_modules/binbot-charts": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/binbot-charts/-/binbot-charts-0.15.0.tgz", - "integrity": "sha512-X2JfJiDsMbHEh6RRHaV3RgkzCX0+uW679PWg9UGIH1ciqVFBfgjfvR2f95QcRIbFp8tbEVUqYU+kb/+uEDFMPg==", + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/binbot-charts/-/binbot-charts-0.16.1.tgz", + "integrity": "sha512-MiJ/8ewel1HnQhpniwvWyW5zHstY8/AvbXwXl9xhXEUC7yvlCjhR6LVx1bYNroRqLsVjzULXHymA83SZLWvMVQ==", + "license": "ISC", "dependencies": { "binbot-charts": "^0.12.12", "use-immer": "^0.10.0" diff --git a/terminal/package.json b/terminal/package.json index 3f6c16a5b..417f42e3b 100644 --- a/terminal/package.json +++ b/terminal/package.json @@ -17,7 +17,7 @@ }, "dependencies": { "@reduxjs/toolkit": "^2.0.1", - "binbot-charts": "^0.16.0", + "binbot-charts": "^0.16.1", "bootstrap": "^5.3.8", "chart.js": "^4.5.1", "moment": "^2.29.1", diff --git a/terminal/src/app/pages/tests/BotDetail.test.tsx b/terminal/src/app/pages/tests/BotDetail.test.tsx index a01d4c102..3cce756ea 100644 --- a/terminal/src/app/pages/tests/BotDetail.test.tsx +++ b/terminal/src/app/pages/tests/BotDetail.test.tsx @@ -7,27 +7,27 @@ import { vi } from "vitest"; import { render } from "@testing-library/react"; // import { vi } from "vitest"; // Commented out for clarity // Place mocks here, after imports, before any usage -vi.mock("../components/ChartContainer", () => { +vi.mock("../../components/ChartContainer", () => { const ChartContainer = (_props: any) =>
; ChartContainer.displayName = "ChartContainer"; return { default: ChartContainer }; }); -vi.mock("../components/BotInfo", () => { +vi.mock("../../components/BotInfo", () => { const BotInfo = (_props: any) =>
; BotInfo.displayName = "BotInfo"; return { default: BotInfo }; }); -vi.mock("../components/LogsInfo", () => { +vi.mock("../../components/LogsInfo", () => { const LogsInfo = (_props: any) =>
; LogsInfo.displayName = "LogsInfo"; return { default: LogsInfo }; }); -vi.mock("../components/BotDetailTabs", () => { +vi.mock("../../components/BotDetailTabs", () => { const BotDetailTabs = (_props: any) =>
; BotDetailTabs.displayName = "BotDetailTabs"; return { default: BotDetailTabs }; }); -vi.mock("../components/BalanceAnalysis", () => { +vi.mock("../../components/BalanceAnalysis", () => { const BalanceAnalysis = (_props: any) => (
); diff --git a/terminal/src/app/pages/tests/FuturesBotDetail.test.tsx b/terminal/src/app/pages/tests/FuturesBotDetail.test.tsx index 138737ebe..f49281c2b 100644 --- a/terminal/src/app/pages/tests/FuturesBotDetail.test.tsx +++ b/terminal/src/app/pages/tests/FuturesBotDetail.test.tsx @@ -72,27 +72,27 @@ vi.mock("../../../features/symbolsApiSlice", async () => { }); // Lightweight mocks for heavy child components -vi.mock("../components/ChartContainer", () => { +vi.mock("../../components/ChartContainer", () => { const ChartContainer = (_props: any) =>
; ChartContainer.displayName = "ChartContainer"; return { default: ChartContainer }; }); -vi.mock("../components/BotInfo", () => { +vi.mock("../../components/BotInfo", () => { const BotInfo = (_props: any) =>
; BotInfo.displayName = "BotInfo"; return { default: BotInfo }; }); -vi.mock("../components/LogsInfo", () => { +vi.mock("../../components/LogsInfo", () => { const LogsInfo = (_props: any) =>
; LogsInfo.displayName = "LogsInfo"; return { default: LogsInfo }; }); -vi.mock("../components/BotDetailTabs", () => { +vi.mock("../../components/BotDetailTabs", () => { const BotDetailTabs = (_props: any) =>
; BotDetailTabs.displayName = "BotDetailTabs"; return { default: BotDetailTabs }; }); -vi.mock("../components/BalanceAnalysis", () => { +vi.mock("../../components/BalanceAnalysis", () => { const BalanceAnalysis = (_props: any) => (
); From 8246602c93bc3e4629257fa2b91b1f469445c8b6 Mon Sep 17 00:00:00 2001 From: carkod Date: Mon, 26 Oct 2026 22:24:16 +0000 Subject: [PATCH 6/7] Keep bot detail tests isolated from chart widget --- .../src/app/pages/tests/BotDetail.test.tsx | 41 +++++++++++++++---- .../app/pages/tests/FuturesBotDetail.test.tsx | 1 + 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/terminal/src/app/pages/tests/BotDetail.test.tsx b/terminal/src/app/pages/tests/BotDetail.test.tsx index 3cce756ea..a32fd9cd3 100644 --- a/terminal/src/app/pages/tests/BotDetail.test.tsx +++ b/terminal/src/app/pages/tests/BotDetail.test.tsx @@ -4,9 +4,26 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; import BotDetail from "../BotDetail"; import { store } from "../../store"; import { vi } from "vitest"; -import { render } from "@testing-library/react"; -// import { vi } from "vitest"; // Commented out for clarity -// Place mocks here, after imports, before any usage +import { render, screen as rtlScreen, waitFor } from "@testing-library/react"; +import { singleBot } from "../../../features/bots/botInitialState"; + +const mockUseGetSingleBotQuery = vi.hoisted(() => vi.fn()); + +vi.mock("../../../features/bots/botsApiSlice", async () => ({ + ...(await vi.importActual("../../../features/bots/botsApiSlice")), + useGetSingleBotQuery: mockUseGetSingleBotQuery, +})); + +vi.mock("../../../features/balanceApiSlice", async () => ({ + ...(await vi.importActual("../../../features/balanceApiSlice")), + useGetBalanceQuery: vi.fn(() => ({ data: undefined, isLoading: false })), +})); + +vi.mock("../../../features/symbolsApiSlice", async () => ({ + ...(await vi.importActual("../../../features/symbolsApiSlice")), + useGetSymbolsQuery: vi.fn(() => ({ data: [] })), +})); + vi.mock("../../components/ChartContainer", () => { const ChartContainer = (_props: any) =>
; ChartContainer.displayName = "ChartContainer"; @@ -42,6 +59,13 @@ const SpinnerContext = React.createContext({ }); describe("BotDetail page", () => { + beforeEach(() => { + mockUseGetSingleBotQuery.mockReturnValue({ + data: { bot: { ...singleBot, id: "123" } }, + isLoading: false, + }); + }); + it("renders without crashing", () => { const { container } = render( @@ -57,9 +81,10 @@ describe("BotDetail page", () => { , ); expect(container.querySelector(".content")).not.toBeNull(); + expect(rtlScreen.getByTestId("chart-container")).toBeTruthy(); }); - it("shows BotInfo and BotDetailTabs when bot and id are present", () => { + it("shows BotInfo and BotDetailTabs when bot and id are present", async () => { render( { , ); - // Check for BotInfo by class or id - expect(document.querySelector(".card-header")).not.toBeNull(); - // Check for BotDetailTabs by nav-tabs class - expect(document.querySelector(".nav.nav-tabs")).not.toBeNull(); + await waitFor(() => { + expect(rtlScreen.getByTestId("bot-info")).toBeTruthy(); + }); + expect(rtlScreen.getByTestId("bot-detail-tabs")).toBeTruthy(); }); }); diff --git a/terminal/src/app/pages/tests/FuturesBotDetail.test.tsx b/terminal/src/app/pages/tests/FuturesBotDetail.test.tsx index f49281c2b..4d4f804ed 100644 --- a/terminal/src/app/pages/tests/FuturesBotDetail.test.tsx +++ b/terminal/src/app/pages/tests/FuturesBotDetail.test.tsx @@ -141,6 +141,7 @@ describe("FuturesBotDetail page", () => { ); expect(container.querySelector(".content")).not.toBeNull(); + expect(rtlScreen.getByTestId("chart-container")).toBeTruthy(); }); it("keeps existing bot data but overrides market_type to FUTURES when editing", () => { From d35ca51d7ba301546477883d07955b8ceb08485f Mon Sep 17 00:00:00 2001 From: carkod Date: Mon, 26 Oct 2026 22:27:48 +0000 Subject: [PATCH 7/7] Flatten residual exposure in order updates --- api/tests/test_dynamic_position_sizing.py | 55 +++++++++++++++++++++++ streaming/lifecycle.py | 55 +++++++++++------------ 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/api/tests/test_dynamic_position_sizing.py b/api/tests/test_dynamic_position_sizing.py index 75411b03b..c43315ee0 100644 --- a/api/tests/test_dynamic_position_sizing.py +++ b/api/tests/test_dynamic_position_sizing.py @@ -771,3 +771,58 @@ def test_native_trailing_fill_cancels_a_pending_increase_before_retiring_bot( make_lifecycle(engine, monkeypatch).exit(104) engine.close_all.assert_called_once_with(deal_type=DealType.trailing_profit) engine.kucoin_futures_api.futures_order_api.add_order.assert_not_called() + + +@pytest.mark.parametrize( + "trailing_stop_price,expected_deal_type", + [(0, DealType.stop_loss), (105, DealType.trailing_profit)], +) +def test_process_tick_cleans_pending_increase_after_native_protective_fill( + monkeypatch, trailing_stop_price, expected_deal_type +): + engine = execution() + bot = engine.active_bot + bot.deal.position_size_order = pending_order() + bot.deal.trailing_stop_loss_price = trailing_stop_price + lifecycle = make_lifecycle(engine, monkeypatch) + lifecycle.base_streaming.kucoin_futures_api = engine.kucoin_futures_api + monkeypatch.setattr( + lifecycle, + "lifecycle_candle_intervals", + lambda: ( + BinanceKlineIntervals.fifteen_minutes, + BinanceKlineIntervals.fifteen_minutes, + ), + ) + + class FilledProtectiveOrderPosition: + def __init__(self, base_streaming, execution): + self.base_streaming = base_streaming + self.execution = execution + self.df = None + self.btc_df = None + + def dataframe_ops(self, **kwargs): + return [], [] + + def build_bb_metrics(self): + return None + + def order_updates(self): + bot.deal.closing_qty = bot.deal.opening_qty + bot.deal.current_position_qty = 0 + bot.deal.closing_price = trailing_stop_price or 95 + bot.status = Status.completed + return bot + + def position_updates(self, **kwargs): + pytest.fail("completed bot must not run position updates") + + monkeypatch.setattr( + "streaming.lifecycle.FuturesPosition", FilledProtectiveOrderPosition + ) + + assert lifecycle.process_tick() is bot + engine.close_all.assert_called_once_with(deal_type=expected_deal_type) + engine.kucoin_futures_api.get_futures_position.assert_not_called() + engine.kucoin_futures_api.futures_order_api.add_order.assert_not_called() diff --git a/streaming/lifecycle.py b/streaming/lifecycle.py index 20d862d22..7dcc030be 100644 --- a/streaming/lifecycle.py +++ b/streaming/lifecycle.py @@ -851,39 +851,38 @@ def process_tick(self) -> BotModel: self.btc_df = cls.btc_df self.bb_metrics = cls.build_bb_metrics() self.execution.active_bot = cls.order_updates() - if self.execution.active_bot.status in { - Status.error, - Status.inactive, - Status.completed, - }: - return self.execution.active_bot - - # 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.execution.active_bot.market_type == MarketType.FUTURES: - position = self.base_streaming.kucoin_futures_api.get_futures_position( - self.execution.active_bot.pair - ) - close_price = self.base_streaming.kucoin_futures_api.get_mark_price( - self.execution.active_bot.pair - ) + bot = self.execution.active_bot + if bot.status in {Status.error, Status.inactive}: + return bot + if bot.status == Status.completed and bot.deal.position_size_order is None: + return bot + + if bot.status != Status.completed: + # 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 bot.market_type == MarketType.FUTURES: + position = self.base_streaming.kucoin_futures_api.get_futures_position( + bot.pair + ) + close_price = self.base_streaming.kucoin_futures_api.get_mark_price( + bot.pair + ) - if self.execution.awaiting_close_confirmation(): - return self.execution.active_bot + if self.execution.awaiting_close_confirmation(): + return bot - if not ( - self.execution.active_bot.deal.closing_qty > 0 - and self.execution.active_bot.deal.current_position_qty == 0 - ): - self.execution.active_bot = cls.position_updates(position=position) + if not (bot.deal.closing_qty > 0 and bot.deal.current_position_qty == 0): + self.execution.active_bot = cls.position_updates(position=position) - if not close_price or close_price == 0: - close_price = self.klines[-1][4] + if not close_price or close_price == 0: + close_price = self.klines[-1][4] - self.execution.active_bot.deal.current_price = close_price - self.execution.controller.save(self.execution.active_bot) + self.execution.active_bot.deal.current_price = close_price + self.execution.controller.save(self.execution.active_bot) + # A protective fill may complete the bot while an increase remains live. + # exit() cancels that order and flattens any residual before retirement. try: self.execution.active_bot = self.exit(close_price) return self.execution.active_bot