Files
llm-trade-bot/src/llm_trade_bot/risk.py
2026-09-22 14:15:00 +02:00

143 lines
6.0 KiB
Python

from __future__ import annotations
from decimal import ROUND_DOWN, Decimal
from llm_trade_bot.models import (
AccountSnapshot,
Category,
InstrumentRules,
OpenTradeRequest,
OrderType,
Side,
ValidatedOrder,
)
class OrderRejected(ValueError):
"""Raised when an order cannot be represented by the selected exchange."""
class OrderValidator:
def validate_open(
self,
request: OpenTradeRequest,
account: AccountSnapshot,
instrument: InstrumentRules,
quote_notional: Decimal | None = None,
available_balance_percent: Decimal | None = None,
) -> ValidatedOrder:
if account.equity <= 0:
raise OrderRejected("Account equity must be positive")
if request.order_type is OrderType.LIMIT and request.limit_price is None:
raise OrderRejected("Limit order requires limit_price")
entry = request.limit_price if request.order_type is OrderType.LIMIT else request.reference_price
if entry is None or entry <= 0 or request.stop_loss <= 0:
raise OrderRejected("Entry and stop-loss prices must be positive")
self._validate_protection(request, entry)
leverage = Decimal(1) if request.category is Category.SPOT else request.leverage
if leverage < 1 or leverage > instrument.max_leverage:
raise OrderRejected(f"Leverage must be between 1 and {instrument.max_leverage}")
if quote_notional is not None and available_balance_percent is not None:
raise OrderRejected("Set either quote_notional or available_balance_percent, not both")
if quote_notional is not None and quote_notional <= 0:
raise OrderRejected("quote_notional must be positive")
if available_balance_percent is not None and not 0 < available_balance_percent <= 100:
raise OrderRejected("available_balance_percent must be in (0, 100]")
maximum_notional = account.available_balance * leverage
if quote_notional is not None:
requested_notional = quote_notional
elif available_balance_percent is not None:
requested_notional = maximum_notional * available_balance_percent / Decimal(100)
else:
requested_notional = maximum_notional
if requested_notional > maximum_notional:
raise OrderRejected(
f"Requested quote notional {requested_notional} exceeds available buying power "
f"{maximum_notional} (available_balance={account.available_balance}, leverage={leverage})"
)
quantity = requested_notional / entry
quantity = _round_down(quantity, instrument.qty_step)
notional = quantity * entry
if quantity < instrument.min_qty:
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(
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)
take_profit = (
_round_price(request.take_profit, instrument.tick_size) if request.take_profit is not None else None
)
trailing = (
_round_price(request.trailing_distance, instrument.tick_size)
if request.trailing_distance is not None
else None
)
rounded_request = OpenTradeRequest(
request.category,
request.symbol,
request.side,
request.order_type,
rounded_entry,
rounded_entry if request.order_type is OrderType.LIMIT else None,
stop,
take_profit,
trailing,
leverage,
request.rationale,
)
self._validate_protection(rounded_request, rounded_entry)
return ValidatedOrder(
rounded_entry,
quantity,
quantity * rounded_entry,
stop,
take_profit,
trailing,
leverage,
)
@staticmethod
def _validate_protection(request: OpenTradeRequest, entry: Decimal) -> None:
if request.side is Side.BUY:
if request.stop_loss >= entry:
raise OrderRejected("Buy stop-loss must be below entry")
if request.take_profit is not None and request.take_profit <= entry:
raise OrderRejected("Buy take-profit must be above entry")
else:
if request.stop_loss <= entry:
raise OrderRejected("Sell stop-loss must be above entry")
if request.take_profit is not None and request.take_profit >= entry:
raise OrderRejected("Sell take-profit must be below entry")
if request.category is Category.SPOT:
if request.take_profit is None:
raise OrderRejected("Spot entry requires take-profit")
if request.trailing_distance is not None:
raise OrderRejected("Trailing stop is not supported for Spot")
elif request.take_profit is None and request.trailing_distance is None:
raise OrderRejected("Linear entry requires take-profit or trailing stop")
if request.trailing_distance is not None and request.trailing_distance <= 0:
raise OrderRejected("Trailing distance must be positive")
def _round_down(value: Decimal, step: Decimal) -> Decimal:
if step <= 0:
raise OrderRejected("Instrument step must be positive")
return (value / step).to_integral_value(rounding=ROUND_DOWN) * step
def _round_price(value: Decimal, tick: Decimal) -> Decimal:
return _round_down(value, tick)