From 45f4b7caa06fc450b20be73d000ba821596ad854 Mon Sep 17 00:00:00 2001 From: Carlos Wu Date: Sun, 31 May 2026 01:03:43 +0100 Subject: [PATCH 1/3] Fix missing base order position by replacing it with pending order and let lifecycle pick it up --- .../kucoin/futures/futures_deal.py | 54 +++++++++++++------ .../kucoin/futures/position_market.py | 22 ++++++++ api/streaming/futures_position.py | 13 +++-- .../test_kucoin_futures_contract_sizing.py | 4 ++ 4 files changed, 73 insertions(+), 20 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index d9526682f..a02b12a6b 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -640,20 +640,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) @@ -863,18 +882,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 diff --git a/api/exchange_apis/kucoin/futures/position_market.py b/api/exchange_apis/kucoin/futures/position_market.py index 8def6e24a..bfc41c562 100644 --- a/api/exchange_apis/kucoin/futures/position_market.py +++ b/api/exchange_apis/kucoin/futures/position_market.py @@ -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 @@ -16,6 +17,7 @@ KucoinFutures, MarketType, Position, + Status, convert_to_kucoin_symbol, round_numbers, ) @@ -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 + ): + 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) diff --git a/api/streaming/futures_position.py b/api/streaming/futures_position.py index db5c1546d..a5ea957e5 100644 --- a/api/streaming/futures_position.py +++ b/api/streaming/futures_position.py @@ -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 ( ( diff --git a/api/tests/test_kucoin_futures_contract_sizing.py b/api/tests/test_kucoin_futures_contract_sizing.py index 1f0722c61..66d87fe66 100644 --- a/api/tests/test_kucoin_futures_contract_sizing.py +++ b/api/tests/test_kucoin_futures_contract_sizing.py @@ -196,6 +196,10 @@ def sell(self, symbol, qty, leverage): def get_futures_position(self, symbol): return types.SimpleNamespace(mark_price=10) + def retrieve_order(self, order_id): + # Simulate an already-filled entry so base_order() activates immediately. + return types.SimpleNamespace(filled_size="9", avg_deal_price="10") + # margin_sized at 1x: 1500 / (10*10) = 15 contracts (notional 1500, margin 1500). # affordable: per-contract margin (100*1 + 2*100*0.0006) = 100.12 → floor(1000/100.12) = 9. # min(15, 9) = 9, downsized from 15 to 9. From 210ef3d4b4c1bee89fdc0be6eabab737ee1a4fe0 Mon Sep 17 00:00:00 2001 From: Carlos Wu Date: Sun, 31 May 2026 02:57:23 +0100 Subject: [PATCH 2/3] Revert prefetch to get fresh orders --- .../kucoin/futures/position_deal.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/position_deal.py b/api/exchange_apis/kucoin/futures/position_deal.py index b922c53c0..89ca74f6d 100644 --- a/api/exchange_apis/kucoin/futures/position_deal.py +++ b/api/exchange_apis/kucoin/futures/position_deal.py @@ -797,7 +797,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, @@ -808,13 +807,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, @@ -836,7 +828,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]) From 38647a2b116c9e02d6c8a0b3598cfe7599fd0e54 Mon Sep 17 00:00:00 2001 From: Carlos Wu Date: Sun, 31 May 2026 21:30:25 +0100 Subject: [PATCH 3/3] Band-capped Immeidate or Cancel (IOC) order execution --- .../kucoin/futures/futures_deal.py | 7 + .../kucoin/futures/position_deal.py | 52 +++- api/pyproject.toml | 2 +- api/tests/test_anti_wick_exit.py | 249 ++++++++++++++++++ .../test_futures_reversal_integration.py | 6 +- api/tests/test_kucoin_futures_stop_loss.py | 2 + api/uv.lock | 98 +++---- 7 files changed, 354 insertions(+), 62 deletions(-) create mode 100644 api/tests/test_anti_wick_exit.py diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index a02b12a6b..30e93c01f 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -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 ) @@ -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 diff --git a/api/exchange_apis/kucoin/futures/position_deal.py b/api/exchange_apis/kucoin/futures/position_deal.py index 89ca74f6d..934c2e01f 100644 --- a/api/exchange_apis/kucoin/futures/position_deal.py +++ b/api/exchange_apis/kucoin/futures/position_deal.py @@ -10,6 +10,7 @@ from kucoin_universal_sdk.model.common import RestError from pybinbot import ( BotBase, + DealType, KucoinApi, KucoinFutures, MarketType, @@ -17,12 +18,11 @@ 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 @@ -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, @@ -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) @@ -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 @@ -240,6 +251,7 @@ 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( @@ -247,6 +259,7 @@ def execute_stop_loss(self) -> BotModel: qty=qty, reduce_only=True, leverage=self.symbol_info.futures_leverage, + reference_price=reference_price, ) except RestError as e: @@ -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) @@ -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 = ( @@ -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 @@ -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 = ( @@ -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( @@ -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: diff --git a/api/pyproject.toml b/api/pyproject.toml index 1c938467b..900cfc223 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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] diff --git a/api/tests/test_anti_wick_exit.py b/api/tests/test_anti_wick_exit.py new file mode 100644 index 000000000..63d077b11 --- /dev/null +++ b/api/tests/test_anti_wick_exit.py @@ -0,0 +1,249 @@ +""" +Tests for anti-wick exit execution improvements (Phase 1). + +Phase 1: reference_price threading through execute_stop_loss / reverse_position + so that SL closes use the band-capped IOC escalation path instead of a raw + 1-tick cross into a hollow book. + +Regression fixture: FHEUSDTM 2026-05-31 + Long opened 0.02343, SL 0.02249 (4% floored). + 14:18 1m candle: low 0.02233, close 0.02235 (wick). + Actual fill was 0.02235 (the wick low). With reference_price anchored to the + last closed 15m candle (0.02252), the band-cap is 0.02247 — a meaningfully + better fill price. + +Note: closed-candle trigger confirmation (Phase 2 / SL_CONFIRM_MODE) was +evaluated via simulation over 14 historical SL exits and found to worsen +aggregate outcomes by ~2.1% (1 clear wick saved, 6 delayed breakdowns). +The phase-2 gate was removed; SL fires on mark-price breach as before. +""" + +import types +from typing import Any, cast + +import pytest +from bots.models import BotModel, DealModel +from exchange_apis.kucoin.futures.position_deal import PositionDeal +from pybinbot import MarketType, OrderBase, OrderStatus, DealType, Position + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Raw kline row: [open_time_ms, open, high, low, close, volume, close_time_ms] +# 14:15 15m candle (last fully closed): close=0.02252 (above SL 0.02249) +_CLOSED_CANDLE_NORMAL = [ + 1780236900000, + 0.02279, + 0.02288, + 0.02233, + 0.02252, + 1000, + 1780237799999, +] +# 14:30 15m candle (in-progress at time of SL trigger): +_IN_PROGRESS_CANDLE = [ + 1780237800000, + 0.02252, + 0.02255, + 0.02233, + 0.02235, + 500, + 1780238699999, +] + +# A klines list where klines[-2] is above the SL (wick scenario) +KLINES_WICK = [_CLOSED_CANDLE_NORMAL, _IN_PROGRESS_CANDLE] + +# A klines list where klines[-2] is below the SL (genuine breakdown) +_CLOSED_CANDLE_BREAKDOWN = [ + 1780236900000, + 0.02300, + 0.02310, + 0.02200, + 0.02230, + 1000, + 1780237799999, +] +KLINES_BREAKDOWN = [_CLOSED_CANDLE_BREAKDOWN, _IN_PROGRESS_CANDLE] + + +def _make_fheusdtm_deal( + *, + klines=None, + stop_loss_price: float = 0.02249, + opening_price: float = 0.02343, + stop_loss: float = 4.0, + margin_short_reversal: bool = False, + position: Position = Position.long, +) -> Any: + """Minimal PositionDeal stub shaped after the FHEUSDTM production case.""" + deal = cast(Any, PositionDeal.__new__(PositionDeal)) + deal.price_precision = 5 + deal.kucoin_symbol = "FHEUSDTM" + deal.symbol_info = types.SimpleNamespace(futures_leverage=2) + deal.active_bot = BotModel( + pair="FHEUSDTM", + market_type=MarketType.FUTURES, + position=position, + stop_loss=stop_loss, + take_profit=0.0, + trailing=False, + margin_short_reversal=margin_short_reversal, + deal=DealModel( + opening_price=opening_price, + opening_qty=8.0, + stop_loss_price=stop_loss_price, + opening_timestamp=1780232436000, # 14:00 UTC + ), + ) + # Pydantic may serialise the enum to its value string; re-assign the + # enum object so exit() can call .value on it without AttributeError. + deal.active_bot.position = position + deal.controller = types.SimpleNamespace( + save=lambda bot: None, + update_logs=lambda *args, **kwargs: None, + ) + deal.klines = klines if klines is not None else KLINES_WICK + return deal + + +# --------------------------------------------------------------------------- +# Phase 1 — reference_price flows through to buy/sell in execute_stop_loss +# --------------------------------------------------------------------------- + + +def test_execute_stop_loss_passes_reference_price_to_sell(): + """ + Phase 1: execute_stop_loss(reference_price=X) must pass reference_price + through to kucoin_futures_api.sell() for a long position. + """ + captured: dict = {} + + def fake_sell(symbol, qty, reduce_only, leverage, reference_price=None): + captured["reference_price"] = reference_price + return OrderBase( + order_id="sl-test", + order_type="limit", + pair=symbol, + timestamp=1780237000000, + order_side="sell", + qty=qty, + price=0.02247, + status=OrderStatus.FILLED, + time_in_force="IOC", + deal_type=DealType.stop_loss, + ) + + deal = _make_fheusdtm_deal() + deal.kucoin_futures_api = types.SimpleNamespace(sell=fake_sell) + + PositionDeal.execute_stop_loss(deal, reference_price=0.02252) + + assert captured.get("reference_price") == pytest.approx(0.02252, abs=1e-6) + + +def test_execute_stop_loss_passes_reference_price_to_buy_for_short(): + """ + Phase 1: execute_stop_loss(reference_price=X) for a SHORT position must + pass reference_price through to kucoin_futures_api.buy(). + """ + captured: dict = {} + + def fake_buy(symbol, qty, reduce_only, reference_price=None): + captured["reference_price"] = reference_price + return OrderBase( + order_id="sl-test", + order_type="limit", + pair=symbol, + timestamp=1780237000000, + order_side="buy", + qty=qty, + price=0.02260, + status=OrderStatus.FILLED, + time_in_force="IOC", + deal_type=DealType.stop_loss, + ) + + deal = _make_fheusdtm_deal(position=Position.short, stop_loss_price=0.02334) + deal.kucoin_futures_api = types.SimpleNamespace(buy=fake_buy) + + PositionDeal.execute_stop_loss(deal, reference_price=0.02245) + + assert captured.get("reference_price") == pytest.approx(0.02245, abs=1e-6) + + +def test_paper_trading_execute_stop_loss_uses_reference_price_as_fill(): + """ + Paper-trading branch: when reference_price is provided the simulated fill + price should be reference_price, not the current mark price. + """ + from databases.crud.paper_trading_crud import PaperTradingTableCrud + from databases.tables.bot_table import PaperTradingTable + + saved: list[BotModel] = [] + + # Subclass PaperTradingTableCrud so isinstance() checks pass, but override + # the DB-touching methods to avoid any real database calls. + class PaperCtrlStub(PaperTradingTableCrud): + def __init__(self) -> None: + pass # skip real __init__ that opens a DB session + + def save(self, bot: BotModel) -> PaperTradingTable: + saved.append(bot) + return cast(PaperTradingTable, None) + + def update_logs(self, *args: Any, **kwargs: Any) -> PaperTradingTable: + return cast(PaperTradingTable, None) + + deal = _make_fheusdtm_deal() + deal.controller = PaperCtrlStub() + deal.active_bot.deal.current_price = 0.0224 # the wick low + + PositionDeal.execute_stop_loss(deal, reference_price=0.02252) + + assert len(saved) > 0 + closing_price = saved[-1].deal.closing_price + # Should use reference_price (0.02252) not the wick mark (0.0224) + assert closing_price == pytest.approx(0.02252, abs=1e-5) + + +def test_reverse_position_passes_reference_price_to_close_leg(): + """ + Phase 1: reverse_position(reference_price=X) must pass reference_price + through to the reduce-only sell/buy call that closes the current position. + """ + captured: dict = {} + + def fake_sell(symbol, qty, reduce_only, leverage, reference_price=None): + captured["reference_price"] = reference_price + return OrderBase( + order_id="rev-close", + order_type="limit", + pair=symbol, + timestamp=1780247000000, + order_side="sell", + qty=qty, + price=0.02247, + status=OrderStatus.FILLED, + time_in_force="IOC", + deal_type=DealType.margin_short, + ) + + deal = _make_fheusdtm_deal() + # Stub the exchange position query + deal.kucoin_futures_api = types.SimpleNamespace( + sell=fake_sell, + get_futures_position=lambda symbol: types.SimpleNamespace(current_qty=8.0), + ) + # Stub the DB create so the new pending bot is created without a real DB + deal.controller = types.SimpleNamespace( + save=lambda bot: None, + update_logs=lambda *a, **kw: None, + create=lambda bot: BotModel(**bot.model_dump()), + ) + + PositionDeal.reverse_position(deal, reference_price=0.02252) + + assert captured.get("reference_price") == pytest.approx(0.02252, abs=1e-6) diff --git a/api/tests/test_futures_reversal_integration.py b/api/tests/test_futures_reversal_integration.py index 56031fcf1..642870b74 100644 --- a/api/tests/test_futures_reversal_integration.py +++ b/api/tests/test_futures_reversal_integration.py @@ -34,7 +34,7 @@ def __init__(self, current_qty: float = 68): def get_futures_position(self, symbol): return self._position - def sell(self, symbol, qty, reduce_only, leverage=None): + def sell(self, symbol, qty, reduce_only, leverage=None, reference_price=None): self.sell_calls.append({"qty": qty, "reduce_only": reduce_only}) return OrderModel( order_id="close-order-1", @@ -49,7 +49,7 @@ def sell(self, symbol, qty, reduce_only, leverage=None): deal_type=DealType.margin_short, ) - def buy(self, symbol, qty, reduce_only, leverage=None): + def buy(self, symbol, qty, reduce_only, leverage=None, reference_price=None): self.buy_calls.append({"qty": qty, "reduce_only": reduce_only}) return OrderModel( order_id="close-order-1", @@ -160,7 +160,7 @@ def test_reverse_position_errors_when_reduce_only_fails(): bot = make_long_bot() class FailingApi(DummyFuturesApi): - def sell(self, symbol, qty, reduce_only, leverage=None): + def sell(self, symbol, qty, reduce_only, leverage=None, reference_price=None): raise RestError( msg="insufficient balance", response=DummyResponse(400100, "insufficient balance"), diff --git a/api/tests/test_kucoin_futures_stop_loss.py b/api/tests/test_kucoin_futures_stop_loss.py index 7fa177611..aaf58667e 100644 --- a/api/tests/test_kucoin_futures_stop_loss.py +++ b/api/tests/test_kucoin_futures_stop_loss.py @@ -266,6 +266,7 @@ def test_reconcile_exchange_sl_places_when_exchange_missing(): def test_exit_panic_closes_stale_mild_loser_after_three_days(monkeypatch): deal = cast(Any, PositionDeal.__new__(PositionDeal)) deal.price_precision = 2 + deal.klines = None deal.active_bot = BotModel( pair="BEATUSDTM", market_type=MarketType.FUTURES, @@ -299,6 +300,7 @@ def test_exit_panic_closes_stale_mild_loser_after_three_days(monkeypatch): def test_exit_keeps_stale_loser_below_panic_close_band(monkeypatch): deal = cast(Any, PositionDeal.__new__(PositionDeal)) deal.price_precision = 2 + deal.klines = None deal.active_bot = BotModel( pair="BEATUSDTM", market_type=MarketType.FUTURES, diff --git a/api/uv.lock b/api/uv.lock index 2d843b15a..8cfe47950 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -328,7 +328,7 @@ requires-dist = [ { name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "py3cw", specifier = ">=0.0.39" }, { name = "py4j", specifier = ">=0.10.9" }, - { name = "pybinbot", specifier = ">=1.9.23" }, + { name = "pybinbot", specifier = ">=1.9.25" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" }, @@ -1085,11 +1085,11 @@ wheels = [ [[package]] name = "idna" -version = "3.16" +version = "3.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, ] [[package]] @@ -1115,7 +1115,7 @@ wheels = [ [[package]] name = "kucoin-universal-sdk" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -1123,9 +1123,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/77/e8ba73550b6f84ff2bbaa8c4901e662eb08445d15926939c988217353a45/kucoin_universal_sdk-1.3.0.tar.gz", hash = "sha256:89a3da888881ae3d3a4d2c7a75888c080b7ed14732f738d48673e9a4a5eb5668", size = 441856, upload-time = "2025-06-12T01:38:09.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/d5/9d324391c7edb40683e643d31fb97ab1fb7eb2d75eabbf4611903cc3538e/kucoin_universal_sdk-1.3.1.tar.gz", hash = "sha256:6c03a38c4496d5c0f8c6025cff290682865ac2e049619badc5ca8f01a7f68c6a", size = 441974, upload-time = "2026-05-29T07:57:08.485Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/43/838d7e9211801a756be2620755f08a4020dd89e2e20b5eac00cc1b0bfbe2/kucoin_universal_sdk-1.3.0-py3-none-any.whl", hash = "sha256:b8ba340b1e27683dc1259aa05d794492ec0117e173cc9a7b8a5eab58260f6d6f", size = 1173680, upload-time = "2025-06-12T01:38:08.208Z" }, + { url = "https://files.pythonhosted.org/packages/c4/77/a527dad5a6c615138dcdc8ad0fef84ad91e6f8c59745211961a823537d9d/kucoin_universal_sdk-1.3.1-py3-none-any.whl", hash = "sha256:dde290654c063fc4d0375eedc7c416694a37bc93f66a5ec6ae39b9053191ffdb", size = 1173680, upload-time = "2026-05-29T07:57:07.145Z" }, ] [[package]] @@ -1700,14 +1700,14 @@ wheels = [ [[package]] name = "pandas-stubs" -version = "3.0.0.260204" +version = "3.0.3.260530" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/1d/297ff2c7ea50a768a2247621d6451abb2a07c0e9be7ca6d36ebe371658e5/pandas_stubs-3.0.0.260204.tar.gz", hash = "sha256:bf9294b76352effcffa9cb85edf0bed1339a7ec0c30b8e1ac3d66b4228f1fbc3", size = 109383, upload-time = "2026-02-04T15:17:17.247Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/aa/c41a8a0ff86fd85dbb3ec0c1f3fa488ca64a8b5f82654ae1b07d84acefe5/pandas_stubs-3.0.3.260530.tar.gz", hash = "sha256:d1efe47b2e5a312c047d7feabec5cb7a55365747983420077e9fcbe9ab74f714", size = 113183, upload-time = "2026-05-30T17:47:40.34Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/2f/f91e4eee21585ff548e83358332d5632ee49f6b2dcd96cb5dca4e0468951/pandas_stubs-3.0.0.260204-py3-none-any.whl", hash = "sha256:5ab9e4d55a6e2752e9720828564af40d48c4f709e6a2c69b743014a6fcb6c241", size = 168540, upload-time = "2026-02-04T15:17:15.615Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e0/99ec5b02203c4e9ce878bc63d8caa06ac1f891e4d63bded9a5ced70fcb4f/pandas_stubs-3.0.3.260530-py3-none-any.whl", hash = "sha256:a6277eb1c8cebf48d9b2413fcd2e9a6b4ff479c934a223c29eacbc3058c4cb55", size = 173780, upload-time = "2026-05-30T17:47:39.13Z" }, ] [[package]] @@ -1755,11 +1755,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -1967,7 +1967,7 @@ wheels = [ [[package]] name = "pybinbot" -version = "1.9.23" +version = "1.9.25" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -1980,9 +1980,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/40/ca428356d19103d44da38f88365fe46ca67f099e813db310caa684f48f70/pybinbot-1.9.23.tar.gz", hash = "sha256:f46f630a508a05772fb33f5b5b35f09bff002e3f374010710b2298a02b398b1a", size = 66130, upload-time = "2026-05-26T21:34:35.424Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/8c/e9e46691aff485e628e4d4f895ab1e2563cbb651af4c80475148a22e4c63/pybinbot-1.9.25.tar.gz", hash = "sha256:648e91a41455e49dcaa0c5e7ab074fc03942048f855ee03b44f4cfaccc132540", size = 71885, upload-time = "2026-05-31T20:26:53.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/2c/e1e2f9651d41b819ba63a2bf90719e76dec70798b1f9a36ed73eda780ee1/pybinbot-1.9.23-py3-none-any.whl", hash = "sha256:5f76db902def703f565936b76f04329708bf8dd5f941c6dd0eccffd893427dc8", size = 66954, upload-time = "2026-05-26T21:34:33.682Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/be10eee991d7785ed208f96062e3e26f9f6e2b108363a4608126028df1ba/pybinbot-1.9.25-py3-none-any.whl", hash = "sha256:c3651d749ac72aadcaf4deefc111329b3ad745711923479d309c6275763f717b", size = 69067, upload-time = "2026-05-31T20:26:52.568Z" }, ] [[package]] @@ -2290,11 +2290,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.29" +version = "0.0.30" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/82/c8cd43a6e0719bf5a3b034f6726dd701f75829c08944c83d4b95d02ed0e8/python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", size = 46316, upload-time = "2026-05-31T19:24:55.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/0318007beb234790993d3ec5afd051d1dbceb733e81e3afe2b981ece3f37/python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b", size = 29730, upload-time = "2026-05-31T19:24:53.814Z" }, ] [[package]] @@ -2547,40 +2547,40 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] [[package]] name = "sentry-sdk" -version = "2.60.0" +version = "2.61.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/a2/2e6c090db384cc515069f4f85542bd5baf6786852073020ea73d4a76d3ea/sentry_sdk-2.60.0.tar.gz", hash = "sha256:0bd25e54e78ca02d0be512529fa644bbbf9e8470d7b26371294012d4ca93c978", size = 452946, upload-time = "2026-05-13T13:34:52.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/4d/3c66e6045bd2071256b6b6fdcb0cc02b86ce54b2acc2ceac79af8e0efbb5/sentry_sdk-2.61.0.tar.gz", hash = "sha256:1ca9b4bb777eb5be67004edab7eb894f21c6301f1d05ed64966719ad5d1764ce", size = 458510, upload-time = "2026-05-28T09:40:28.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/41/f2b800b7f12a05dd48c2a6280d4dd812d1425fc66ed3fe3fd99420c41d1a/sentry_sdk-2.60.0-py3-none-any.whl", hash = "sha256:28a536c03291c8bcb363cf35c611b32738ec118ff64d8d6383b096448ac4c803", size = 475616, upload-time = "2026-05-13T13:34:50.259Z" }, + { url = "https://files.pythonhosted.org/packages/21/5a/9794736d5802689c1a48862e6afe6b7f3e86cc37c15d4a84bc0143877dc1/sentry_sdk-2.61.0-py3-none-any.whl", hash = "sha256:ec4d30273909cb1d198e03208b16ee70e2bc5d90a16fd9f1fb2fc6a72e1f03dc", size = 483111, upload-time = "2026-05-28T09:40:27.027Z" }, ] [[package]] @@ -2674,15 +2674,15 @@ wheels = [ [[package]] name = "starlette" -version = "1.1.0" +version = "1.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/66/4d20cdf39a8d6a51e663b7038e3b828ff211d3891a43a713fe7e4643f3a8/starlette-1.1.0.tar.gz", hash = "sha256:e83c7fe0ddecd8719c5b840080325aec0260acec86e9832899e377b91d65e90f", size = 2660060, upload-time = "2026-05-23T16:55:41.376Z" } +sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/79/920b8e0a8b20f793e8d64855095cb8febabf6175b8550b6f7a547d813891/starlette-1.1.0-py3-none-any.whl", hash = "sha256:7f0dfd38e428aad5cb6f9f667f0ca1d2d8ca3f3385dccac8305f79ec98458382", size = 72899, upload-time = "2026-05-23T16:55:39.201Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" }, ] [[package]] @@ -2711,7 +2711,7 @@ wheels = [ [[package]] name = "typer" -version = "0.26.1" +version = "0.26.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -2719,9 +2719,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/26/8e9a4f2c98caefcf4ac25788d48939516a9dd4265fcf9bdd578a2a1b55dd/typer-0.26.1.tar.gz", hash = "sha256:537d27ae686d82967f6383382a952cb32ba4768898541effccb69ca75bbd5d23", size = 198884, upload-time = "2026-05-26T17:49:07.912Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/d3/90c1ee19209cb59f6ad185883fd4ccfcf72f8f0bfd549d5a8b70474611d0/typer-0.26.4.tar.gz", hash = "sha256:25b128964de66c5ea36d5ac82adc579e5e113509b17469edf9f5a4a1864ff2a9", size = 201191, upload-time = "2026-05-30T17:05:04.213Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/27/8a22d4833fe8aa0836ce7fa59096ad50d7e93b83be6d5383f11f9a140d54/typer-0.26.1-py3-none-any.whl", hash = "sha256:933e4f0083521f3c57d6a5aedf3b073271b2f95a19761b171b494dd6fdb21ff6", size = 123097, upload-time = "2026-05-26T17:49:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6d/5a525c69df4a90892135e5d490b00e9e46402491f3416d4395fcb0d0201e/typer-0.26.4-py3-none-any.whl", hash = "sha256:11bfd7b43557137e373c2b10f6967a555f9678a61ed72c808968b011d95534d6", size = 122436, upload-time = "2026-05-30T17:05:05.812Z" }, ] [[package]]