Fix Bybit isolated balance sizing
This commit is contained in:
@@ -91,7 +91,8 @@ class BybitGateway:
|
|||||||
raise BybitError("Bybit returned no unified wallet")
|
raise BybitError("Bybit returned no unified wallet")
|
||||||
unified = accounts[0]
|
unified = accounts[0]
|
||||||
equity = _decimal(unified.get("totalEquity"))
|
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)
|
gross = Decimal(0)
|
||||||
ideas: set[str] = set()
|
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
|
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:
|
def _is_protective_order(order: dict[str, Any]) -> bool:
|
||||||
if bool(order.get("reduceOnly")):
|
if bool(order.get("reduceOnly")):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -43,9 +43,15 @@ class OrderValidator:
|
|||||||
quantity = _round_down(quantity, instrument.qty_step)
|
quantity = _round_down(quantity, instrument.qty_step)
|
||||||
notional = quantity * entry
|
notional = quantity * entry
|
||||||
if quantity < instrument.min_qty:
|
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:
|
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)
|
rounded_entry = _round_price(entry, instrument.tick_size)
|
||||||
stop = _round_price(request.stop_loss, instrument.tick_size)
|
stop = _round_price(request.stop_loss, instrument.tick_size)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from llm_trade_bot.bybit_gateway import BybitGateway
|
from llm_trade_bot.bybit_gateway import BybitGateway
|
||||||
@@ -7,9 +8,17 @@ from llm_trade_bot.config import BybitConfig, ScannerConfig
|
|||||||
|
|
||||||
|
|
||||||
class FakeBybitHTTP:
|
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]] = []
|
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]:
|
def get_open_orders(self, **parameters: Any) -> dict[str, Any]:
|
||||||
self.open_order_requests.append(parameters)
|
self.open_order_requests.append(parameters)
|
||||||
return {"retCode": 0, "result": {"list": []}}
|
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": "spot", "limit": 50},
|
||||||
{"category": "linear", "limit": 50, "settleCoin": "USDC"},
|
{"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")
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ def test_existing_exposure_does_not_cap_position(engine, rules):
|
|||||||
assert result.notional == Decimal(30000)
|
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(
|
@pytest.mark.parametrize(
|
||||||
"overrides, message",
|
"overrides, message",
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user