140 lines
6.4 KiB
Python
140 lines
6.4 KiB
Python
|
|
"""Trade execution and single-slot FSM."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from datetime import datetime
|
||
|
|
from typing import Any, Dict, List, Optional, Sequence
|
||
|
|
|
||
|
|
from .contracts import DecisionAction, Intent, TradeEvent, TradePosition, TradeSide, TradeStage
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class TradeExecutionResult:
|
||
|
|
"""Result of applying an intent to a trade slot."""
|
||
|
|
|
||
|
|
intent: Intent
|
||
|
|
receipt: Optional[Any]
|
||
|
|
stages: Sequence[TradeStage]
|
||
|
|
position_before: Optional[TradePosition]
|
||
|
|
position_after: Optional[TradePosition]
|
||
|
|
partial_close: bool = False
|
||
|
|
|
||
|
|
|
||
|
|
class TradeExecutor:
|
||
|
|
"""Single-slot trade FSM.
|
||
|
|
|
||
|
|
Owns the live position and translates executable intents into exchange
|
||
|
|
requests and canonical lifecycle stages.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
self.position: Optional[TradePosition] = None
|
||
|
|
self.trade_history: List[TradeEvent] = []
|
||
|
|
|
||
|
|
def execute(self, intent: Intent, exchange: Any, capital_before: float) -> TradeExecutionResult:
|
||
|
|
position_before = self._clone_position(self.position)
|
||
|
|
if intent.action == DecisionAction.ENTER:
|
||
|
|
return self._execute_enter(intent, exchange, capital_before, position_before)
|
||
|
|
if intent.action == DecisionAction.EXIT:
|
||
|
|
return self._execute_exit(intent, exchange, capital_before, position_before)
|
||
|
|
return TradeExecutionResult(
|
||
|
|
intent=intent,
|
||
|
|
receipt=None,
|
||
|
|
stages=(TradeStage.INTENT_CREATED,),
|
||
|
|
position_before=position_before,
|
||
|
|
position_after=self._clone_position(self.position),
|
||
|
|
)
|
||
|
|
|
||
|
|
def apply_fill(self, receipt: Any, intent: Intent) -> None:
|
||
|
|
if receipt is None:
|
||
|
|
return
|
||
|
|
if intent.action == DecisionAction.ENTER and receipt.status == "FILLED":
|
||
|
|
self.position = TradePosition(
|
||
|
|
trade_id=intent.trade_id,
|
||
|
|
asset=intent.asset,
|
||
|
|
side=intent.side,
|
||
|
|
entry_price=receipt.fill_price,
|
||
|
|
entry_time=intent.timestamp,
|
||
|
|
size=receipt.fill_size,
|
||
|
|
leverage=intent.leverage,
|
||
|
|
entry_velocity_divergence=float(
|
||
|
|
intent.metadata.get("entry_velocity_divergence", intent.metadata.get("velocity_divergence", 0.0))
|
||
|
|
),
|
||
|
|
entry_irp_alignment=float(intent.metadata.get("entry_irp_alignment", intent.confidence)),
|
||
|
|
current_price=receipt.fill_price,
|
||
|
|
initial_size=receipt.fill_size,
|
||
|
|
exit_leg_ratios=tuple(intent.exit_leg_ratios),
|
||
|
|
)
|
||
|
|
return
|
||
|
|
if intent.action == DecisionAction.EXIT and self.position is not None and receipt.status == "FILLED":
|
||
|
|
self.position.size = max(0.0, float(receipt.remaining_size))
|
||
|
|
self.position.exit_price = receipt.fill_price
|
||
|
|
self.position.realized_pnl += receipt.realized_pnl
|
||
|
|
self.position.mark_price(receipt.fill_price)
|
||
|
|
self.position.closed = self.position.size <= 1e-12
|
||
|
|
self.position.close_reason = intent.reason if self.position.closed else "PARTIAL_" + intent.reason
|
||
|
|
if self.position.closed:
|
||
|
|
self.position = None
|
||
|
|
|
||
|
|
def _execute_enter(self, intent: Intent, exchange: Any, capital_before: float, position_before: Optional[TradePosition]) -> TradeExecutionResult:
|
||
|
|
if self.position is not None and not self.position.closed:
|
||
|
|
return TradeExecutionResult(intent=intent, receipt=exchange.reject(intent, "POSITION_ALREADY_OPEN"), stages=(TradeStage.ORDER_REQUESTED,), position_before=position_before, position_after=self._clone_position(self.position))
|
||
|
|
receipt = exchange.submit(intent)
|
||
|
|
stages = (TradeStage.ORDER_REQUESTED, TradeStage.ORDER_SENT)
|
||
|
|
if receipt and receipt.status == "FILLED":
|
||
|
|
self.apply_fill(receipt, intent)
|
||
|
|
stages = stages + (TradeStage.ORDER_ACKED, TradeStage.POSITION_OPENED)
|
||
|
|
return TradeExecutionResult(intent=intent, receipt=receipt, stages=stages, position_before=position_before, position_after=self._clone_position(self.position))
|
||
|
|
|
||
|
|
def _execute_exit(self, intent: Intent, exchange: Any, capital_before: float, position_before: Optional[TradePosition]) -> TradeExecutionResult:
|
||
|
|
if self.position is None or self.position.closed:
|
||
|
|
return TradeExecutionResult(intent=intent, receipt=exchange.reject(intent, "NO_OPEN_POSITION"), stages=(TradeStage.EXIT_REQUESTED,), position_before=position_before, position_after=None)
|
||
|
|
receipt = exchange.submit(intent)
|
||
|
|
stages = [TradeStage.EXIT_REQUESTED, TradeStage.EXIT_SENT]
|
||
|
|
if receipt and receipt.status == "FILLED":
|
||
|
|
self.apply_fill(receipt, intent)
|
||
|
|
stages.append(TradeStage.EXIT_ACKED)
|
||
|
|
if self.position is None:
|
||
|
|
stages.extend([TradeStage.POSITION_CLOSED, TradeStage.TRADE_TERMINAL_WRITTEN])
|
||
|
|
else:
|
||
|
|
stages.extend([TradeStage.POSITION_PARTIALLY_CLOSED, TradeStage.POSITION_UPDATED])
|
||
|
|
return TradeExecutionResult(
|
||
|
|
intent=intent,
|
||
|
|
receipt=receipt,
|
||
|
|
stages=tuple(stages),
|
||
|
|
position_before=position_before,
|
||
|
|
position_after=self._clone_position(self.position),
|
||
|
|
partial_close=self.position is not None,
|
||
|
|
)
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _clone_position(position: Optional[TradePosition]) -> Optional[TradePosition]:
|
||
|
|
if position is None:
|
||
|
|
return None
|
||
|
|
return TradePosition(
|
||
|
|
trade_id=position.trade_id,
|
||
|
|
asset=position.asset,
|
||
|
|
side=position.side,
|
||
|
|
entry_price=position.entry_price,
|
||
|
|
entry_time=position.entry_time,
|
||
|
|
size=position.size,
|
||
|
|
leverage=position.leverage,
|
||
|
|
entry_velocity_divergence=position.entry_velocity_divergence,
|
||
|
|
entry_irp_alignment=position.entry_irp_alignment,
|
||
|
|
bars_held=position.bars_held,
|
||
|
|
current_price=position.current_price,
|
||
|
|
realized_pnl=position.realized_pnl,
|
||
|
|
unrealized_pnl=position.unrealized_pnl,
|
||
|
|
exit_price=position.exit_price,
|
||
|
|
closed=position.closed,
|
||
|
|
close_reason=position.close_reason,
|
||
|
|
initial_size=position.initial_size,
|
||
|
|
exit_leg_ratios=position.exit_leg_ratios,
|
||
|
|
exit_leg_index=position.exit_leg_index,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def decision_confidence_from_intent(intent: Intent) -> float:
|
||
|
|
return max(0.0, min(1.0, float(intent.confidence)))
|