120 lines
3.8 KiB
Python
120 lines
3.8 KiB
Python
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from llm_trade_bot.models import (
|
|
AccountSnapshot,
|
|
Category,
|
|
InstrumentRules,
|
|
OpenTradeRequest,
|
|
OrderType,
|
|
Side,
|
|
)
|
|
from llm_trade_bot.risk import OrderRejected, OrderValidator
|
|
|
|
|
|
@pytest.fixture
|
|
def engine() -> OrderValidator:
|
|
return OrderValidator()
|
|
|
|
|
|
@pytest.fixture
|
|
def account() -> AccountSnapshot:
|
|
return AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(0), 0)
|
|
|
|
|
|
@pytest.fixture
|
|
def rules() -> InstrumentRules:
|
|
return InstrumentRules(Decimal("0.1"), Decimal("0.001"), Decimal("0.001"), Decimal(5), Decimal(100))
|
|
|
|
|
|
def request(**overrides) -> OpenTradeRequest:
|
|
values = {
|
|
"category": Category.LINEAR,
|
|
"symbol": "BTCUSDT",
|
|
"side": Side.BUY,
|
|
"order_type": OrderType.MARKET,
|
|
"reference_price": Decimal(100),
|
|
"limit_price": None,
|
|
"stop_loss": Decimal(90),
|
|
"take_profit": Decimal(120),
|
|
"trailing_distance": None,
|
|
"leverage": Decimal(3),
|
|
"rationale": "test",
|
|
}
|
|
values.update(overrides)
|
|
return OpenTradeRequest(**values)
|
|
|
|
|
|
def test_uses_all_available_buying_power(engine, account, rules):
|
|
result = engine.validate_open(request(), account, rules)
|
|
assert result.quantity == Decimal(300)
|
|
assert result.notional == Decimal(30000)
|
|
|
|
|
|
def test_existing_exposure_does_not_cap_position(engine, rules):
|
|
account = AccountSnapshot(Decimal(10000), Decimal(10000), Decimal(3900), 2)
|
|
result = engine.validate_open(request(), account, rules)
|
|
assert result.quantity == Decimal(300)
|
|
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)
|
|
|
|
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",
|
|
[
|
|
({"leverage": Decimal(101)}, "Leverage"),
|
|
({"stop_loss": Decimal(101)}, "stop-loss"),
|
|
({"take_profit": None, "trailing_distance": None}, "take-profit or trailing"),
|
|
({"category": Category.SPOT, "order_type": OrderType.LIMIT, "limit_price": Decimal(100), "take_profit": None}, "Spot entry requires"),
|
|
],
|
|
)
|
|
def test_rejects_unsafe_requests(engine, account, rules, overrides, message):
|
|
with pytest.raises(OrderRejected, match=message):
|
|
engine.validate_open(request(**overrides), account, rules)
|