diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index 1efa24f3a..cbceb39ce 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -85,19 +85,19 @@ def create_controller(self) -> PaperTradingTableCrud | BotTableCrud: else: return BotTableCrud() - def calculate_contracts(self, balance: float, price: float) -> int: + def calculate_contracts(self, fiat_order_size: float, price: float) -> int: """ - Size futures positions from a fiat risk budget. + Size futures positions so ``fiat_order_size`` is the initial margin + the bot commits, leaving the rest of the wallet free for stop-loss + absorption and a same-size reversal. - For futures bots, ``fiat_order_size`` is the max fiat loss budget at - the configured stop, not the margin to spend. KuCoin PnL is determined - by notional move, so leverage does not change the loss at stop. + notional = fiat_order_size * leverage + contracts = notional / (price * multiplier) """ - if balance <= 0 or price <= 0: + if fiat_order_size <= 0 or price <= 0: return 0 - stop_loss_ratio = float(self.active_bot.stop_loss or 0) / 100 - if stop_loss_ratio <= 0: + if float(self.active_bot.stop_loss or 0) <= 0: return 0 symbol_data = getattr(self, "kucoin_symbol_data", None) @@ -106,8 +106,9 @@ def calculate_contracts(self, balance: float, price: float) -> int: or getattr(self.kucoin_futures_api, "DEFAULT_MULTIPLIER", 1) or 1 ) + leverage = float(self.DEFAULT_FUTURES_LEVERAGE) - contracts = balance / (stop_loss_ratio * price * multiplier) + contracts = fiat_order_size * leverage / (price * multiplier) return int(round_numbers(contracts, self.symbol_info.qty_precision)) def _is_reversal_possible( @@ -176,26 +177,55 @@ def estimate_reversal_possible_for_new_bot(self) -> bool: ) return available_contracts > estimated_contracts + def affordable_contracts(self, price: float, available_balance: float) -> int: + """ + Maximum contracts the configured leverage and available balance can + margin, after reserving round-trip taker fees. Returns 0 when even one + contract is unaffordable. + + Defensive cap: with margin-based sizing in ``calculate_contracts``, + contracts only exceed wallet capacity when ``fiat_order_size`` is set + larger than the available balance. This trims the order down so it can + still be placed rather than rejected by the exchange. + """ + if price <= 0 or available_balance <= 0: + return 0 + + symbol_data = getattr(self, "kucoin_symbol_data", None) + multiplier = float( + getattr(symbol_data, "multiplier", 0) + or getattr(self.kucoin_futures_api, "DEFAULT_MULTIPLIER", 1) + or 1 + ) + taker_fee_rate = float(getattr(symbol_data, "taker_fee_rate", 0) or 0) + leverage = self.DEFAULT_FUTURES_LEVERAGE + + per_contract_notional = price * multiplier + per_contract_cost = (per_contract_notional / leverage) + ( + 2 * per_contract_notional * taker_fee_rate + ) + if per_contract_cost <= 0: + return 0 + + return int(available_balance / per_contract_cost) + def contracts_to_fiat_order_size(self, contracts: float, price: float) -> float: """ - Invert calculate_contracts() so fiat_order_size reflects the actual - risk budget used to open an existing futures position. + Invert calculate_contracts() so fiat_order_size reflects the initial + margin actually committed by an open futures position. """ if contracts <= 0 or price <= 0: return 0.0 - stop_loss_ratio = float(self.active_bot.stop_loss or 0) / 100 - if stop_loss_ratio <= 0: - return 0.0 - symbol_data = getattr(self, "kucoin_symbol_data", None) multiplier = float( getattr(symbol_data, "multiplier", 0) or getattr(self.kucoin_futures_api, "DEFAULT_MULTIPLIER", 1) ) + leverage = float(self.DEFAULT_FUTURES_LEVERAGE) return round_numbers( - contracts * price * multiplier * stop_loss_ratio, + contracts * price * multiplier / leverage, 8, ) @@ -550,14 +580,6 @@ def base_order(self) -> BotModel: raise BinbotErrors("Fiat order size must be set.") available_balance = self.compute_available_balance() - if self.active_bot.fiat_order_size > available_balance: - required_balance = self.min_required_balance() - - if required_balance > available_balance: - raise BinbotErrors( - f"Requested base order size {self.active_bot.fiat_order_size} {self.fiat} " - f"exceeds available balance {available_balance} {self.fiat}." - ) price = self.kucoin_futures_api.matching_engine( symbol=self.kucoin_symbol, @@ -572,6 +594,22 @@ def base_order(self) -> BotModel: "Calculated contracts is 0. Check if the order size, stop loss, and risk settings are correct." ) + # Defensive cap: margin-based sizing only exceeds the wallet when + # fiat_order_size is set above the available balance. Trim contracts + # so KuCoin doesn't reject the order with code 300003. + affordable = self.affordable_contracts(price, available_balance) + if affordable <= 0: + raise BinbotErrors( + f"Insufficient balance to open any {self.kucoin_symbol} contract: " + f"{available_balance} {self.fiat} available." + ) + if affordable < contracts: + self.active_bot.add_log( + f"Order capped from {contracts} to {affordable} contracts to fit " + f"available balance ({available_balance} {self.fiat})." + ) + contracts = affordable + if self.active_bot.position == Position.short: order: OrderBase = self.kucoin_futures_api.sell( symbol=self.kucoin_symbol, diff --git a/api/tests/test_futures_reversal_integration.py b/api/tests/test_futures_reversal_integration.py index ec86c9476..388a044db 100644 --- a/api/tests/test_futures_reversal_integration.py +++ b/api/tests/test_futures_reversal_integration.py @@ -182,7 +182,8 @@ def test_reverse_position_closes_previous_and_opens_new_bot(monkeypatch): assert reversed_bot.status == Status.active assert reversed_bot.deal.base_order_size == 68 assert reversed_bot.deal.opening_qty == 68 - assert reversed_bot.fiat_order_size == 2.58467999 + # contracts × price × multiplier / leverage = 68 × 1.267 × 1 / 1 + assert reversed_bot.fiat_order_size == 86.156 assert len(reversed_bot.orders) == 1 assert reversed_bot.orders[0].qty == 56 assert reversed_bot.orders[0].deal_type == DealType.base_order @@ -238,7 +239,8 @@ def test_reverse_position_retries_second_order_after_insufficient_balance( assert reversed_bot.status == Status.active assert reversed_bot.deal.base_order_size == 54 assert reversed_bot.deal.opening_qty == 54 - assert reversed_bot.fiat_order_size == 6.84179999 + # contracts × price × multiplier / leverage = 54 × 1.267 × 1 / 1 (floored to 1e-8) + assert reversed_bot.fiat_order_size == 68.41799999 assert len(reversed_bot.orders) == 1 assert reversed_bot.orders[0].qty == 42 assert reversed_bot.orders[0].deal_type == DealType.base_order diff --git a/api/tests/test_kucoin_futures_contract_sizing.py b/api/tests/test_kucoin_futures_contract_sizing.py index 707f72cfa..e77ba9f5d 100644 --- a/api/tests/test_kucoin_futures_contract_sizing.py +++ b/api/tests/test_kucoin_futures_contract_sizing.py @@ -12,6 +12,7 @@ def make_sizing_deal( stop_loss: float = 6.43252, multiplier: float = 10.0, qty_precision: int = 0, + taker_fee_rate: float = 0.0, ) -> Any: deal = cast(Any, KucoinPositionDeal.__new__(KucoinPositionDeal)) deal.active_bot = BotModel( @@ -21,27 +22,72 @@ def make_sizing_deal( stop_loss=stop_loss, ) deal.symbol_info = types.SimpleNamespace(qty_precision=qty_precision) - deal.kucoin_symbol_data = types.SimpleNamespace(multiplier=multiplier) + deal.kucoin_symbol_data = types.SimpleNamespace( + multiplier=multiplier, + taker_fee_rate=taker_fee_rate, + ) deal.kucoin_futures_api = types.SimpleNamespace( DEFAULT_MULTIPLIER=1, DEFAULT_LEVERAGE=1, ) + deal.DEFAULT_FUTURES_LEVERAGE = 1 return deal -def test_calculate_contracts_uses_stop_loss_as_percent_risk_budget(): +def test_calculate_contracts_treats_fiat_order_size_as_initial_margin(): + """ + fiat_order_size is the margin to commit, so contracts = fos*lev/(price*mult). + round_numbers floors, so 15/9.3269 ≈ 1.61 → 1. + """ deal = make_sizing_deal() - assert deal.calculate_contracts(balance=15, price=0.93269) == 25 + assert deal.calculate_contracts(fiat_order_size=15, price=0.93269) == 1 -def test_contracts_to_fiat_order_size_is_inverse_risk_budget(): +def test_contracts_to_fiat_order_size_is_inverse_margin(): + """ + Inverse of the new sizing: 1 contract × 0.93269 price × 10 mult / 1 lev. + """ deal = make_sizing_deal() - assert deal.contracts_to_fiat_order_size(contracts=25, price=0.93269) == 14.99886769 + assert deal.contracts_to_fiat_order_size(contracts=1, price=0.93269) == 9.3269 + + +def test_calculate_contracts_returns_zero_when_margin_is_below_one_contract(): + deal = make_sizing_deal(fiat_order_size=0.05, multiplier=10.0) + + assert deal.calculate_contracts(fiat_order_size=0.05, price=0.93269) == 0 + + +def test_affordable_contracts_caps_when_fiat_order_size_exceeds_balance(): + """ + The margin-based sizing only exceeds wallet capacity when fiat_order_size + is misconfigured above the available balance. The cap keeps the order + placeable rather than rejected by the exchange. + """ + deal = make_sizing_deal(fiat_order_size=100.0, multiplier=1.0) + + desired = deal.calculate_contracts(fiat_order_size=100.0, price=1.0) + affordable = deal.affordable_contracts(price=1.0, available_balance=56.9) + + assert desired > affordable, ( + "margin sizing must exceed wallet capacity for this regression" + ) + assert affordable == 56 + + +def test_affordable_contracts_zero_when_one_contract_unaffordable(): + deal = make_sizing_deal(multiplier=1.0) + + assert deal.affordable_contracts(price=100.0, available_balance=10.0) == 0 -def test_calculate_contracts_returns_zero_when_risk_budget_is_below_one_contract(): - deal = make_sizing_deal(fiat_order_size=0.5) +def test_affordable_contracts_reserves_round_trip_taker_fees(): + """ + With a non-zero taker fee, affordable contracts must leave room for two + fills (entry + exit). 100 USDT @ 1.0 with 0.06% taker fee → per-contract + cost = 1 + 2*0.0006 = 1.0012; floor(100/1.0012) = 99. + """ + deal = make_sizing_deal(multiplier=1.0, taker_fee_rate=0.0006) - assert deal.calculate_contracts(balance=0.5, price=0.93269) == 0 + assert deal.affordable_contracts(price=1.0, available_balance=100.0) == 99