Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 62 additions & 24 deletions api/exchange_apis/kucoin/futures/futures_deal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions api/tests/test_futures_reversal_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
62 changes: 54 additions & 8 deletions api/tests/test_kucoin_futures_contract_sizing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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