cleanup: remove dead fh_ob/oe_em Python modules, add book_ts_ms to screen output
This commit is contained in:
parent
43333984a3
commit
97b341fec9
|
|
@ -188,6 +188,7 @@ class ExecutionReport:
|
|||
profit: float = 0.0
|
||||
effective_bps: float = 0.0
|
||||
error: str = ""
|
||||
book_ts_ms: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -837,6 +838,7 @@ class Executor:
|
|||
effective_bps=effective_bps,
|
||||
ts_ms=int(time.time() * 1000),
|
||||
timings=timings,
|
||||
book_ts_ms=book_ts_ms,
|
||||
)
|
||||
self._last_trade_ts_ms[in_flight.triangle_key] = report.ts_ms
|
||||
self._emit_report(report)
|
||||
|
|
@ -867,6 +869,7 @@ class Executor:
|
|||
f"{ts_iso} {report.status.upper()} | corr={report.correlation_id} | "
|
||||
f"triangle={report.triangle_key} | predicted_bps={report.predicted_bps:.2f} | "
|
||||
f"effective_bps={report.effective_bps:.2f} | "
|
||||
f"book_ts={report.book_ts_ms} | "
|
||||
f"profit={report.profit:.4f}"
|
||||
f"{f' | error={report.error}' if report.error else ''}",
|
||||
flush=True,
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
"""Feed Handler + Order Book Mirror."""
|
||||
|
||||
from fh_ob.ws_client import KuCoinWSClient
|
||||
from fh_ob.book_store import BookStore, OrderBookTop5, BookLevel
|
||||
from fh_ob.socket_server import SocketServer
|
||||
from fh_ob.rest_server import create_app
|
||||
|
||||
__all__ = [
|
||||
"KuCoinWSClient",
|
||||
"BookStore",
|
||||
"OrderBookTop5",
|
||||
"BookLevel",
|
||||
"SocketServer",
|
||||
"create_app",
|
||||
]
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import asyncio
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
|
||||
from common.config import Settings
|
||||
from common.log import configure_logging
|
||||
from fh_ob.book_store import BookStore
|
||||
from fh_ob.rest_server import create_app
|
||||
from fh_ob.socket_server import SocketServer
|
||||
from fh_ob.ws_client import KuCoinWSClient
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
config_path = Path("config.yaml")
|
||||
settings = await Settings.from_yaml(config_path) if config_path.exists() else Settings()
|
||||
configure_logging(settings.fh_ob.log_level, settings.fh_ob.log_file)
|
||||
log = structlog.get_logger().bind(component="fh_ob")
|
||||
|
||||
log.info("fh_ob_starting", symbols=settings.fh_ob.symbols)
|
||||
|
||||
book_store = BookStore()
|
||||
socket_server = SocketServer(settings.fh_ob.socket_path)
|
||||
|
||||
async def on_book_update(book):
|
||||
try:
|
||||
await socket_server.broadcast(book)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ws_client = KuCoinWSClient(
|
||||
settings=settings.fh_ob,
|
||||
book_store=book_store,
|
||||
on_book_update=on_book_update,
|
||||
)
|
||||
|
||||
rest_app = create_app(
|
||||
book_store,
|
||||
get_socket_clients=socket_server.client_count,
|
||||
get_subscribed_count=ws_client.subscribed_count,
|
||||
is_connected=ws_client.is_connected,
|
||||
add_symbol=ws_client.add_symbol,
|
||||
remove_symbol=ws_client.remove_symbol,
|
||||
get_symbols=ws_client.get_symbols,
|
||||
get_reconnect_stats=ws_client.reconnect_stats,
|
||||
)
|
||||
rest_config = uvicorn.Config(
|
||||
rest_app,
|
||||
host=settings.fh_ob.rest_host,
|
||||
port=settings.fh_ob.rest_port,
|
||||
log_level="warning",
|
||||
)
|
||||
rest_server = uvicorn.Server(rest_config)
|
||||
|
||||
async def shutdown(sig: signal.Signals) -> None:
|
||||
log.info("shutdown_signal_received", signal=sig.name)
|
||||
await ws_client.stop()
|
||||
await socket_server.stop()
|
||||
rest_config.should_exit = True
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(shutdown(s)))
|
||||
|
||||
ws_task = asyncio.create_task(ws_client.start())
|
||||
socket_task = asyncio.create_task(socket_server.start())
|
||||
rest_task = asyncio.create_task(rest_server.serve())
|
||||
|
||||
log.info(
|
||||
"fh_ob_ready",
|
||||
rest_endpoint=f"http://{settings.fh_ob.rest_host}:{settings.fh_ob.rest_port}",
|
||||
socket_path=str(settings.fh_ob.socket_path),
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.gather(ws_task, socket_task, rest_task)
|
||||
except asyncio.CancelledError:
|
||||
log.info("fh_ob_cancelled")
|
||||
except Exception as e:
|
||||
log.error("fh_ob_error", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookLevel:
|
||||
price: float
|
||||
size: float
|
||||
|
||||
@classmethod
|
||||
def from_list(cls, data: list) -> "BookLevel":
|
||||
return cls(price=float(data[0]), size=float(data[1]))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {"price": self.price, "size": self.size}
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderBookTop5:
|
||||
symbol: str
|
||||
bids: list[BookLevel] = field(default_factory=list)
|
||||
asks: list[BookLevel] = field(default_factory=list)
|
||||
ts_ms: int = 0
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"symbol": self.symbol,
|
||||
"bids": [b.to_dict() for b in self.bids],
|
||||
"asks": [a.to_dict() for a in self.asks],
|
||||
"ts_ms": self.ts_ms,
|
||||
}
|
||||
|
||||
|
||||
class BookStore:
|
||||
def __init__(self) -> None:
|
||||
self._books: dict[str, OrderBookTop5] = {}
|
||||
self._log = logger.bind(component="book_store")
|
||||
|
||||
def update(self, raw: dict) -> Optional[OrderBookTop5]:
|
||||
topic = raw.get("topic", "")
|
||||
data = raw.get("data", {})
|
||||
|
||||
topic_suffix = topic.split(":")[-1] if ":" in topic else ""
|
||||
symbol = topic_suffix.split(",")[0].strip() if topic_suffix else ""
|
||||
asks_raw = data.get("asks", [])
|
||||
bids_raw = data.get("bids", [])
|
||||
|
||||
if not symbol:
|
||||
return None
|
||||
|
||||
ts_ms = int(data.get("time", time.time() * 1000))
|
||||
|
||||
bids = [BookLevel.from_list(b) for b in bids_raw[:1]]
|
||||
asks = [BookLevel.from_list(a) for a in asks_raw[:1]]
|
||||
|
||||
book = OrderBookTop5(symbol=symbol, bids=bids, asks=asks, ts_ms=ts_ms)
|
||||
self._books[symbol] = book
|
||||
|
||||
return book
|
||||
|
||||
def get(self, symbol: str) -> Optional[OrderBookTop5]:
|
||||
return self._books.get(symbol)
|
||||
|
||||
def get_all(self) -> dict[str, OrderBookTop5]:
|
||||
return self._books.copy()
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
from typing import Callable, Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fh_ob.book_store import BookStore, OrderBookTop5
|
||||
|
||||
|
||||
class BookLevelResponse(BaseModel):
|
||||
price: str
|
||||
size: str
|
||||
|
||||
|
||||
class OrderBookResponse(BaseModel):
|
||||
symbol: str
|
||||
bids: list[BookLevelResponse]
|
||||
asks: list[BookLevelResponse]
|
||||
ts_ms: int
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
books_tracked: int
|
||||
socket_clients: int
|
||||
subscribed_symbols: int
|
||||
connected: bool
|
||||
last_update_ms: Optional[int] = None
|
||||
reconnect_count: int = 0
|
||||
last_reconnect_ms: Optional[int] = None
|
||||
|
||||
|
||||
class SymbolOpRequest(BaseModel):
|
||||
symbol: str
|
||||
|
||||
|
||||
class SymbolsResponse(BaseModel):
|
||||
symbols: list[str]
|
||||
|
||||
|
||||
def create_app(
|
||||
book_store: BookStore,
|
||||
get_socket_clients: Optional[Callable[[], int]] = None,
|
||||
get_subscribed_count: Optional[Callable[[], int]] = None,
|
||||
is_connected: Optional[Callable[[], bool]] = None,
|
||||
add_symbol: Optional[Callable[[str], bool]] = None,
|
||||
remove_symbol: Optional[Callable[[str], bool]] = None,
|
||||
get_symbols: Optional[Callable[[], list[str]]] = None,
|
||||
get_reconnect_stats: Optional[Callable[[], tuple[int, int]]] = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="FH+OB Debug API", description="Dev-only debug endpoint")
|
||||
|
||||
@app.get("/book/{symbol}", response_model=OrderBookResponse)
|
||||
async def get_book(symbol: str) -> OrderBookResponse:
|
||||
book = book_store.get(symbol)
|
||||
if book is None:
|
||||
raise HTTPException(status_code=404, detail=f"No book data for {symbol}")
|
||||
return OrderBookResponse(
|
||||
symbol=book.symbol,
|
||||
bids=[BookLevelResponse(price=str(b.price), size=str(b.size)) for b in book.bids],
|
||||
asks=[BookLevelResponse(price=str(a.price), size=str(a.size)) for a in book.asks],
|
||||
ts_ms=book.ts_ms,
|
||||
)
|
||||
|
||||
@app.get("/books", response_model=dict[str, OrderBookResponse])
|
||||
async def get_all_books() -> dict[str, OrderBookResponse]:
|
||||
books = book_store.get_all()
|
||||
return {
|
||||
symbol: OrderBookResponse(
|
||||
symbol=book.symbol,
|
||||
bids=[BookLevelResponse(price=str(b.price), size=str(b.size)) for b in book.bids],
|
||||
asks=[BookLevelResponse(price=str(a.price), size=str(a.size)) for a in book.asks],
|
||||
ts_ms=book.ts_ms,
|
||||
)
|
||||
for symbol, book in books.items()
|
||||
}
|
||||
|
||||
@app.get("/symbols")
|
||||
async def list_symbols():
|
||||
return SymbolsResponse(symbols=get_symbols() if get_symbols else [])
|
||||
|
||||
@app.post("/symbols")
|
||||
async def add_sym(req: SymbolOpRequest):
|
||||
if add_symbol and add_symbol(req.symbol):
|
||||
return SymbolsResponse(symbols=get_symbols() if get_symbols else [])
|
||||
raise HTTPException(status_code=400, detail="Symbol not found or already subscribed")
|
||||
|
||||
@app.delete("/symbols/{symbol}")
|
||||
async def rm_sym(symbol: str):
|
||||
if remove_symbol and remove_symbol(symbol):
|
||||
return SymbolsResponse(symbols=get_symbols() if get_symbols else [])
|
||||
raise HTTPException(status_code=404, detail="Symbol not found or not subscribed")
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
books = book_store.get_all()
|
||||
latest_ts = max((b.ts_ms for b in books.values()), default=None)
|
||||
reconnects, last_reconnect_ms = get_reconnect_stats() if get_reconnect_stats else (0, None)
|
||||
|
||||
return HealthResponse(
|
||||
status="ok" if (is_connected and is_connected()) else "degraded",
|
||||
books_tracked=len(books),
|
||||
socket_clients=get_socket_clients() if get_socket_clients else 0,
|
||||
subscribed_symbols=get_subscribed_count() if get_subscribed_count else 0,
|
||||
connected=is_connected() if is_connected else False,
|
||||
last_update_ms=latest_ts,
|
||||
reconnect_count=reconnects,
|
||||
last_reconnect_ms=last_reconnect_ms,
|
||||
)
|
||||
|
||||
return app
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
from typing import Optional
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
from fh_ob.book_store import OrderBookTop5
|
||||
|
||||
|
||||
class SocketServer:
|
||||
def __init__(self, socket_path: Path) -> None:
|
||||
self._socket_path = socket_path
|
||||
self._log = structlog.get_logger().bind(component="socket_server")
|
||||
self._clients: set[asyncio.StreamWriter] = set()
|
||||
self._server: Optional[asyncio.Server] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._socket_path.exists():
|
||||
self._socket_path.unlink()
|
||||
|
||||
self._server = await asyncio.start_unix_server(
|
||||
self._accept_client,
|
||||
path=str(self._socket_path),
|
||||
)
|
||||
self._log.info("socket_server_started", path=str(self._socket_path))
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._server:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
if self._socket_path.exists():
|
||||
self._socket_path.unlink()
|
||||
self._log.info("socket_server_stopped")
|
||||
|
||||
def client_count(self) -> int:
|
||||
return len(self._clients)
|
||||
|
||||
async def _accept_client(
|
||||
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
self._clients.add(writer)
|
||||
self._log.info("client_connected", addr=writer.get_extra_info("peername"))
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
line = await reader.readline()
|
||||
except (ConnectionResetError, BrokenPipeError, asyncio.CancelledError):
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
if not line:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._clients.discard(writer)
|
||||
writer.close()
|
||||
try:
|
||||
await asyncio.wait_for(writer.wait_closed(), timeout=1.0)
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
self._log.info("client_disconnected")
|
||||
|
||||
async def broadcast(self, book: OrderBookTop5) -> None:
|
||||
if not self._clients:
|
||||
return
|
||||
|
||||
msg_bytes = json.dumps(book.to_dict(), separators=(",", ":")).encode() + b"\n"
|
||||
|
||||
clients_snapshot = list(self._clients)
|
||||
bad = set()
|
||||
for w in clients_snapshot:
|
||||
try:
|
||||
w.write(msg_bytes)
|
||||
except Exception as e:
|
||||
self._log.warning("broadcast_write_failed", error=str(e))
|
||||
bad.add(w)
|
||||
|
||||
if not clients_snapshot:
|
||||
return
|
||||
|
||||
drain_results = await asyncio.gather(
|
||||
*(w.drain() for w in clients_snapshot),
|
||||
return_exceptions=True,
|
||||
)
|
||||
for w, res in zip(clients_snapshot, drain_results):
|
||||
if isinstance(res, Exception):
|
||||
self._log.warning("broadcast_drain_failed", error=str(res))
|
||||
bad.add(w)
|
||||
|
||||
self._clients -= bad
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Optional, Awaitable
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
import websockets
|
||||
|
||||
from common.config import FHobSettings
|
||||
from fh_ob.book_store import BookStore, OrderBookTop5
|
||||
|
||||
|
||||
@dataclass
|
||||
class _WorkerState:
|
||||
symbols: set[str] = field(default_factory=set)
|
||||
command_queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
||||
ws_id: int = 0
|
||||
reconnect_count: int = 0
|
||||
last_reconnect_ts_ms: int = 0
|
||||
connection_active: bool = False
|
||||
|
||||
|
||||
class KuCoinWSClient:
|
||||
def __init__(
|
||||
self,
|
||||
settings: FHobSettings,
|
||||
book_store: BookStore,
|
||||
on_book_update: Optional[Callable[[OrderBookTop5], None | Awaitable[None]]] = None,
|
||||
) -> None:
|
||||
self._settings = settings
|
||||
self._book_store = book_store
|
||||
self._on_book_update_callback = on_book_update
|
||||
self._log = structlog.get_logger().bind(component="ws_client")
|
||||
self._running = False
|
||||
self._reconnect_delay = settings.reconnect_base_delay
|
||||
self._subscription_events: dict[str, asyncio.Event] = {}
|
||||
self._workers: list[_WorkerState] = []
|
||||
self._worker_tasks: list[asyncio.Task] = []
|
||||
self._http_session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
self._workers.clear()
|
||||
self._worker_tasks.clear()
|
||||
self._http_session = aiohttp.ClientSession()
|
||||
symbol_list = list(self._settings.symbols)
|
||||
for i in range(0, len(symbol_list) or 1, 400):
|
||||
group = set(symbol_list[i : i + 400])
|
||||
ws_id = len(self._workers) + 1
|
||||
state = _WorkerState(symbols=group, ws_id=ws_id)
|
||||
self._workers.append(state)
|
||||
for state in self._workers:
|
||||
task = asyncio.create_task(self._connection_worker(state))
|
||||
self._worker_tasks.append(task)
|
||||
try:
|
||||
await asyncio.gather(*self._worker_tasks)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._log.debug("all_workers_stopped")
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
for t in self._worker_tasks:
|
||||
t.cancel()
|
||||
if self._worker_tasks:
|
||||
await asyncio.wait(self._worker_tasks, timeout=5)
|
||||
if self._http_session and not self._http_session.closed:
|
||||
await self._http_session.close()
|
||||
self._log.debug("ws_client_stopped")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return any(w.connection_active for w in self._workers)
|
||||
|
||||
def subscribed_count(self) -> int:
|
||||
return sum(len(w.symbols) for w in self._workers)
|
||||
|
||||
def reconnect_stats(self) -> tuple[int, int]:
|
||||
"""Return (total_reconnects, timestamp_ms of last reconnect) across all workers."""
|
||||
total = sum(w.reconnect_count for w in self._workers)
|
||||
latest = max((w.last_reconnect_ts_ms for w in self._workers), default=0)
|
||||
return total, latest
|
||||
|
||||
def get_symbols(self) -> list[str]:
|
||||
result = []
|
||||
for w in self._workers:
|
||||
result.extend(w.symbols)
|
||||
return result
|
||||
|
||||
def add_symbol(self, symbol: str) -> bool:
|
||||
if not self._workers:
|
||||
return False
|
||||
if any(symbol in w.symbols for w in self._workers):
|
||||
return False
|
||||
self._settings.symbols.append(symbol)
|
||||
eligible = [w for w in self._workers if len(w.symbols) < 400]
|
||||
if not eligible:
|
||||
self._log.warning("all_workers_full", symbol=symbol)
|
||||
return False
|
||||
worker = min(eligible, key=lambda w: len(w.symbols))
|
||||
worker.symbols.add(symbol)
|
||||
worker.command_queue.put_nowait(("subscribe", symbol))
|
||||
return True
|
||||
|
||||
def remove_symbol(self, symbol: str) -> bool:
|
||||
found = False
|
||||
for worker in self._workers:
|
||||
if symbol in worker.symbols:
|
||||
worker.symbols.discard(symbol)
|
||||
found = True
|
||||
break
|
||||
if not found:
|
||||
return False
|
||||
self._settings.symbols.remove(symbol)
|
||||
return True
|
||||
|
||||
async def _connection_worker(self, state: _WorkerState) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
token, instance = await self._get_public_token()
|
||||
self._ping_interval = instance.get("pingInterval", 18000) / 1000.0
|
||||
ws = await websockets.connect(
|
||||
instance["endpoint"] + f"?token={token}&connectId={uuid.uuid4()}-{state.ws_id}",
|
||||
ping_interval=None,
|
||||
)
|
||||
self._log.debug("ws_connected", ws_id=state.ws_id)
|
||||
self._reconnect_delay = self._settings.reconnect_base_delay
|
||||
state.connection_active = True
|
||||
|
||||
ping_task = asyncio.create_task(self._ping_loop(ws, state.ws_id))
|
||||
|
||||
async def reader() -> None:
|
||||
try:
|
||||
async for msg in ws:
|
||||
await self._handle_message(msg)
|
||||
except websockets.ConnectionClosed as e:
|
||||
self._log.warning("reader_connection_closed", ws_id=state.ws_id, code=e.code, reason=e.reason)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._log.error("reader_unexpected_error", ws_id=state.ws_id, error=str(e))
|
||||
|
||||
reader_task = asyncio.create_task(reader())
|
||||
|
||||
try:
|
||||
if state.symbols:
|
||||
await self._send_subscribe(ws, list(state.symbols), state.ws_id)
|
||||
|
||||
while True:
|
||||
cmd = await state.command_queue.get()
|
||||
if cmd is None:
|
||||
break
|
||||
action, symbol = cmd
|
||||
if action == "subscribe":
|
||||
self._log.debug(
|
||||
"subscribing_dynamic",
|
||||
symbol=symbol,
|
||||
ws_id=state.ws_id,
|
||||
)
|
||||
await self._send_subscribe(ws, [symbol], state.ws_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except websockets.ConnectionClosed as e:
|
||||
self._log.warning("ws_disconnected", ws_id=state.ws_id, code=e.code, reason=e.reason)
|
||||
except Exception as e:
|
||||
self._log.error("command_loop_error", ws_id=state.ws_id, error=str(e))
|
||||
finally:
|
||||
state.connection_active = False
|
||||
ping_task.cancel()
|
||||
reader_task.cancel()
|
||||
try:
|
||||
await reader_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
if not self._running:
|
||||
break
|
||||
state.connection_active = False
|
||||
state.reconnect_count += 1
|
||||
state.last_reconnect_ts_ms = int(time.time() * 1000)
|
||||
self._log.warning(
|
||||
"ws_reconnecting",
|
||||
ws_id=state.ws_id,
|
||||
reconnect_count=state.reconnect_count,
|
||||
error=str(e),
|
||||
)
|
||||
await asyncio.sleep(self._reconnect_delay)
|
||||
self._reconnect_delay = min(
|
||||
self._reconnect_delay * 2,
|
||||
self._settings.reconnect_max_delay,
|
||||
)
|
||||
|
||||
self._log.debug("worker_exiting", ws_id=state.ws_id)
|
||||
|
||||
async def _get_public_token(self) -> tuple[str, dict]:
|
||||
self._log.debug("fetching_public_token", url=self._settings.token_url)
|
||||
async with self._http_session.post(self._settings.token_url) as resp:
|
||||
data = await resp.json()
|
||||
token = data["data"]["token"]
|
||||
instance = data["data"]["instanceServers"][0]
|
||||
self._log.debug("public_token_received", ping_interval_ms=instance.get("pingInterval"))
|
||||
return token, instance
|
||||
|
||||
async def _send_subscribe(self, ws, symbols: list[str], ws_id: int) -> None:
|
||||
for i in range(0, len(symbols), 100):
|
||||
batch = symbols[i : i + 100]
|
||||
topic = "/spotMarket/level2Depth5:" + ",".join(batch)
|
||||
ack_id = str(uuid.uuid4())
|
||||
evt = asyncio.Event()
|
||||
self._subscription_events[ack_id] = evt
|
||||
sub_msg = {
|
||||
"id": ack_id,
|
||||
"type": "subscribe",
|
||||
"topic": topic,
|
||||
"response": True,
|
||||
}
|
||||
self._log.debug("subscribing", topic=topic[:80], ws_id=ws_id)
|
||||
await ws.send(json.dumps(sub_msg))
|
||||
try:
|
||||
await asyncio.wait_for(evt.wait(), timeout=self._reconnect_delay)
|
||||
except asyncio.TimeoutError:
|
||||
self._log.warning("subscription_ack_timeout", topic=topic[:80], ws_id=ws_id)
|
||||
raise
|
||||
|
||||
async def _ping_loop(self, ws, ws_id: int) -> None:
|
||||
while self._running:
|
||||
await asyncio.sleep(self._ping_interval)
|
||||
try:
|
||||
await ws.ping()
|
||||
except Exception:
|
||||
self._log.warning("ping_failed", ws_id=ws_id)
|
||||
break
|
||||
|
||||
async def _handle_message(self, msg: str) -> None:
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except json.JSONDecodeError:
|
||||
self._log.warning("invalid_json", msg=msg[:100])
|
||||
return
|
||||
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "welcome":
|
||||
self._log.debug("ws_welcome")
|
||||
return
|
||||
|
||||
if msg_type == "pong":
|
||||
return
|
||||
|
||||
if msg_type == "ack":
|
||||
ack_id = data.get("id")
|
||||
self._log.debug("subscription_ack", topic=data.get("topic"), ack_id=ack_id)
|
||||
if ack_id in self._subscription_events:
|
||||
self._subscription_events[ack_id].set()
|
||||
del self._subscription_events[ack_id]
|
||||
return
|
||||
|
||||
topic = data.get("topic", "")
|
||||
|
||||
if msg_type == "message" and "level2Depth5" in topic:
|
||||
book = self._book_store.update(data)
|
||||
if book and self._on_book_update_callback:
|
||||
result = self._on_book_update_callback(book)
|
||||
if asyncio.iscoroutine(result):
|
||||
asyncio.create_task(result)
|
||||
elif topic:
|
||||
self._log.warning("ws_unexpected_message", type=msg_type, topic=topic)
|
||||
|
|
@ -1,223 +0,0 @@
|
|||
"""
|
||||
Opportunity engine entry point.
|
||||
|
||||
Initialises the order-book consumer, triangle index, and signal socket client;
|
||||
starts background tasks for book consumption and periodic stats logging; shuts
|
||||
down cleanly on SIGTERM/SIGINT.
|
||||
"""
|
||||
import asyncio
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
from common.log import configure_logging
|
||||
from oe_em.book_consumer import BookConsumer
|
||||
from oe_em.config import Settings
|
||||
from oe_em.kucoin_api import KuCoinAPI
|
||||
from oe_em.opportunity import OpportunityEngine
|
||||
from oe_em.risk import RiskManager
|
||||
from oe_em.socket_client import SignalSocketClient
|
||||
from oe_em.triangle_enum import TradingPair, enumerate_triangles
|
||||
|
||||
|
||||
async def sync_symbols_with_fh_ob(
|
||||
fh_ob_url: str,
|
||||
needed_symbols: set[str],
|
||||
http_session: aiohttp.ClientSession,
|
||||
log,
|
||||
) -> set[str]:
|
||||
"""
|
||||
Ensure fh_ob is subscribed to every symbol needed by the triangle index.
|
||||
|
||||
Fetches the current subscription list from fh_ob, posts any missing symbols,
|
||||
and returns the full set of subscribed symbols.
|
||||
"""
|
||||
get_url = f"{fh_ob_url}/symbols"
|
||||
async with http_session.get(get_url) as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
|
||||
current_symbols = set(data.get("symbols", []))
|
||||
missing = needed_symbols - current_symbols
|
||||
|
||||
if missing:
|
||||
log.info("syncing_symbols", missing=len(missing), current=len(current_symbols))
|
||||
for sym in missing:
|
||||
post_url = f"{fh_ob_url}/symbols"
|
||||
payload = {"symbol": sym}
|
||||
async with http_session.post(post_url, json=payload) as post_resp:
|
||||
if post_resp.status == 400:
|
||||
log.warning("symbol_cannot_be_subscribed", symbol=sym)
|
||||
else:
|
||||
log.debug("symbol_subscribed", symbol=sym)
|
||||
|
||||
return current_symbols | missing
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
config_path = Path("config.yaml")
|
||||
settings = await Settings.from_yaml(config_path) if config_path.exists() else Settings()
|
||||
configure_logging(settings.oe_em.log_level, settings.oe_em.log_file)
|
||||
|
||||
log = structlog.get_logger().bind(component="oe_em")
|
||||
|
||||
log.info("oe_em_starting")
|
||||
|
||||
api = KuCoinAPI()
|
||||
await api.fetch_pairs_and_fees()
|
||||
|
||||
pair_list = [
|
||||
TradingPair(
|
||||
symbol=p["symbol"],
|
||||
base=p["base"],
|
||||
quote=p["quote"],
|
||||
fee_currency=p.get("fee_currency", ""),
|
||||
)
|
||||
for p in api.get_all_pairs()
|
||||
]
|
||||
excluded = set(settings.oe_em.excluded_currencies)
|
||||
if excluded:
|
||||
pair_list = [p for p in pair_list if p.base not in excluded and p.quote not in excluded]
|
||||
log.info("pairs_loaded", count=len(pair_list), excluded=len(excluded))
|
||||
|
||||
fee_table = api._fee_table
|
||||
triangle_index = enumerate_triangles(
|
||||
pair_list,
|
||||
fee_table,
|
||||
hold_currencies=settings.oe_em.hold_currencies,
|
||||
)
|
||||
log.info("triangles_enumerated", count=len(triangle_index.triangles))
|
||||
|
||||
needed_symbols = set()
|
||||
for tri in triangle_index.triangles:
|
||||
needed_symbols.update(tri.pair_symbols)
|
||||
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
subscribed = await sync_symbols_with_fh_ob(
|
||||
settings.oe_em.fh_ob_url,
|
||||
needed_symbols,
|
||||
http_session,
|
||||
log,
|
||||
)
|
||||
|
||||
book_consumer = BookConsumer(
|
||||
socket_path=settings.oe_em.socket_path,
|
||||
on_update=lambda symbol, book: None,
|
||||
)
|
||||
|
||||
signal_client: SignalSocketClient | None = None
|
||||
signal_reconnect_task: asyncio.Task | None = None
|
||||
|
||||
if settings.oe_em.send_signals:
|
||||
signal_client = SignalSocketClient(socket_path=settings.oe_em.executor_socket_path)
|
||||
|
||||
async def send_signal(signal_payload: dict) -> None:
|
||||
"""Forward a signal payload to the executor's Unix socket."""
|
||||
if signal_client:
|
||||
await signal_client.send_signal(signal_payload)
|
||||
|
||||
opp_engine = OpportunityEngine(
|
||||
book_consumer=book_consumer,
|
||||
triangle_index=triangle_index,
|
||||
signal_threshold_bps=settings.oe_em.signal_threshold_bps,
|
||||
log_path=settings.oe_em.opportunity_log_path,
|
||||
kcs_discount_active=settings.oe_em.kcs_discount_active,
|
||||
cooldown_seconds=settings.oe_em.cooldown_seconds,
|
||||
on_signal=send_signal if settings.oe_em.send_signals else None,
|
||||
)
|
||||
|
||||
risk_mgr = RiskManager()
|
||||
|
||||
async def on_book_update(symbol: str, book) -> None:
|
||||
"""Callback invoked by BookConsumer whenever a subscribed book is refreshed."""
|
||||
if risk_mgr.should_continue():
|
||||
opp_engine.evaluate_triangles_for_pair(symbol)
|
||||
|
||||
book_consumer.set_on_update(on_book_update)
|
||||
|
||||
fh_ob_url = settings.oe_em.fh_ob_url
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
async with http_session.get(f"{fh_ob_url}/symbols") as resp:
|
||||
resp.raise_for_status()
|
||||
data = await resp.json()
|
||||
symbols_now = set(data.get("symbols", []))
|
||||
missing = needed_symbols - symbols_now
|
||||
if missing:
|
||||
log.warning("symbols_not_subscribed_after_sync", count=len(missing))
|
||||
for sym in missing:
|
||||
log.warning("unavailable_symbol", symbol=sym)
|
||||
|
||||
if signal_client:
|
||||
signal_reconnect_task = await signal_client.start()
|
||||
log.info("signal_client_connecting", socket_path=str(settings.oe_em.executor_socket_path))
|
||||
|
||||
log.info(
|
||||
"oe_em_ready",
|
||||
triangles=len(triangle_index.triangles),
|
||||
subscribed=len(symbols_now),
|
||||
threshold_bps=settings.oe_em.signal_threshold_bps,
|
||||
hold_currencies=settings.oe_em.hold_currencies,
|
||||
send_signals=settings.oe_em.send_signals,
|
||||
)
|
||||
|
||||
consumer_task = asyncio.create_task(book_consumer.start())
|
||||
|
||||
async def stats_loop() -> None:
|
||||
"""
|
||||
Periodically log evaluation stats so the operator can monitor the engine.
|
||||
|
||||
Runs until cancelled. Suppressed by setting stats_interval_seconds <= 0.
|
||||
"""
|
||||
interval = settings.oe_em.stats_interval_seconds
|
||||
if interval <= 0:
|
||||
return
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
s = opp_engine.get_stats()
|
||||
books_tracked = sum(
|
||||
1 for t in triangle_index.triangles
|
||||
if book_consumer.get_book(t.legs[0].pair.symbol) is not None
|
||||
)
|
||||
except Exception as e:
|
||||
log.error("stats_error", error=str(e))
|
||||
continue
|
||||
log.info("stats", **{
|
||||
"triangles_evaluated": s.triangles_evaluated,
|
||||
"signals_fired": s.signals_fired,
|
||||
"books_tracked": books_tracked,
|
||||
"subscribed": len(book_consumer._books),
|
||||
"best_net_bps": f"{s.best_net_bps:.2f}",
|
||||
"best_legs": s.best_legs,
|
||||
})
|
||||
|
||||
stats_task = asyncio.create_task(stats_loop())
|
||||
|
||||
def shutdown(sig: signal.Signals) -> None:
|
||||
"""Begin graceful shutdown: stop book consumer, cancel stats, close signal client."""
|
||||
log.info("shutdown_signal_received", signal=sig.name)
|
||||
asyncio.create_task(book_consumer.stop())
|
||||
stats_task.cancel()
|
||||
if signal_reconnect_task:
|
||||
signal_reconnect_task.cancel()
|
||||
if signal_client:
|
||||
asyncio.create_task(signal_client.close())
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, lambda s=sig: shutdown(s))
|
||||
|
||||
tasks = [consumer_task, stats_task]
|
||||
if signal_reconnect_task:
|
||||
tasks.append(signal_reconnect_task)
|
||||
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except asyncio.CancelledError:
|
||||
log.info("oe_em_cancelled")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
"""
|
||||
Order-book consumer for the opportunity engine.
|
||||
|
||||
Connects to fh_ob's Unix-domain socket and receives JSON-serialized top-5
|
||||
order-book snapshots. On each update the registered on_update callback is
|
||||
invoked, which triggers triangle evaluation in OpportunityEngine.
|
||||
|
||||
The consumer maintains an in-memory snapshot of the last seen book for each
|
||||
symbol, accessible via get_book().
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional, Awaitable
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger().bind(component="book_consumer")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookLevel:
|
||||
"""
|
||||
A single price level in an order book.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
price, size : float
|
||||
Price and available size at this level.
|
||||
"""
|
||||
|
||||
price: float
|
||||
size: float
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "BookLevel":
|
||||
return cls(
|
||||
price=float(data["price"]),
|
||||
size=float(data["size"]),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderBookTop5:
|
||||
"""
|
||||
Top-5 bid/ask snapshot for a single symbol.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
symbol : str
|
||||
KuCoin symbol e.g. "BTC-USDT".
|
||||
bids : list[BookLevel]
|
||||
Best bids, most aggressive first.
|
||||
asks : list[BookLevel]
|
||||
Best asks, most aggressive first.
|
||||
ts_ms : int
|
||||
Timestamp (ms) of the snapshot from fh_ob.
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
bids: list[BookLevel] = field(default_factory=list)
|
||||
asks: list[BookLevel] = field(default_factory=list)
|
||||
ts_ms: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict) -> "OrderBookTop5":
|
||||
bids = [BookLevel.from_dict(b) for b in data.get("bids", [])]
|
||||
asks = [BookLevel.from_dict(a) for a in data.get("asks", [])]
|
||||
return cls(
|
||||
symbol=data.get("symbol", ""),
|
||||
bids=bids,
|
||||
asks=asks,
|
||||
ts_ms=data.get("ts_ms", 0),
|
||||
)
|
||||
|
||||
|
||||
class BookConsumer:
|
||||
"""
|
||||
Consumes order-book snapshots from fh_ob and dispatches them to OpportunityEngine.
|
||||
|
||||
Maintains a socket connection until EOF or error, then reconnects
|
||||
automatically. Book updates are pushed to an internal queue; a dedicated
|
||||
worker drains the queue and calls on_update, keeping the reader loop
|
||||
non-blocking.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
socket_path: Path,
|
||||
on_update: Callable[[str, OrderBookTop5], None | Awaitable[None]],
|
||||
) -> None:
|
||||
self._socket_path = socket_path
|
||||
self._on_update = on_update
|
||||
self._running = False
|
||||
self._books: dict[str, OrderBookTop5] = {}
|
||||
self._log = logger
|
||||
self._queue: asyncio.Queue[str] = asyncio.Queue(maxsize=2048)
|
||||
self._queued: set[str] = set()
|
||||
self._worker_task: Optional[asyncio.Task] = None
|
||||
|
||||
def get_book(self, symbol: str) -> Optional[OrderBookTop5]:
|
||||
"""Return the last known book for a symbol, or None if not yet received."""
|
||||
return self._books.get(symbol)
|
||||
|
||||
def set_on_update(self, callback: Callable[[str, OrderBookTop5], None]) -> None:
|
||||
"""Replace the on_update callback. Used when the callback needs a
|
||||
reference to an object that does not yet exist at construction time."""
|
||||
self._on_update = callback
|
||||
|
||||
async def start(self) -> None:
|
||||
"""
|
||||
Connect to fh_ob and run the consume loop until stop() is called.
|
||||
|
||||
On unexpected disconnection a 1-second backoff is applied before
|
||||
reconnecting. Interrupted cleanly by CancelledError.
|
||||
"""
|
||||
self._running = True
|
||||
self._worker_task = asyncio.create_task(self._worker())
|
||||
while self._running:
|
||||
try:
|
||||
await self._connect()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self._log.warning("connection_error", error=str(e))
|
||||
await asyncio.sleep(1.0)
|
||||
if self._worker_task:
|
||||
self._worker_task.cancel()
|
||||
try:
|
||||
await self._worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Request the consume loop to exit on the next iteration."""
|
||||
self._running = False
|
||||
|
||||
async def _worker(self) -> None:
|
||||
"""Drain the update queue and call on_update for each symbol."""
|
||||
while self._running:
|
||||
try:
|
||||
symbol = await asyncio.wait_for(self._queue.get(), timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
self._queued.discard(symbol)
|
||||
book = self._books.get(symbol)
|
||||
if not book:
|
||||
continue
|
||||
try:
|
||||
result = self._on_update(symbol, book)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception as e:
|
||||
self._log.error("on_update_error", symbol=symbol, error=str(e))
|
||||
|
||||
async def _connect(self) -> None:
|
||||
"""
|
||||
Open the Unix socket, read and queue messages until EOF or error.
|
||||
|
||||
Each JSON line is parsed into an OrderBookTop5, stored in self._books,
|
||||
and the symbol is pushed to the queue for the worker to evaluate.
|
||||
The reader never blocks on evaluation.
|
||||
"""
|
||||
reader, writer = await asyncio.open_unix_connection(path=str(self._socket_path))
|
||||
self._log.info("connected", path=str(self._socket_path))
|
||||
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
line = await reader.readline()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
self._log.error("socket_read_error", error=str(e))
|
||||
break
|
||||
|
||||
if not line:
|
||||
self._log.warning("socket_eof")
|
||||
break
|
||||
|
||||
try:
|
||||
data = json.loads(line.decode())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
self._log.warning("invalid_json", line=line[:50], error=str(e))
|
||||
continue
|
||||
|
||||
book = OrderBookTop5.from_json(data)
|
||||
if not book.symbol:
|
||||
continue
|
||||
|
||||
self._books[book.symbol] = book
|
||||
if book.symbol not in self._queued:
|
||||
self._queued.add(book.symbol)
|
||||
try:
|
||||
self._queue.put_nowait(book.symbol)
|
||||
except asyncio.QueueFull:
|
||||
self._queued.discard(book.symbol)
|
||||
|
||||
finally:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
self._log.info("disconnected")
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
"""
|
||||
Configuration schema for the opportunity engine (oe_em).
|
||||
|
||||
Parsed from config.yaml into OeEmSettings. Controls logging, signal
|
||||
thresholds, the fee discount flag, symbol subscription, and the socket
|
||||
path to the executor.
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class OeEmSettings(BaseModel):
|
||||
"""Settings that control oe_em's runtime behaviour."""
|
||||
|
||||
fh_ob_url: str = Field(
|
||||
default="http://127.0.0.1:8000",
|
||||
description="REST URL of fh_ob server",
|
||||
)
|
||||
socket_path: Path = Field(
|
||||
default=Path("/tmp/fh_ob.sock"),
|
||||
description="Unix domain socket path for fh_ob",
|
||||
)
|
||||
log_level: str = Field(default="INFO", description="Logging level")
|
||||
log_file: Path = Field(
|
||||
default=Path("/tmp/oe_em.log"),
|
||||
description="Path to log file. Logs are written here in addition to stdout.",
|
||||
)
|
||||
signal_threshold_bps: float = Field(
|
||||
default=0.2,
|
||||
description="Minimum net return in basis points to fire a signal",
|
||||
)
|
||||
opportunity_log_path: Path = Field(
|
||||
default=Path("/tmp/opportunities.log"),
|
||||
description="Path to log detected opportunities",
|
||||
)
|
||||
stats_interval_seconds: float = Field(
|
||||
default=60.0,
|
||||
description="Seconds between stats log lines. 0 to disable.",
|
||||
)
|
||||
cooldown_seconds: float = Field(
|
||||
default=0.0,
|
||||
description="Deprecated — use executor's in-flight blocking instead. "
|
||||
"Kept here for operational flexibility; set to 0.",
|
||||
)
|
||||
excluded_currencies: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Currencies to exclude from triangle enumeration",
|
||||
)
|
||||
hold_currencies: list[str] = Field(
|
||||
default=["USDT"],
|
||||
description="Currencies held as capital. Only triangles starting and ending in one of these are evaluated.",
|
||||
)
|
||||
kcs_discount_active: bool = Field(
|
||||
default=False,
|
||||
description="If true, all taker fees are multiplied by 0.8 (KCS 20% fee discount)",
|
||||
)
|
||||
executor_socket_path: Path = Field(
|
||||
default=Path("/tmp/executor.sock"),
|
||||
description="Unix domain socket path for executor",
|
||||
)
|
||||
send_signals: bool = Field(
|
||||
default=False,
|
||||
description="If true, emit signals to executor socket when opportunities are found",
|
||||
)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Top-level settings parsed from config.yaml."""
|
||||
|
||||
oe_em: OeEmSettings = Field(default_factory=OeEmSettings)
|
||||
fh_ob_url: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
async def from_yaml(cls, path: Path) -> "Settings":
|
||||
"""Load settings from a YAML file."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _read() -> dict:
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
data = await loop.run_in_executor(None, _read)
|
||||
return cls(**data)
|
||||
|
||||
model_config = {"env_prefix": "TRIArb_", "extra": "ignore"}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
"""
|
||||
KuCoin API client for the opportunity engine.
|
||||
|
||||
Fetches trading pair metadata (symbol, base, quote, fees, feeCurrency)
|
||||
and builds an in-memory fee table keyed by base currency. This data is
|
||||
used to construct the triangle index and to populate fee_currency in
|
||||
signal payloads.
|
||||
"""
|
||||
import aiohttp
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger().bind(component="kucoin_api")
|
||||
|
||||
KUCoin_SYMBOLs_URL = "https://api.kucoin.com/api/v1/symbols"
|
||||
|
||||
DEFAULT_FEES = {
|
||||
"BTC": {"maker": 0.0010, "taker": 0.0010},
|
||||
"ETH": {"maker": 0.0010, "taker": 0.0010},
|
||||
"USDT": {"maker": 0.0010, "taker": 0.0010},
|
||||
"USDC": {"maker": 0.0010, "taker": 0.0010},
|
||||
}
|
||||
|
||||
|
||||
class KuCoinAPI:
|
||||
"""
|
||||
Fetch and cache KuCoin pair metadata and per-currency fee rates.
|
||||
|
||||
Used at startup to build the fee table required by triangle enumeration.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._fee_table: dict[str, dict[str, float]] = {}
|
||||
self._pairs: dict[str, dict] = {}
|
||||
self._log = logger
|
||||
|
||||
async def fetch_pairs_and_fees(self) -> None:
|
||||
"""
|
||||
Fetch all symbols from KuCoin, populate _pairs and _fee_table.
|
||||
|
||||
Logs warnings for any symbol that cannot be parsed and skips it.
|
||||
Sets feeCurrency to the empty string when absent in the API response.
|
||||
"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(KUCoin_SYMBOLs_URL) as resp:
|
||||
resp.raise_for_status()
|
||||
payload = await resp.json()
|
||||
|
||||
for item in payload.get("data", []):
|
||||
symbol = item.get("symbol", "")
|
||||
base = item.get("baseCurrency", "")
|
||||
quote = item.get("quoteCurrency", "")
|
||||
maker_fee = float(item.get("makerFeeRate", 0))
|
||||
taker_fee = float(item.get("takerFeeRate", 0))
|
||||
enable_trading = item.get("enableTrading", False)
|
||||
|
||||
if not all([symbol, base, quote]):
|
||||
continue
|
||||
|
||||
fee_currency = item.get("feeCurrency") or ""
|
||||
self._pairs[symbol] = {
|
||||
"symbol": symbol,
|
||||
"base": base,
|
||||
"quote": quote,
|
||||
"maker_fee": maker_fee,
|
||||
"taker_fee": taker_fee,
|
||||
"enable_trading": enable_trading,
|
||||
"fee_currency": fee_currency,
|
||||
}
|
||||
|
||||
if base not in self._fee_table:
|
||||
self._fee_table[base] = {
|
||||
"maker": maker_fee if maker_fee > 0 else DEFAULT_FEES.get(base, {}).get("maker", 0.0010),
|
||||
"taker": taker_fee if taker_fee > 0 else DEFAULT_FEES.get(base, {}).get("taker", 0.0010),
|
||||
}
|
||||
|
||||
self._log.info("fee_table_loaded", bases=len(self._fee_table), pairs=len(self._pairs))
|
||||
|
||||
def get_fee(self, symbol: str, side: str) -> float:
|
||||
"""
|
||||
Return the taker fee rate for the base currency of a given symbol.
|
||||
|
||||
Falls back to DEFAULT_FEES when the base is not in the fee table.
|
||||
"""
|
||||
if symbol not in self._pairs:
|
||||
return 0.0010
|
||||
base = self._pairs[symbol]["base"]
|
||||
fee_data = self._fee_table.get(base, DEFAULT_FEES.get(base, {"maker": 0.0010, "taker": 0.0010}))
|
||||
return fee_data.get(side, 0.0010)
|
||||
|
||||
def get_pair_info(self, symbol: str) -> dict | None:
|
||||
"""Return the full info dict for a symbol, or None if not loaded."""
|
||||
return self._pairs.get(symbol)
|
||||
|
||||
def get_all_pairs(self) -> list[dict]:
|
||||
"""Return all pairs where enable_trading is True."""
|
||||
return [p for p in self._pairs.values() if p["enable_trading"]]
|
||||
|
|
@ -1,408 +0,0 @@
|
|||
"""
|
||||
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))
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
"""
|
||||
Placeholder risk management module for the opportunity engine.
|
||||
|
||||
The RiskManager provides a hook for future risk checks (position limits,
|
||||
daily loss gates, etc.). Currently it is a pass-through: should_continue()
|
||||
always returns True.
|
||||
"""
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger().bind(component="risk")
|
||||
|
||||
|
||||
class RiskManager:
|
||||
"""
|
||||
Enumerates and checks risk constraints before opportunity evaluation.
|
||||
|
||||
Currently a stub that accepts all opportunities. Replace should_continue()
|
||||
with real checks as needed.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._log = logger
|
||||
|
||||
def should_continue(self) -> bool:
|
||||
"""Return True if evaluation should proceed; False to skip this cycle."""
|
||||
return True
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"""
|
||||
Unix-domain socket client for sending opportunity signals to the executor.
|
||||
|
||||
Connects to the executor's SignalSocketServer and keeps the connection open for
|
||||
burst sending. If the connection is lost the background reconnect loop will
|
||||
retry every 2 seconds. All send operations are non-blocking: a warning is
|
||||
logged if the underlying writer is not connected rather than raising.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger().bind(component="signal_socket_client")
|
||||
|
||||
|
||||
class SignalSocketClient:
|
||||
"""
|
||||
Non-blocking signal sender that maintains a persistent Unix-socket connection.
|
||||
|
||||
The caller invokes send_signal() for each opportunity; the client serialises
|
||||
the payload and writes it to the socket. If the socket is not connected
|
||||
(e.g. after a server restart) the signal is dropped with a warning log.
|
||||
"""
|
||||
|
||||
def __init__(self, socket_path: Path) -> None:
|
||||
self._socket_path = socket_path
|
||||
self._log = logger
|
||||
self._writer: asyncio.StreamWriter | None = None
|
||||
self._running = False
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
|
||||
async def start(self) -> asyncio.Task:
|
||||
"""Start the background reconnect loop and return the task."""
|
||||
self._running = True
|
||||
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
||||
return self._reconnect_task
|
||||
|
||||
async def _reconnect_loop(self) -> None:
|
||||
"""
|
||||
Attempt to connect and then wait for the connection to close.
|
||||
|
||||
On connection failure a 2-second backoff is applied before retrying.
|
||||
The loop exits when self._running becomes False (see close()).
|
||||
"""
|
||||
while self._running:
|
||||
try:
|
||||
reader, writer = await asyncio.open_unix_connection(path=str(self._socket_path))
|
||||
self._writer = writer
|
||||
self._log.info("connected", path=str(self._socket_path))
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
except (ConnectionRefusedError, FileNotFoundError) as e:
|
||||
if not self._running:
|
||||
break
|
||||
self._log.warning("connection_retrying", error=str(e))
|
||||
await asyncio.sleep(2.0)
|
||||
except Exception as e:
|
||||
self._log.error("reconnect_error", error=str(e))
|
||||
await asyncio.sleep(5.0)
|
||||
finally:
|
||||
self._writer = None
|
||||
|
||||
async def send_signal(self, signal: dict) -> None:
|
||||
"""
|
||||
Serialise a signal dict and write it to the socket.
|
||||
|
||||
If the socket is not connected this is a no-op (logged as warning).
|
||||
The correlation_id is assigned here if not already set.
|
||||
"""
|
||||
writer = self._writer
|
||||
if not writer:
|
||||
self._log.warning("not_connected")
|
||||
return
|
||||
|
||||
correlation_id = signal.get("correlation_id", "") or str(uuid.uuid4())
|
||||
signal["correlation_id"] = correlation_id
|
||||
|
||||
msg = json.dumps(signal) + "\n"
|
||||
try:
|
||||
writer.write(msg.encode())
|
||||
await writer.drain()
|
||||
self._log.debug("signal_sent", correlation_id=correlation_id)
|
||||
except Exception as e:
|
||||
self._log.error("signal_send_failed", correlation_id=correlation_id, error=str(e))
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Stop the reconnect loop and close the writer if open.
|
||||
|
||||
Safe to call multiple times; after close() any send_signal() call
|
||||
will be a no-op.
|
||||
"""
|
||||
self._running = False
|
||||
if self._reconnect_task:
|
||||
self._reconnect_task.cancel()
|
||||
try:
|
||||
await self._reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._reconnect_task = None
|
||||
writer = self._writer
|
||||
if writer:
|
||||
self._writer = None
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
"""
|
||||
Triangle enumeration for triangular arbitrage.
|
||||
|
||||
Provides the core data structures (TradingPair, TriangleLeg, Triangle,
|
||||
TriangleIndex) and the enumerate_triangles() function that enumerates all
|
||||
valid triangles from a list of TradingPairs using a fee table.
|
||||
|
||||
A triangle is a directed cycle of three currencies c1 → c2 → c3 → c1 where
|
||||
each leg corresponds to a tradable pair. The hold currency (primary quote)
|
||||
is the currency that enters and exits the cycle; it must be one of the three
|
||||
currencies and is typically USDT or USDC.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger().bind(component="triangle_enum")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TradingPair:
|
||||
"""
|
||||
A single tradable currency pair on KuCoin.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
symbol : str
|
||||
KuCoin symbol e.g. "BTC-USDT".
|
||||
base : str
|
||||
Base currency code e.g. "BTC".
|
||||
quote : str
|
||||
Quote currency code e.g. "USDT".
|
||||
fee_currency : str
|
||||
Currency in which fees are denominated for this pair (from KuCoin's
|
||||
feeCurrency field). Included in signal payloads so the executor
|
||||
can interpret fee amounts correctly.
|
||||
"""
|
||||
|
||||
symbol: str
|
||||
base: str
|
||||
quote: str
|
||||
fee_currency: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_api_response(cls, data: dict) -> Optional["TradingPair"]:
|
||||
"""
|
||||
Parse a KuCoin /symbols API response entry into a TradingPair.
|
||||
|
||||
Returns None if enableTrading is not True or if required fields
|
||||
are missing.
|
||||
"""
|
||||
if data.get("enableTrading") is not True:
|
||||
return None
|
||||
symbol = data.get("symbol", "")
|
||||
base = data.get("baseCurrency", "")
|
||||
quote = data.get("quoteCurrency", "")
|
||||
if not all([symbol, base, quote]):
|
||||
return None
|
||||
return cls(symbol=symbol, base=base, quote=quote, fee_currency=data.get("feeCurrency", ""))
|
||||
|
||||
@property
|
||||
def currency_pair(self) -> frozenset[str]:
|
||||
"""The unordered set of the two currencies in this pair."""
|
||||
return frozenset([self.base, self.quote])
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriangleLeg:
|
||||
"""
|
||||
One directed hop in a triangle: input_currency → output_currency via a pair.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
pair : TradingPair
|
||||
The trading pair used for this leg.
|
||||
input_currency : str
|
||||
Currency entering this leg.
|
||||
output_currency : str
|
||||
Currency leaving this leg.
|
||||
maker_fee, taker_fee : float
|
||||
Fee rates (fraction) for this leg's base currency.
|
||||
"""
|
||||
|
||||
pair: TradingPair
|
||||
input_currency: str
|
||||
output_currency: str
|
||||
maker_fee: float
|
||||
taker_fee: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Triangle:
|
||||
"""
|
||||
A complete triangular arbitrage cycle.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
legs : list[TriangleLeg]
|
||||
The three directed hops (must sum to identity: c1 → c2 → c3 → c1).
|
||||
currencies : frozenset[str]
|
||||
The three distinct currency codes in the cycle.
|
||||
pair_symbols : frozenset[str]
|
||||
The three KuCoin symbols involved.
|
||||
primary_quote : str
|
||||
The hold currency that enters and exits the cycle. All minimum order
|
||||
sizes and volumes are expressed in terms of this currency.
|
||||
"""
|
||||
|
||||
legs: list[TriangleLeg] = field(default_factory=list)
|
||||
currencies: frozenset[str] = field(default_factory=frozenset())
|
||||
pair_symbols: frozenset[str] = field(default_factory=frozenset())
|
||||
primary_quote: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriangleIndex:
|
||||
"""
|
||||
Inverted index of triangles by pair symbol.
|
||||
|
||||
Allows O(1) lookup of all triangles that involve a given symbol, which is
|
||||
the primary query pattern used by OpportunityEngine on every book update.
|
||||
"""
|
||||
|
||||
triangles: list[Triangle] = field(default_factory=list)
|
||||
by_pair: dict[str, list[Triangle]] = field(default_factory=dict)
|
||||
|
||||
def get_triangles_for_pair(self, symbol: str) -> list[Triangle]:
|
||||
"""Return all triangles that include the given symbol."""
|
||||
return self.by_pair.get(symbol, [])
|
||||
|
||||
|
||||
def _build_pair_map(pairs: list[TradingPair]) -> dict[frozenset[str], TradingPair]:
|
||||
"""
|
||||
Build a mapping from unordered currency pair (frozenset) to TradingPair.
|
||||
|
||||
Used to look up pairs by their two currencies regardless of direction.
|
||||
"""
|
||||
pair_map: dict[frozenset[str], TradingPair] = {}
|
||||
for p in pairs:
|
||||
pair_map[p.currency_pair] = p
|
||||
return pair_map
|
||||
|
||||
|
||||
def _build_edge_map(pairs: list[TradingPair]) -> dict[str, list[frozenset[str]]]:
|
||||
"""
|
||||
Build an adjacency map: currency → list of currency pairs involving that currency.
|
||||
|
||||
This is the graph representation used by enumerate_triangles to efficiently
|
||||
find paths of length 2 (c1 → c2 → c3) without enumerating all O(n²) pairs.
|
||||
"""
|
||||
edge_map: dict[str, list[frozenset[str]]] = {}
|
||||
for p in pairs:
|
||||
for c in [p.base, p.quote]:
|
||||
if c not in edge_map:
|
||||
edge_map[c] = []
|
||||
edge_map[c].append(p.currency_pair)
|
||||
return edge_map
|
||||
|
||||
|
||||
def _build_legs(
|
||||
c1: str, c2: str, c3: str,
|
||||
pair_map: dict[frozenset[str], TradingPair],
|
||||
fee_table: dict[str, dict[str, float]],
|
||||
) -> list[TriangleLeg]:
|
||||
"""
|
||||
Construct the three TriangleLegs for the cycle c1 → c2 → c3 → c1.
|
||||
|
||||
Looks up each leg's pair via pair_map (keyed by unordered currencies) and
|
||||
resolves fees from fee_table using the base currency of each pair.
|
||||
"""
|
||||
default_fee = {"maker": 0.0010, "taker": 0.0010}
|
||||
|
||||
def fee_for(base: str, side: str) -> float:
|
||||
return fee_table.get(base, default_fee).get(side, 0.0010)
|
||||
|
||||
p1 = pair_map[frozenset([c1, c2])]
|
||||
p2 = pair_map[frozenset([c2, c3])]
|
||||
p3 = pair_map[frozenset([c3, c1])]
|
||||
|
||||
leg1 = TriangleLeg(
|
||||
pair=p1,
|
||||
input_currency=c1,
|
||||
output_currency=c2,
|
||||
maker_fee=fee_for(p1.base, "maker"),
|
||||
taker_fee=fee_for(p1.base, "taker"),
|
||||
)
|
||||
leg2 = TriangleLeg(
|
||||
pair=p2,
|
||||
input_currency=c2,
|
||||
output_currency=c3,
|
||||
maker_fee=fee_for(p2.base, "maker"),
|
||||
taker_fee=fee_for(p2.base, "taker"),
|
||||
)
|
||||
leg3 = TriangleLeg(
|
||||
pair=p3,
|
||||
input_currency=c3,
|
||||
output_currency=c1,
|
||||
maker_fee=fee_for(p3.base, "maker"),
|
||||
taker_fee=fee_for(p3.base, "taker"),
|
||||
)
|
||||
return [leg1, leg2, leg3]
|
||||
|
||||
|
||||
def enumerate_triangles(
|
||||
pairs: list[TradingPair],
|
||||
fee_table: dict[str, dict[str, float]],
|
||||
hold_currencies: Optional[list[str]] = None,
|
||||
) -> TriangleIndex:
|
||||
"""
|
||||
Enumerate all valid triangular arbitrage cycles from a list of TradingPairs.
|
||||
|
||||
A valid triangle must:
|
||||
- Contain exactly three distinct currencies.
|
||||
- Include at least one hold currency (default: ["USDT"]), which becomes
|
||||
the primary_quote / entry/exit currency.
|
||||
- Have all three legs (c1→c2, c2→c3, c3→c1) represent tradable pairs.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pairs : list[TradingPair]
|
||||
All known trading pairs (filtered to enableTrading == True by the caller).
|
||||
fee_table : dict[str, dict[str, float]]
|
||||
Per-base-currency fee rates as returned by oe_em.kucoin_api.
|
||||
hold_currencies : list[str] or None
|
||||
Currencies that may serve as the entry/exit point. Defaults to ["USDT"].
|
||||
|
||||
Returns
|
||||
-------
|
||||
TriangleIndex
|
||||
Contains all triangles and an inverted index by symbol for fast lookup.
|
||||
"""
|
||||
if hold_currencies is None:
|
||||
hold_currencies = ["USDT"]
|
||||
hold_set = set(hold_currencies)
|
||||
|
||||
pair_map = _build_pair_map(pairs)
|
||||
edge_map = _build_edge_map(pairs)
|
||||
|
||||
triangles: list[Triangle] = []
|
||||
by_pair: dict[str, list[Triangle]] = {}
|
||||
seen: set[frozenset[str]] = set()
|
||||
|
||||
all_currencies = sorted(edge_map.keys())
|
||||
|
||||
for c1 in all_currencies:
|
||||
for c2_edge in edge_map.get(c1, []):
|
||||
c2 = next(x for x in c2_edge if x != c1)
|
||||
for c3_edge in edge_map.get(c2, []):
|
||||
c3 = next(x for x in c3_edge if x != c2)
|
||||
if c3 == c1:
|
||||
continue
|
||||
if c3_edge == c2_edge:
|
||||
continue
|
||||
if frozenset([c1, c3]) not in pair_map:
|
||||
continue
|
||||
currencies = frozenset([c1, c2, c3])
|
||||
if currencies in seen:
|
||||
continue
|
||||
seen.add(currencies)
|
||||
|
||||
in_triangle = hold_set & currencies
|
||||
if not in_triangle:
|
||||
continue
|
||||
|
||||
for hold_curr in in_triangle:
|
||||
others = [c for c in [c1, c2, c3] if c != hold_curr]
|
||||
for x, y in [(others[0], others[1]), (others[1], others[0])]:
|
||||
legs = _build_legs(hold_curr, x, y, pair_map, fee_table)
|
||||
pair_symbols = frozenset([
|
||||
legs[0].pair.symbol,
|
||||
legs[1].pair.symbol,
|
||||
legs[2].pair.symbol,
|
||||
])
|
||||
|
||||
triangle = Triangle(
|
||||
legs=legs,
|
||||
currencies=currencies,
|
||||
pair_symbols=pair_symbols,
|
||||
primary_quote=hold_curr,
|
||||
)
|
||||
triangles.append(triangle)
|
||||
|
||||
for sym in pair_symbols:
|
||||
if sym not in by_pair:
|
||||
by_pair[sym] = []
|
||||
by_pair[sym].append(triangle)
|
||||
|
||||
logger.info("triangles_enumerated", total=len(triangles), indexed_pairs=len(by_pair))
|
||||
|
||||
return TriangleIndex(triangles=triangles, by_pair=by_pair)
|
||||
Loading…
Reference in New Issue