Add explicit trade sizing

This commit is contained in:
Azimkin
2026-09-22 14:15:00 +02:00
parent 7b632c9640
commit c9d197031e
8 changed files with 213 additions and 20 deletions

View File

@@ -80,6 +80,33 @@ async def test_open_trade_uses_all_available_buying_power_and_allows_repeated_en
await database.close()
async def test_open_trade_accepts_explicit_quote_notional(tmp_path):
database = Database(tmp_path / "state.db")
await database.open()
bybit = FakeBybit()
tools = AgentTools(make_config(database.path), database, bybit) # type: ignore[arg-type]
run_id = await database.start_run("test")
try:
result = await tools.open_trade(
"linear",
"BTCUSDT",
"Buy",
"Market",
90,
"sized entry",
take_profit=120,
leverage=3,
quote_notional=5000,
context={"run_id": run_id},
)
assert result["quantity"] == "50"
assert result["notional"] == "5000"
assert bybit.orders[0]["qty"] == "50"
finally:
await database.close()
async def test_pause_blocks_entry_from_running_session(tmp_path):
database = Database(tmp_path / "state.db")
await database.open()

View File

@@ -5,12 +5,19 @@ from typing import Any
from llm_trade_bot.bybit_gateway import BybitGateway
from llm_trade_bot.config import BybitConfig, ScannerConfig
from llm_trade_bot.models import Category, OpenTradeRequest, OrderType, Side, ValidatedOrder
class FakeBybitHTTP:
def __init__(self, wallet: dict[str, Any] | None = None) -> None:
def __init__(
self,
wallet: dict[str, Any] | None = None,
open_orders: dict[str, list[dict[str, Any]]] | None = None,
) -> None:
self.wallet = wallet
self.open_orders = open_orders or {}
self.open_order_requests: list[dict[str, Any]] = []
self.placed_orders: list[dict[str, Any]] = []
def get_wallet_balance(self, **parameters: Any) -> dict[str, Any]:
assert parameters == {"accountType": "UNIFIED"}
@@ -21,7 +28,14 @@ class FakeBybitHTTP:
def get_open_orders(self, **parameters: Any) -> dict[str, Any]:
self.open_order_requests.append(parameters)
return {"retCode": 0, "result": {"list": []}}
return {
"retCode": 0,
"result": {"list": self.open_orders.get(str(parameters["category"]), [])},
}
def place_order(self, **parameters: Any) -> dict[str, Any]:
self.placed_orders.append(parameters)
return {"retCode": 0, "result": {"orderId": "order-1"}}
async def test_open_orders_scopes_linear_requests_by_settle_coin() -> None:
@@ -44,7 +58,7 @@ async def test_account_snapshot_uses_quote_coin_balance_when_account_available_i
"list": [
{
"totalEquity": "100000",
"totalAvailableBalance": "",
"totalAvailableBalance": "99999",
"totalMarginBalance": "100000",
"coin": [
{
@@ -70,3 +84,80 @@ async def test_account_snapshot_uses_quote_coin_balance_when_account_available_i
assert spot.available_balance == Decimal("49000")
assert linear.available_balance == Decimal("50000")
async def test_account_snapshot_uses_open_spot_orders_when_reported_locked_is_stale() -> None:
wallet = {
"retCode": 0,
"result": {
"list": [
{
"totalEquity": "100005",
"totalAvailableBalance": "50005",
"coin": [
{
"coin": "USDT",
"walletBalance": "100005",
"availableToWithdraw": "",
"totalPositionIM": "0",
"totalOrderIM": "50000",
"locked": "0",
"bonus": "0",
}
],
}
]
},
}
open_orders = {
"spot": [
{
"category": "spot",
"symbol": "BTCUSDT",
"side": "Buy",
"orderStatus": "New",
"leavesQty": "0.586166",
"leavesValue": "49999.96",
"price": "85300",
}
]
}
gateway = BybitGateway(BybitConfig("key", "secret", "USDT"), ScannerConfig(quote_coin="USDT"))
gateway._http = FakeBybitHTTP(wallet, open_orders) # type: ignore[assignment]
snapshot = await gateway.account_snapshot("linear")
assert snapshot.available_balance == Decimal("5.04")
async def test_spot_market_order_sends_base_coin_quantity() -> None:
gateway = BybitGateway(BybitConfig("key", "secret"), ScannerConfig())
http = FakeBybitHTTP()
gateway._http = http # type: ignore[assignment]
request = OpenTradeRequest(
category=Category.SPOT,
symbol="BTCUSDT",
side=Side.BUY,
order_type=OrderType.MARKET,
reference_price=Decimal("100000"),
limit_price=None,
stop_loss=Decimal("90000"),
take_profit=Decimal("110000"),
trailing_distance=None,
leverage=Decimal(1),
rationale="test",
)
order = ValidatedOrder(
entry_price=Decimal("100000"),
quantity=Decimal("0.5"),
notional=Decimal("50000"),
stop_loss=Decimal("90000"),
take_profit=Decimal("110000"),
trailing_distance=None,
leverage=Decimal(1),
)
await gateway.open_trade(request, order, "ltb-test")
assert http.placed_orders[0]["qty"] == "0.5"
assert http.placed_orders[0]["marketUnit"] == "baseCoin"

View File

@@ -59,6 +59,42 @@ def test_existing_exposure_does_not_cap_position(engine, rules):
assert result.notional == Decimal(30000)
def test_explicit_quote_notional_sizes_order(engine, account, rules):
result = engine.validate_open(request(), account, rules, quote_notional=Decimal("5000"))
assert result.quantity == Decimal(50)
assert result.notional == Decimal(5000)
def test_available_balance_percent_sizes_margin_before_leverage(engine, account, rules):
result = engine.validate_open(
request(), account, rules, available_balance_percent=Decimal("25")
)
assert result.quantity == Decimal(75)
assert result.notional == Decimal(7500)
@pytest.mark.parametrize(
"quote_notional, available_balance_percent, message",
[
(Decimal("1"), Decimal("50"), "either"),
(Decimal("0"), None, "quote_notional"),
(None, Decimal("101"), "available_balance_percent"),
(Decimal("30001"), None, "exceeds available buying power"),
],
)
def test_rejects_invalid_explicit_sizing(
engine, account, rules, quote_notional, available_balance_percent, message
):
with pytest.raises(OrderRejected, match=message):
engine.validate_open(
request(),
account,
rules,
quote_notional=quote_notional,
available_balance_percent=available_balance_percent,
)
def test_minimum_quantity_error_reports_sizing_inputs(engine, rules):
empty_account = AccountSnapshot(Decimal(10000), Decimal(0), Decimal(0), 0)