From 7b632c9640572b8a629e00cb2ff6a21a50545b76 Mon Sep 17 00:00:00 2001 From: Azimkin <112006710+Azimkin@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:11:22 +0200 Subject: [PATCH] Fix Bybit isolated balance sizing --- src/llm_trade_bot/bybit_gateway.py | 31 +++++++++++++++++++- src/llm_trade_bot/risk.py | 10 +++++-- tests/test_bybit_gateway.py | 46 +++++++++++++++++++++++++++++- tests/test_risk.py | 10 +++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/src/llm_trade_bot/bybit_gateway.py b/src/llm_trade_bot/bybit_gateway.py index caca155..600bc1e 100644 --- a/src/llm_trade_bot/bybit_gateway.py +++ b/src/llm_trade_bot/bybit_gateway.py @@ -91,7 +91,8 @@ class BybitGateway: raise BybitError("Bybit returned no unified wallet") unified = accounts[0] equity = _decimal(unified.get("totalEquity")) - available = _decimal(unified.get("totalAvailableBalance")) + balance_coin = self.scanner.quote_coin if category == "spot" else self.config.settle_coin + available = _available_balance(unified, balance_coin) gross = Decimal(0) ideas: set[str] = set() @@ -483,6 +484,34 @@ def _decimal(value: Any) -> Decimal: raise BybitError(f"Invalid numeric value from Bybit: {value!r}") from error +def _available_balance(account: dict[str, Any], coin_name: str) -> Decimal: + total_available = account.get("totalAvailableBalance") + if total_available is not None and str(total_available).strip(): + return max(_decimal(total_available), Decimal(0)) + + coin = next( + ( + item + for item in account.get("coin", []) + if isinstance(item, dict) and str(item.get("coin", "")).upper() == coin_name.upper() + ), + None, + ) + if coin is None: + return Decimal(0) + + available_to_withdraw = coin.get("availableToWithdraw") + if available_to_withdraw is not None and str(available_to_withdraw).strip(): + return max(_decimal(available_to_withdraw), Decimal(0)) + + available = _decimal(coin.get("walletBalance")) + available -= _decimal(coin.get("totalPositionIM")) + available -= _decimal(coin.get("totalOrderIM")) + available -= _decimal(coin.get("locked")) + available -= _decimal(coin.get("bonus")) + return max(available, Decimal(0)) + + def _is_protective_order(order: dict[str, Any]) -> bool: if bool(order.get("reduceOnly")): return True diff --git a/src/llm_trade_bot/risk.py b/src/llm_trade_bot/risk.py index 50c47bc..bf38328 100644 --- a/src/llm_trade_bot/risk.py +++ b/src/llm_trade_bot/risk.py @@ -43,9 +43,15 @@ class OrderValidator: quantity = _round_down(quantity, instrument.qty_step) notional = quantity * entry if quantity < instrument.min_qty: - raise OrderRejected("Calculated quantity is below instrument minimum") + raise OrderRejected( + f"Calculated quantity {quantity} is below instrument minimum {instrument.min_qty} " + f"(available_balance={account.available_balance}, leverage={leverage}, entry_price={entry})" + ) if notional < instrument.min_notional: - raise OrderRejected("Calculated notional is below instrument minimum") + raise OrderRejected( + f"Calculated notional {notional} is below instrument minimum {instrument.min_notional} " + f"(available_balance={account.available_balance}, quantity={quantity}, entry_price={entry})" + ) rounded_entry = _round_price(entry, instrument.tick_size) stop = _round_price(request.stop_loss, instrument.tick_size) diff --git a/tests/test_bybit_gateway.py b/tests/test_bybit_gateway.py index 5e33273..dcab19f 100644 --- a/tests/test_bybit_gateway.py +++ b/tests/test_bybit_gateway.py @@ -1,5 +1,6 @@ from __future__ import annotations +from decimal import Decimal from typing import Any from llm_trade_bot.bybit_gateway import BybitGateway @@ -7,9 +8,17 @@ from llm_trade_bot.config import BybitConfig, ScannerConfig class FakeBybitHTTP: - def __init__(self) -> None: + def __init__(self, wallet: dict[str, Any] | None = None) -> None: + self.wallet = wallet self.open_order_requests: list[dict[str, Any]] = [] + def get_wallet_balance(self, **parameters: Any) -> dict[str, Any]: + assert parameters == {"accountType": "UNIFIED"} + return self.wallet or {"retCode": 0, "result": {"list": []}} + + def get_positions(self, **parameters: Any) -> dict[str, Any]: + return {"retCode": 0, "result": {"list": []}} + def get_open_orders(self, **parameters: Any) -> dict[str, Any]: self.open_order_requests.append(parameters) return {"retCode": 0, "result": {"list": []}} @@ -26,3 +35,38 @@ async def test_open_orders_scopes_linear_requests_by_settle_coin() -> None: {"category": "spot", "limit": 50}, {"category": "linear", "limit": 50, "settleCoin": "USDC"}, ] + + +async def test_account_snapshot_uses_quote_coin_balance_when_account_available_is_empty() -> None: + wallet = { + "retCode": 0, + "result": { + "list": [ + { + "totalEquity": "100000", + "totalAvailableBalance": "", + "totalMarginBalance": "100000", + "coin": [ + { + "coin": "USDT", + "walletBalance": "50000", + "availableToWithdraw": "", + "totalPositionIM": "100", + "totalOrderIM": "200", + "locked": "300", + "bonus": "400", + }, + {"coin": "USDC", "walletBalance": "50000", "availableToWithdraw": ""}, + ], + } + ] + }, + } + gateway = BybitGateway(BybitConfig("key", "secret", "USDC"), ScannerConfig(quote_coin="USDT")) + gateway._http = FakeBybitHTTP(wallet) # type: ignore[assignment] + + spot = await gateway.account_snapshot("spot") + linear = await gateway.account_snapshot("linear") + + assert spot.available_balance == Decimal("49000") + assert linear.available_balance == Decimal("50000") diff --git a/tests/test_risk.py b/tests/test_risk.py index 1957181..126b528 100644 --- a/tests/test_risk.py +++ b/tests/test_risk.py @@ -59,6 +59,16 @@ def test_existing_exposure_does_not_cap_position(engine, rules): assert result.notional == Decimal(30000) +def test_minimum_quantity_error_reports_sizing_inputs(engine, rules): + empty_account = AccountSnapshot(Decimal(10000), Decimal(0), Decimal(0), 0) + + with pytest.raises( + OrderRejected, + match=r"quantity 0.*minimum 0\.001.*available_balance=0.*leverage=3.*entry_price=100", + ): + engine.validate_open(request(), empty_account, rules) + + @pytest.mark.parametrize( "overrides, message", [