From 943681b71249b61d64e850f134f3c6db4ea0374e Mon Sep 17 00:00:00 2001 From: Carlos Wu Date: Fri, 8 May 2026 15:19:22 +0100 Subject: [PATCH 1/3] Temporarily deploy previous commit to fix insufficient balance issues caused by contracts leverage calc --- api/exchange_apis/kucoin/futures/futures_deal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index 1efa24f3a..c39ceaa66 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -719,7 +719,7 @@ def update_parameters(self) -> BotModel: self.reconcile_exchange_sl() return self.active_bot - def update_parameters_with_activation(self) -> BotModel: + def update_parameters_activation(self) -> BotModel: """ update_parameters with some additional logic for activation: - If the bot is already active, it means we are updating parameters without changing the position, so we just call update_parameters. @@ -823,7 +823,7 @@ def open_deal(self) -> BotModel: self.active_bot = self.update_parameters() else: # Activation required - self.active_bot = self.update_parameters_with_activation() + self.active_bot = self.update_parameters_activation() self.controller.save(self.active_bot) return self.active_bot From b0d6615a65fc20a1ea7979a55b4ad9fa48d52483 Mon Sep 17 00:00:00 2001 From: Carlos Wu Date: Mon, 11 May 2026 15:35:16 +0100 Subject: [PATCH 2/3] Stop min margin size blowing up the position size --- .../kucoin/futures/futures_deal.py | 61 ++++++++++++++++--- .../test_kucoin_futures_contract_sizing.py | 43 ++++++++++++- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index c39ceaa66..f9e5f5998 100644 --- a/api/exchange_apis/kucoin/futures/futures_deal.py +++ b/api/exchange_apis/kucoin/futures/futures_deal.py @@ -176,6 +176,38 @@ 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. + + At 1x leverage notional == margin, so the risk-budget sizing in + ``calculate_contracts`` can demand a notional far larger than the + wallet (notional = fiat_order_size / stop_loss_ratio). This is the + cap that keeps the order placeable. + """ + 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 @@ -550,14 +582,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 +596,23 @@ def base_order(self) -> BotModel: "Calculated contracts is 0. Check if the order size, stop loss, and risk settings are correct." ) + # Risk-budget sizing assumes the wallet can margin the resulting + # notional. At 1x leverage notional == margin, so a small balance + # combined with a small stop_loss easily produces a notional KuCoin + # rejects with code 300003. Cap contracts to what the balance affords. + 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, @@ -719,7 +760,7 @@ def update_parameters(self) -> BotModel: self.reconcile_exchange_sl() return self.active_bot - def update_parameters_activation(self) -> BotModel: + def update_parameters_with_activation(self) -> BotModel: """ update_parameters with some additional logic for activation: - If the bot is already active, it means we are updating parameters without changing the position, so we just call update_parameters. @@ -823,7 +864,7 @@ def open_deal(self) -> BotModel: self.active_bot = self.update_parameters() else: # Activation required - self.active_bot = self.update_parameters_activation() + self.active_bot = self.update_parameters_with_activation() self.controller.save(self.active_bot) return self.active_bot diff --git a/api/tests/test_kucoin_futures_contract_sizing.py b/api/tests/test_kucoin_futures_contract_sizing.py index 707f72cfa..35a4ee2a1 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,11 +22,15 @@ 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 @@ -45,3 +50,39 @@ def test_calculate_contracts_returns_zero_when_risk_budget_is_below_one_contract deal = make_sizing_deal(fiat_order_size=0.5) assert deal.calculate_contracts(balance=0.5, price=0.93269) == 0 + + +def test_affordable_contracts_caps_below_risk_budget_when_balance_is_short(): + """ + Reproduction for the prod 300003 "Insufficient balance" storm: a 15 USDT + risk budget at a 13.76% stop demands ~109 USDT notional, which at 1x + leverage equals 109 USDT margin. With only 56.9 USDT available, the + balance can margin at most ~56 USDT notional; the order must be capped + rather than sent and rejected. + """ + deal = make_sizing_deal(stop_loss=13.76, multiplier=1.0) + + desired = deal.calculate_contracts(balance=15.0, price=1.0) + affordable = deal.affordable_contracts(price=1.0, available_balance=56.9) + + assert desired > affordable, ( + "risk-budget 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_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.affordable_contracts(price=1.0, available_balance=100.0) == 99 From 5805fac6ba3c54ea6d7967ffdc39b94c67c13fe0 Mon Sep 17 00:00:00 2001 From: Carlos Wu Date: Tue, 12 May 2026 10:00:47 +0100 Subject: [PATCH 3/3] Contract calculations revert to default leverage as cap --- .../kucoin/futures/futures_deal.py | 45 +++++++++---------- .../test_futures_reversal_integration.py | 6 ++- .../test_kucoin_futures_contract_sizing.py | 37 ++++++++------- 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/api/exchange_apis/kucoin/futures/futures_deal.py b/api/exchange_apis/kucoin/futures/futures_deal.py index f9e5f5998..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( @@ -182,10 +183,10 @@ def affordable_contracts(self, price: float, available_balance: float) -> int: margin, after reserving round-trip taker fees. Returns 0 when even one contract is unaffordable. - At 1x leverage notional == margin, so the risk-budget sizing in - ``calculate_contracts`` can demand a notional far larger than the - wallet (notional = fiat_order_size / stop_loss_ratio). This is the - cap that keeps the order placeable. + 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 @@ -210,24 +211,21 @@ def affordable_contracts(self, price: float, available_balance: float) -> int: 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, ) @@ -596,10 +594,9 @@ def base_order(self) -> BotModel: "Calculated contracts is 0. Check if the order size, stop loss, and risk settings are correct." ) - # Risk-budget sizing assumes the wallet can margin the resulting - # notional. At 1x leverage notional == margin, so a small balance - # combined with a small stop_loss easily produces a notional KuCoin - # rejects with code 300003. Cap contracts to what the balance affords. + # 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( 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 35a4ee2a1..e77ba9f5d 100644 --- a/api/tests/test_kucoin_futures_contract_sizing.py +++ b/api/tests/test_kucoin_futures_contract_sizing.py @@ -34,39 +34,44 @@ def make_sizing_deal( 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_risk_budget_is_below_one_contract(): - deal = make_sizing_deal(fiat_order_size=0.5) +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(balance=0.5, price=0.93269) == 0 + assert deal.calculate_contracts(fiat_order_size=0.05, price=0.93269) == 0 -def test_affordable_contracts_caps_below_risk_budget_when_balance_is_short(): +def test_affordable_contracts_caps_when_fiat_order_size_exceeds_balance(): """ - Reproduction for the prod 300003 "Insufficient balance" storm: a 15 USDT - risk budget at a 13.76% stop demands ~109 USDT notional, which at 1x - leverage equals 109 USDT margin. With only 56.9 USDT available, the - balance can margin at most ~56 USDT notional; the order must be capped - rather than sent and rejected. + 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(stop_loss=13.76, multiplier=1.0) + deal = make_sizing_deal(fiat_order_size=100.0, multiplier=1.0) - desired = deal.calculate_contracts(balance=15.0, price=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, ( - "risk-budget sizing must exceed wallet capacity for this regression" + "margin sizing must exceed wallet capacity for this regression" ) assert affordable == 56