triangular_arbitrage_bot/oe_em/opportunity.py

408 lines
14 KiB
Python

"""
Opportunity detection engine.
Evaluates all triangles involving a given symbol on every order-book update,
computes the net return after fees, and fires a signal when the return exceeds
the configured threshold. Supports KCS fee discounts and per-triangle cooldowns
to avoid flooding the executor with duplicate signals.
"""
import asyncio
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Callable
import structlog
from oe_em.book_consumer import BookConsumer
from oe_em.triangle_enum import Triangle, TriangleLeg, TriangleIndex
KCS_FEE_DISCOUNT = 0.8
logger = structlog.get_logger().bind(component="opportunity")
def max_volume_for_triangle(
triangle: Triangle,
book_consumer: BookConsumer,
primary_quote: str,
fee_mult: float = 1.0,
) -> Optional[float]:
"""Compute max volume — kept for backward compatibility, but _build_full now does this inline."""
return None
@dataclass
class OpportunitySignal:
"""
A detected profitable triangular arbitrage opportunity.
Emitted to the signal client when net_return_bps exceeds the threshold.
"""
triangle: Triangle
direction: str
net_return_bps: float
max_volume: float
leg_details: list[dict]
ts_ms: int
book_ts_ms: int
books: list[dict]
@dataclass
class Stats:
"""
Running statistics counters for opportunity evaluation.
Updated on every evaluate_triangles_for_pair call and returned by
get_stats().
"""
triangles_evaluated: int = 0
signals_fired: int = 0
books_missing: int = 0
books_full: int = 0
best_net_bps: float = -999999.0
worst_net_bps: float = 999999.0
last_eval_ts_ms: int = 0
best_legs: str = ""
worst_legs: str = ""
@dataclass
class _EvalResult:
"""
Intermediate result of triangle evaluation.
Attributes
----------
net_return_bps : float
Net return after fees in basis points.
max_volume : float
Maximum safe input volume for the triangle.
leg_details : list[dict]
Per-leg dictionary suitable for serialising into a signal payload.
book_ts_ms : int
Timestamp (ms) of the oldest order book used in the evaluation.
books : list[dict]
Serialised top-of-book for each leg, in order.
"""
net_return_bps: float
max_volume: float
leg_details: list[dict]
book_ts_ms: int
books: list[dict]
def leg_str(self) -> str:
return " -> ".join(
f"{d['pair']}({d['input_currency']}->{d['output_currency']})" for d in self.leg_details
)
class OpportunityEngine:
"""
Detects and reports triangular arbitrage opportunities.
On every order-book update (triggered via the on_update callback) the engine
evaluates every triangle that involves the updated symbol. If the net
return after fees exceeds signal_threshold_bps and the cooldown for that
triangle has elapsed, a signal is dispatched to the executor via the
on_signal callback.
"""
def __init__(
self,
book_consumer: BookConsumer,
triangle_index: TriangleIndex,
signal_threshold_bps: float,
log_path: Path,
kcs_discount_active: bool = False,
cooldown_seconds: float = 5.0,
on_signal: Optional[callable] = None,
) -> None:
self._book_consumer = book_consumer
self._triangle_index = triangle_index
self._threshold_bps = signal_threshold_bps
self._log_path = log_path
self._fee_mult = KCS_FEE_DISCOUNT if kcs_discount_active else 1.0
self._cooldown_seconds = cooldown_seconds
self._last_signal_ts: dict[frozenset[str], float] = {}
self._log = logger
self._stats = Stats()
self._on_signal = on_signal
self._net_cache: dict[frozenset[str], tuple[float, tuple[int, int, int]]] = {}
def _compute_net_only(
self,
triangle: Triangle,
) -> tuple[Optional[float], int]:
"""
Compute net return BPS and min book ts_ms without building books/leg_details.
Returns (net_return_bps, book_ts_ms) or (None, 0) if any book is missing.
Used for fast-path threshold filtering before expensive serialization.
"""
cumulative = 1.0
book_ts_ms = 0
for leg in triangle.legs:
book = self._book_consumer.get_book(leg.pair.symbol)
if not book:
return None, 0
if leg.input_currency == leg.pair.base:
level = book.bids[0] if book.bids else None
if not level:
return None, 0
rate = level.price
else:
level = book.asks[0] if book.asks else None
if not level:
return None, 0
rate = 1.0 / level.price
fee_factor = 1.0 - leg.taker_fee * self._fee_mult
cumulative *= rate * fee_factor
if book_ts_ms == 0 or book.ts_ms < book_ts_ms:
book_ts_ms = book.ts_ms
net_return = (cumulative - 1.0) * 10000
return net_return, book_ts_ms
def _build_full(
self,
triangle: Triangle,
) -> Optional[_EvalResult]:
"""
Single-pass evaluation: compute net return, build leg_details/books,
and compute max_volume — all in one loop over the triangle's legs.
"""
cumulative = 1.0
max_v0_list: list[float] = []
cumulative_mult = 1.0
leg_details = []
books: list[dict] = []
book_ts_ms = 0
for leg in triangle.legs:
book = self._book_consumer.get_book(leg.pair.symbol)
if not book:
return None
if leg.input_currency == leg.pair.base:
level = book.bids[0] if book.bids else None
if not level:
return None
max_input = level.size
rate = level.price
else:
level = book.asks[0] if book.asks else None
if not level:
return None
max_input = level.size * level.price
rate = 1.0 / level.price
fee_factor = 1.0 - leg.taker_fee * self._fee_mult
cumulative *= rate * fee_factor
if cumulative_mult > 0:
max_v0_list.append(max_input / cumulative_mult)
cumulative_mult *= rate * fee_factor
leg_details.append({
"pair": leg.pair.symbol,
"input_currency": leg.input_currency,
"output_currency": leg.output_currency,
"exchange_rate": rate,
"fee_rate": leg.taker_fee,
"fee_currency": leg.pair.fee_currency,
})
books.append({
"symbol": book.symbol,
"bids": [
{"price": b.price, "size": b.size} for b in book.bids
],
"asks": [
{"price": a.price, "size": a.size} for a in book.asks
],
"ts_ms": book.ts_ms,
})
if book_ts_ms == 0 or book.ts_ms < book_ts_ms:
book_ts_ms = book.ts_ms
net_return = (cumulative - 1.0) * 10000
max_volume = min(max_v0_list) if max_v0_list else 0.0
return _EvalResult(
net_return_bps=net_return,
max_volume=max_volume,
leg_details=leg_details,
book_ts_ms=book_ts_ms,
books=books,
)
def evaluate_triangles_for_pair(self, symbol: str) -> list[OpportunitySignal]:
"""
Evaluate all triangles that include the given symbol.
Called by the book consumer's on_update callback whenever an order book
is refreshed. Updates stats, emits signals for triangles that clear the
threshold and cooldown, and returns the list of signals (primarily for
use in tests).
"""
triangles = self._triangle_index.get_triangles_for_pair(symbol)
signals: list[OpportunitySignal] = []
now_ts_ms = int(time.time() * 1000)
for triangle in triangles:
cache_key = triangle.currencies
leg_books = [self._book_consumer.get_book(leg.pair.symbol) for leg in triangle.legs]
if any(b is None for b in leg_books):
self._stats.triangles_evaluated += 1
self._stats.books_missing += 1
self._stats.last_eval_ts_ms = now_ts_ms
continue
current_ts = tuple(b.ts_ms for b in leg_books)
cached = self._net_cache.get(cache_key)
if cached and cached[1] == current_ts:
net_bps = cached[0]
book_ts_ms = min(current_ts)
else:
try:
net_bps, book_ts_ms = self._compute_net_only(triangle)
except Exception as e:
self._log.error("triangle_compute_error", triangle=str(triangle.currencies), error=str(e))
self._stats.triangles_evaluated += 1
self._stats.last_eval_ts_ms = now_ts_ms
continue
if net_bps is not None:
self._net_cache[cache_key] = (net_bps, current_ts)
self._stats.triangles_evaluated += 1
if net_bps is None:
self._stats.books_missing += 1
self._stats.last_eval_ts_ms = now_ts_ms
continue
self._stats.books_full += 1
if net_bps > self._stats.best_net_bps:
self._stats.best_net_bps = net_bps
if net_bps < self._stats.worst_net_bps:
self._stats.worst_net_bps = net_bps
if net_bps <= self._threshold_bps:
self._stats.last_eval_ts_ms = now_ts_ms
continue
try:
result = self._build_full(triangle)
except Exception as e:
self._log.error("triangle_compute_full_error", triangle=str(triangle.currencies), error=str(e))
continue
if result is None:
continue
sig = OpportunitySignal(
triangle=triangle,
direction="forward",
net_return_bps=net_bps,
max_volume=result.max_volume,
leg_details=result.leg_details,
ts_ms=now_ts_ms,
book_ts_ms=result.book_ts_ms,
books=result.books,
)
signals.append(sig)
self._stats.signals_fired += 1
now = time.time()
last = self._last_signal_ts.get(triangle.currencies, 0.0)
if now - last >= self._cooldown_seconds:
self._last_signal_ts[triangle.currencies] = now
self._notify_opportunity(sig)
self._stats.last_eval_ts_ms = now_ts_ms
return signals
def get_stats(self) -> Stats:
"""
Return a snapshot of the current stats counters.
The returned Stats object is a copy; the internal counters continue
to accumulate.
"""
return Stats(
triangles_evaluated=self._stats.triangles_evaluated,
signals_fired=self._stats.signals_fired,
books_missing=self._stats.books_missing,
books_full=self._stats.books_full,
best_net_bps=self._stats.best_net_bps,
worst_net_bps=self._stats.worst_net_bps,
last_eval_ts_ms=self._stats.last_eval_ts_ms,
best_legs=self._stats.best_legs,
worst_legs=self._stats.worst_legs,
)
def _notify_opportunity(self, sig: OpportunitySignal) -> None:
"""
Log the opportunity and dispatch the signal to the executor.
The signal is sent over a Unix-domain socket via the on_signal callback
(which is the send_signal coroutine of SignalSocketClient). A done
callback is attached so any exception raised inside the receiver is
logged rather than propagating silently.
"""
ts = sig.ts_ms
direction = sig.direction
net_bps = sig.net_return_bps
leg_str = " -> ".join(
f"{ld['pair']}({ld['input_currency']}->{ld['output_currency']})" for ld in sig.leg_details
)
msg = (
f"[{ts}] OPPORTUNITY FOUND | "
f"direction={direction} | "
f"net_return={net_bps:.2f} bps | "
f"max_volume={sig.max_volume} | "
f"legs: {leg_str}"
)
self._log.info("opportunity_detected", **{
"ts_ms": ts,
"book_ts_ms": sig.book_ts_ms,
"direction": direction,
"net_return_bps": net_bps,
"legs": leg_str,
"max_volume": str(sig.max_volume),
})
if self._on_signal:
signal_payload = {
"type": "signal",
"correlation_id": str(uuid.uuid4()),
"triangle_key": list(sig.triangle.currencies),
"primary_quote": sig.triangle.primary_quote,
"legs": sig.leg_details,
"predicted_bps": net_bps,
"max_volume": str(sig.max_volume),
"ts_ms": ts,
"book_ts_ms": sig.book_ts_ms,
# books: full top-5 order books per leg — reserved for future
# volume-extension logic (analyze deeper levels to compute how
# far profit shrinks when volume is increased beyond top-of-book).
"books": sig.books,
}
try:
task = asyncio.create_task(self._on_signal(signal_payload))
task.add_done_callback(lambda t: t.exception() and self._log.warning("on_signal_error", error=str(t.exception())))
except Exception as e:
self._log.warning("on_signal_error", error=str(e))