feat: implement autonomous Bybit demo trading agent
This commit is contained in:
145
src/llm_trade_bot/risk.py
Normal file
145
src/llm_trade_bot/risk.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import ROUND_DOWN, Decimal
|
||||
|
||||
from llm_trade_bot.config import RiskConfig
|
||||
from llm_trade_bot.models import (
|
||||
AccountSnapshot,
|
||||
Category,
|
||||
InstrumentRules,
|
||||
OpenTradeRequest,
|
||||
OrderType,
|
||||
Side,
|
||||
ValidatedOrder,
|
||||
)
|
||||
|
||||
|
||||
class RiskRejected(ValueError):
|
||||
"""Raised when a requested order violates a hard safety rule."""
|
||||
|
||||
|
||||
class RiskEngine:
|
||||
def __init__(self, config: RiskConfig):
|
||||
self._config = config
|
||||
|
||||
def validate_open(
|
||||
self,
|
||||
request: OpenTradeRequest,
|
||||
account: AccountSnapshot,
|
||||
instrument: InstrumentRules,
|
||||
) -> ValidatedOrder:
|
||||
if account.equity <= 0:
|
||||
raise RiskRejected("Account equity must be positive")
|
||||
if account.active_ideas >= self._config.max_positions:
|
||||
raise RiskRejected("Maximum number of active ideas reached")
|
||||
if request.category is Category.SPOT and request.side is Side.SELL:
|
||||
raise RiskRejected("Opening spot shorts is not allowed")
|
||||
if request.category is Category.SPOT and request.order_type is OrderType.MARKET:
|
||||
raise RiskRejected("Spot entries must be Limit orders with attached TP/SL")
|
||||
if request.order_type is OrderType.LIMIT and request.limit_price is None:
|
||||
raise RiskRejected("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 RiskRejected("Entry and stop-loss prices must be positive")
|
||||
self._validate_protection(request, entry)
|
||||
|
||||
max_risk = Decimal(str(self._config.max_risk_per_trade_pct))
|
||||
if request.risk_pct <= 0 or request.risk_pct > max_risk:
|
||||
raise RiskRejected(f"risk_pct must be in (0, {max_risk}]")
|
||||
|
||||
configured_leverage = Decimal(str(self._config.max_leverage))
|
||||
allowed_leverage = min(configured_leverage, instrument.max_leverage)
|
||||
leverage = Decimal(1) if request.category is Category.SPOT else request.leverage
|
||||
if leverage < 1 or leverage > allowed_leverage:
|
||||
raise RiskRejected(f"Leverage must be between 1 and {allowed_leverage}")
|
||||
|
||||
risk_amount = account.equity * request.risk_pct / Decimal(100)
|
||||
per_unit_loss = abs(entry - request.stop_loss)
|
||||
if per_unit_loss <= 0:
|
||||
raise RiskRejected("Stop-loss must differ from entry")
|
||||
risk_quantity = risk_amount / per_unit_loss
|
||||
|
||||
exposure_limit = account.equity * Decimal(str(self._config.max_total_exposure_pct)) / Decimal(100)
|
||||
remaining_exposure = exposure_limit - account.gross_exposure
|
||||
if remaining_exposure <= 0:
|
||||
raise RiskRejected("Total exposure limit reached")
|
||||
exposure_quantity = remaining_exposure / entry
|
||||
|
||||
buying_power = account.available_balance * leverage
|
||||
balance_quantity = buying_power / entry
|
||||
quantity = min(risk_quantity, exposure_quantity, balance_quantity)
|
||||
quantity = _round_down(quantity, instrument.qty_step)
|
||||
notional = quantity * entry
|
||||
if quantity < instrument.min_qty:
|
||||
raise RiskRejected("Calculated quantity is below instrument minimum")
|
||||
if notional < instrument.min_notional:
|
||||
raise RiskRejected("Calculated notional is below instrument minimum")
|
||||
|
||||
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,
|
||||
request.risk_pct,
|
||||
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 RiskRejected("Buy stop-loss must be below entry")
|
||||
if request.take_profit is not None and request.take_profit <= entry:
|
||||
raise RiskRejected("Buy take-profit must be above entry")
|
||||
else:
|
||||
if request.stop_loss <= entry:
|
||||
raise RiskRejected("Sell stop-loss must be above entry")
|
||||
if request.take_profit is not None and request.take_profit >= entry:
|
||||
raise RiskRejected("Sell take-profit must be below entry")
|
||||
|
||||
if request.category is Category.SPOT:
|
||||
if request.take_profit is None:
|
||||
raise RiskRejected("Spot entry requires take-profit")
|
||||
if request.trailing_distance is not None:
|
||||
raise RiskRejected("Trailing stop is not supported for Spot")
|
||||
elif request.take_profit is None and request.trailing_distance is None:
|
||||
raise RiskRejected("Linear entry requires take-profit or trailing stop")
|
||||
if request.trailing_distance is not None and request.trailing_distance <= 0:
|
||||
raise RiskRejected("Trailing distance must be positive")
|
||||
|
||||
|
||||
def _round_down(value: Decimal, step: Decimal) -> Decimal:
|
||||
if step <= 0:
|
||||
raise RiskRejected("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)
|
||||
Reference in New Issue
Block a user