Files
llm-trade-bot/tests/test_risk.py
2026-09-22 14:15:00 +02:00

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)