1df4b11aef
CI / lint-test-build (push) Failing after 1m7s
- Introduced new HTML templates for the dashboard, metrics, overview, and backtesting functionalities. - Implemented partial templates for metrics, overview, audit, controls, and charts to enhance modularity. - Updated the Jinja2 template resolution logic to support different deployment environments. - Added a health check template to display the service status. - Included a test suite to verify the template resolution logic. - Updated `pyproject.toml` to include new HTML templates in the package data.
289 lines
11 KiB
Python
289 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Protocol
|
|
|
|
from arbitrade.alerting.notifier import SupportsAlerts
|
|
from arbitrade.detection.engine import OpportunityEvent
|
|
from arbitrade.storage.executions import AsyncExecutionWriter
|
|
from arbitrade.storage.repositories import (
|
|
AuditRecord,
|
|
AuditRepository,
|
|
OrderRecord,
|
|
PnLRecord,
|
|
TradeRecord,
|
|
)
|
|
|
|
|
|
class SupportsOrderPlacement(Protocol):
|
|
async def place_market_order(
|
|
self, *, pair: str, side: str, volume: float
|
|
) -> dict[str, Any]: ...
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ExecutionLeg:
|
|
from_currency: str
|
|
to_currency: str
|
|
pair: str
|
|
side: str
|
|
volume: float
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TriangularExecutionResult:
|
|
success: bool
|
|
requested_legs: tuple[ExecutionLeg, ...]
|
|
completed_legs: int
|
|
responses: tuple[dict[str, Any], ...]
|
|
failure_reason: str | None = None
|
|
|
|
|
|
class TriangularExecutionSequencer:
|
|
def __init__(
|
|
self,
|
|
rest_client: SupportsOrderPlacement,
|
|
*,
|
|
available_pairs: Sequence[str],
|
|
volume_for_leg: Callable[[OpportunityEvent, ExecutionLeg, int], float] | None = None,
|
|
execution_writer: AsyncExecutionWriter | None = None,
|
|
alert_notifier: SupportsAlerts | None = None,
|
|
audit_repository: AuditRepository | None = None,
|
|
) -> None:
|
|
self._rest_client = rest_client
|
|
self._available_pairs = {self._normalize_pair(pair) for pair in available_pairs}
|
|
self._volume_for_leg = volume_for_leg or self._default_volume_for_leg
|
|
self._execution_writer = execution_writer
|
|
self._alert_notifier = alert_notifier
|
|
self._audit_repository = audit_repository
|
|
|
|
@staticmethod
|
|
def _normalize_pair(pair: str) -> str:
|
|
normalized = pair.strip().upper().replace("-", "/")
|
|
if "/" not in normalized:
|
|
return normalized
|
|
base, quote = normalized.split("/", 1)
|
|
return f"{base}/{quote}"
|
|
|
|
@staticmethod
|
|
def _default_volume_for_leg(event: OpportunityEvent, _leg: ExecutionLeg, _idx: int) -> float:
|
|
if event.allocated_capital <= 0.0:
|
|
raise ValueError("allocated_capital must be > 0.0")
|
|
return event.allocated_capital
|
|
|
|
def _resolve_leg(self, from_currency: str, to_currency: str, volume: float) -> ExecutionLeg:
|
|
from_cur = from_currency.upper()
|
|
to_cur = to_currency.upper()
|
|
|
|
buy_pair = f"{to_cur}/{from_cur}"
|
|
if buy_pair in self._available_pairs:
|
|
return ExecutionLeg(
|
|
from_currency=from_cur,
|
|
to_currency=to_cur,
|
|
pair=buy_pair,
|
|
side="buy",
|
|
volume=volume,
|
|
)
|
|
|
|
sell_pair = f"{from_cur}/{to_cur}"
|
|
if sell_pair in self._available_pairs:
|
|
return ExecutionLeg(
|
|
from_currency=from_cur,
|
|
to_currency=to_cur,
|
|
pair=sell_pair,
|
|
side="sell",
|
|
volume=volume,
|
|
)
|
|
|
|
raise ValueError(f"No tradable pair for leg {from_cur}->{to_cur}")
|
|
|
|
def _build_legs(self, event: OpportunityEvent) -> tuple[ExecutionLeg, ...]:
|
|
currencies = [part.strip().upper() for part in event.cycle.split("->") if part.strip()]
|
|
if len(currencies) < 4 or currencies[0] != currencies[-1]:
|
|
raise ValueError("cycle must be a closed triangular path like A->B->C->A")
|
|
|
|
if len(currencies) != 4:
|
|
raise ValueError("cycle must contain exactly three unique currencies")
|
|
|
|
legs: list[ExecutionLeg] = []
|
|
for idx in range(3):
|
|
from_currency = currencies[idx]
|
|
to_currency = currencies[idx + 1]
|
|
placeholder_leg = ExecutionLeg(
|
|
from_currency=from_currency,
|
|
to_currency=to_currency,
|
|
pair="",
|
|
side="buy",
|
|
volume=0.0,
|
|
)
|
|
volume = self._volume_for_leg(event, placeholder_leg, idx)
|
|
if volume <= 0.0:
|
|
raise ValueError("volume_for_leg must return a positive volume")
|
|
legs.append(self._resolve_leg(from_currency, to_currency, volume))
|
|
|
|
return tuple(legs)
|
|
|
|
@staticmethod
|
|
def _trade_ref_for_event(event: OpportunityEvent) -> str:
|
|
material = (
|
|
f"{event.cycle}|{event.updated_pair}|"
|
|
f"{event.detected_at.timestamp():.6f}|"
|
|
f"{event.allocated_capital:.12f}"
|
|
)
|
|
return material.encode("utf-8").hex()[:32]
|
|
|
|
@staticmethod
|
|
def _order_ref_from_response(response: dict[str, Any], default: str) -> str:
|
|
txid = response.get("txid")
|
|
if isinstance(txid, list) and txid:
|
|
return str(txid[0])
|
|
if isinstance(txid, str) and txid.strip():
|
|
return txid
|
|
return default
|
|
|
|
async def execute(self, event: OpportunityEvent) -> TriangularExecutionResult:
|
|
legs = self._build_legs(event)
|
|
responses: list[dict[str, Any]] = []
|
|
trade_ref = self._trade_ref_for_event(event)
|
|
started_at = datetime.now(UTC)
|
|
|
|
for idx, leg in enumerate(legs):
|
|
try:
|
|
response = await self._rest_client.place_market_order(
|
|
pair=leg.pair,
|
|
side=leg.side,
|
|
volume=leg.volume,
|
|
)
|
|
except Exception as exc:
|
|
if self._audit_repository is not None:
|
|
self._audit_repository.insert(
|
|
AuditRecord(
|
|
occurred_at=datetime.now(UTC),
|
|
actor="execution_engine",
|
|
event_type="execution.trade.failed",
|
|
decision="rejected",
|
|
payload={
|
|
"cycle": event.cycle,
|
|
"failed_leg_index": idx,
|
|
"error": str(exc),
|
|
},
|
|
correlation_id=trade_ref,
|
|
)
|
|
)
|
|
if self._alert_notifier is not None:
|
|
await self._alert_notifier.notify(
|
|
category="error",
|
|
severity="error",
|
|
title="Trade execution failed",
|
|
message="Triangular execution failed before completing all legs.",
|
|
details={
|
|
"cycle": event.cycle,
|
|
"failed_leg_index": str(idx),
|
|
"error": str(exc),
|
|
},
|
|
)
|
|
if self._execution_writer is not None:
|
|
await self._execution_writer.enqueue(
|
|
TradeRecord(
|
|
trade_ref=trade_ref,
|
|
started_at=started_at,
|
|
finished_at=datetime.now(UTC),
|
|
status="failed",
|
|
realized_pnl=None,
|
|
estimated_pnl=event.est_profit,
|
|
capital_used=event.allocated_capital,
|
|
cycle=event.cycle,
|
|
leg_count=len(legs),
|
|
)
|
|
)
|
|
return TriangularExecutionResult(
|
|
success=False,
|
|
requested_legs=legs,
|
|
completed_legs=idx,
|
|
responses=tuple(responses),
|
|
failure_reason=str(exc),
|
|
)
|
|
|
|
responses.append(response)
|
|
|
|
if self._execution_writer is not None:
|
|
order_ref = self._order_ref_from_response(response, f"leg-{idx}")
|
|
await self._execution_writer.enqueue(
|
|
OrderRecord(
|
|
trade_ref=trade_ref,
|
|
order_ref=order_ref,
|
|
leg_index=idx,
|
|
pair=leg.pair,
|
|
side=leg.side,
|
|
volume=leg.volume,
|
|
user_ref=None,
|
|
status=str(response.get("status", "submitted")),
|
|
filled_volume=None,
|
|
avg_price=None,
|
|
raw_response=response,
|
|
recorded_at=datetime.now(UTC),
|
|
)
|
|
)
|
|
|
|
if self._execution_writer is not None:
|
|
await self._execution_writer.enqueue(
|
|
TradeRecord(
|
|
trade_ref=trade_ref,
|
|
started_at=started_at,
|
|
finished_at=datetime.now(UTC),
|
|
status="filled",
|
|
realized_pnl=None,
|
|
estimated_pnl=event.est_profit,
|
|
capital_used=event.allocated_capital,
|
|
cycle=event.cycle,
|
|
leg_count=len(legs),
|
|
)
|
|
)
|
|
await self._execution_writer.enqueue(
|
|
PnLRecord(
|
|
trade_ref=trade_ref,
|
|
recorded_at=datetime.now(UTC),
|
|
kind="estimated",
|
|
pnl_usd=event.est_profit,
|
|
source="triangular_sequencer",
|
|
)
|
|
)
|
|
|
|
if self._alert_notifier is not None:
|
|
await self._alert_notifier.notify(
|
|
category="trade",
|
|
severity="warning" if event.est_profit < 0.0 else "info",
|
|
title="Trade execution completed",
|
|
message="Triangular execution completed all requested legs.",
|
|
details={
|
|
"cycle": event.cycle,
|
|
"completed_legs": str(len(legs)),
|
|
"estimated_pnl_usd": f"{event.est_profit}",
|
|
},
|
|
)
|
|
|
|
if self._audit_repository is not None:
|
|
self._audit_repository.insert(
|
|
AuditRecord(
|
|
occurred_at=datetime.now(UTC),
|
|
actor="execution_engine",
|
|
event_type="execution.trade.completed",
|
|
decision="approved",
|
|
payload={
|
|
"cycle": event.cycle,
|
|
"completed_legs": len(legs),
|
|
"estimated_pnl_usd": event.est_profit,
|
|
},
|
|
correlation_id=trade_ref,
|
|
)
|
|
)
|
|
|
|
return TriangularExecutionResult(
|
|
success=True,
|
|
requested_legs=legs,
|
|
completed_legs=len(legs),
|
|
responses=tuple(responses),
|
|
)
|