Add initial triangular arbitrage bot
Two-process architecture: a C17 fused engine (WebSocket order book mirror, triangle enumeration, real-time profitability evaluation) communicating via Unix domain socket to a Python 3 executor (order placement with paper/live trading modes, REST control API). Targets KuCoin spot market.
This commit is contained in:
commit
2a82086683
|
|
@ -0,0 +1,14 @@
|
|||
.venv/
|
||||
config.yaml
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
*.so
|
||||
.DS_Store
|
||||
deploy.sh
|
||||
deploy_amzn.sh
|
||||
build/
|
||||
triangular_arb.egg-info/
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
# Triangular Arbitrage Bot
|
||||
|
||||
Real-time triangular arbitrage detection and execution for centralized crypto exchanges. Currently supports **KuCoin**.
|
||||
|
||||
## Architecture
|
||||
|
||||
Two-process design communicating via Unix domain sockets:
|
||||
|
||||
| Process | Role |
|
||||
|---|---|
|
||||
| `fused_engine` | C binary combining Feed Handler, Order Book, and Opportunity Engine. KuCoin WebSocket subscriber, order book mirror, triangle enumeration, live opportunity evaluation. Emits signals to executor. |
|
||||
| `executor` | Consumes signals from `fused_engine`, places 3-leg KuCoin REST orders. Fire-and-forget: no re-evaluation, no queue, drop if busy. |
|
||||
|
||||
```
|
||||
[KuCoin WS] ──▶ [fused_engine] ──────────────────── Unix socket ──▶ [executor]
|
||||
│
|
||||
Triangle Evaluator
|
||||
```
|
||||
|
||||
`fused_engine` evaluates triangles on live book updates and emits signals to the executor. The executor does **not** re-evaluate books — it trusts the signal as valid at emission time and places orders directly via KuCoin REST API.
|
||||
|
||||
## Status
|
||||
|
||||
| Component | Status |
|
||||
|---|---|
|
||||
| `fused_engine` | Complete — WebSocket subscription, order book mirror, triangle enumeration, top-level profitability evaluation with chained `max_volume`, signal dispatch via Unix socket |
|
||||
| `executor` | Complete — Signal consumption via Unix socket, paper-mode order validation via `/api/v1/hf/orders/test`, deterministic fill simulation, REST control API |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- C compiler (gcc/clang), CMake 3.22+, OpenSSL, libyaml, pthread
|
||||
- Python 3.11+ (for executor only)
|
||||
|
||||
## Building fused_engine
|
||||
|
||||
```bash
|
||||
mkdir -p build && cd build
|
||||
cmake ../src -DCMAKE_BUILD_TYPE=Release
|
||||
make -j$(nproc)
|
||||
```
|
||||
|
||||
Binary at `build/fused_engine`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `config.yaml`:
|
||||
|
||||
- `hold_currencies` — currencies held as capital. Only triangles starting and ending in one of these are evaluated (default: `["USDT", "USDC", "USD1"]`)
|
||||
- `excluded_currencies` — currencies to exclude from triangle enumeration
|
||||
- `signal_threshold_bps` — minimum net profit in basis points to fire a signal (default: `0.2`)
|
||||
- `cooldown_ms` — minimum milliseconds between opportunity notifications for the same triangle (default: `1000`)
|
||||
- `api.key` / `api.secret` / `api.passphrase` — KuCoin API credentials
|
||||
|
||||
## Running
|
||||
|
||||
**Startup order:** `executor` → `fused_engine`
|
||||
|
||||
### 1. Start the executor
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
python3 -m executor
|
||||
```
|
||||
|
||||
Creates the Unix socket at `/tmp/executor.sock` and listens for signals.
|
||||
|
||||
### 2. Start fused_engine
|
||||
|
||||
```bash
|
||||
./build/fused_engine
|
||||
```
|
||||
|
||||
Fetches all KuCoin pairs, enumerates triangles, subscribes to WebSocket order books, evaluates triangles on every book update, and dispatches signals to the executor via Unix socket.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
tri_arb/
|
||||
├── src/ # C source for fused_engine
|
||||
│ ├── main.c # Entry point
|
||||
│ ├── ws_client.c/h # KuCoin WebSocket client (TLS, masked frames)
|
||||
│ ├── book.c/h # Order book store
|
||||
│ ├── symbols_api.c/h # Symbol discovery, triangle enumeration
|
||||
│ ├── triangle.c/h # Triangle set management
|
||||
│ ├── evaluate.c/h # Triangle evaluation & signal construction
|
||||
│ ├── events.c/h # Event loop, Unix socket client to executor
|
||||
│ ├── queue.c/h # SPSC lock-free queue (hot → cold thread)
|
||||
│ ├── config.c/h # YAML config parser
|
||||
│ ├── http_client.c/h # KuCoin REST API client (HMAC signing)
|
||||
│ ├── hash.c/h # Hash table
|
||||
│ ├── cJSON.c/h # JSON parser
|
||||
│ ├── jsmn.h # Minimal JSON tokenizer (header-only, unused)
|
||||
│ └── CMakeLists.txt
|
||||
├── executor/ # Triangular Arbitrage Executor (Python)
|
||||
│ ├── __main__.py
|
||||
│ ├── executor.py
|
||||
│ ├── kucoin_api.py
|
||||
│ ├── rest_api.py
|
||||
│ ├── socket_server.py
|
||||
│ └── config.py
|
||||
├── common/ # Shared Python utilities
|
||||
│ ├── config.py
|
||||
│ └── log.py
|
||||
├── build/ # Build output (not committed)
|
||||
├── scripts/
|
||||
│ └── install.sh
|
||||
├── config.yaml
|
||||
└── config.yaml.example
|
||||
```
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""Common utilities for triangular arbitrage bot."""
|
||||
|
||||
from common.config import Settings
|
||||
from common.log import configure_logging
|
||||
|
||||
__all__ = ["Settings", "configure_logging"]
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class FHobSettings(BaseModel):
|
||||
symbols: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Trading pairs to subscribe to. Empty = no subscriptions. oe_em adds pairs via REST.",
|
||||
)
|
||||
log_level: str = Field(default="INFO", description="Logging level")
|
||||
log_file: Path = Field(
|
||||
default=Path("/tmp/fh_ob.log"),
|
||||
description="Path to log file. Logs are written here in addition to stdout.",
|
||||
)
|
||||
socket_path: Path = Field(
|
||||
default=Path("/tmp/fh_ob.sock"),
|
||||
description="Unix domain socket path for OE+EM",
|
||||
)
|
||||
rest_host: str = Field(default="0.0.0.0", description="FastAPI debug host")
|
||||
rest_port: int = Field(default=8000, description="FastAPI debug port")
|
||||
ws_url: str = Field(
|
||||
default="wss://ws-api-spot.kucoin.com",
|
||||
description="KuCoin WebSocket endpoint",
|
||||
)
|
||||
token_url: str = Field(
|
||||
default="https://api.kucoin.com/api/v1/bullet-public",
|
||||
description="KuCoin public token endpoint",
|
||||
)
|
||||
reconnect_base_delay: float = Field(
|
||||
default=1.0,
|
||||
description="Base delay for reconnect exponential backoff (seconds)",
|
||||
)
|
||||
reconnect_max_delay: float = Field(
|
||||
default=60.0,
|
||||
description="Max delay for reconnect exponential backoff (seconds)",
|
||||
)
|
||||
heartbeat_interval: float = Field(
|
||||
default=18.0,
|
||||
description="WS ping interval (seconds) - KuCoin uses 18s",
|
||||
)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
fh_ob: FHobSettings = Field(default_factory=FHobSettings)
|
||||
|
||||
@classmethod
|
||||
async def from_yaml(cls, path: Path) -> "Settings":
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _read() -> dict:
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
data = await loop.run_in_executor(None, _read)
|
||||
fh_ob_data = data.get("fh_ob", {})
|
||||
if fh_ob_data.get("symbols") is None:
|
||||
fh_ob_data["symbols"] = []
|
||||
return cls(**data)
|
||||
|
||||
model_config = {"env_prefix": "TRIARB_", "extra": "ignore"}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
"""
|
||||
Shared logging configuration for all components.
|
||||
|
||||
Provides configure_logging() which sets up structlog with JSON output to stdout
|
||||
and an optional plain-text file handler. All components (fh_ob, oe_em, executor)
|
||||
call this at startup before any other logging.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
|
||||
class _AsyncFileHandler(logging.Handler):
|
||||
"""Non-blocking file handler that queues log records for async writing."""
|
||||
|
||||
def __init__(self, filepath: Path) -> None:
|
||||
super().__init__()
|
||||
self._filepath = filepath
|
||||
self._queue: Optional[asyncio.Queue] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
|
||||
def _ensure_loop(self) -> None:
|
||||
if self._queue is None:
|
||||
self._queue = asyncio.Queue(maxsize=4096)
|
||||
loop = asyncio.get_running_loop()
|
||||
self._task = loop.create_task(self._writer_loop())
|
||||
|
||||
async def _writer_loop(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
log_file = self._filepath
|
||||
|
||||
def _write(msg: str) -> None:
|
||||
with open(log_file, "a") as f:
|
||||
f.write(msg + "\n")
|
||||
|
||||
while True:
|
||||
record = await self._queue.get()
|
||||
try:
|
||||
msg = self.format(record)
|
||||
await loop.run_in_executor(None, _write, msg)
|
||||
except Exception:
|
||||
pass
|
||||
self._queue.task_done()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self._ensure_loop()
|
||||
try:
|
||||
self._queue.put_nowait(record)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
"""Override stdlib Handler.close() — no-op, use async _flush() instead."""
|
||||
pass
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Override stdlib Handler.flush() — no-op, queue is non-blocking."""
|
||||
pass
|
||||
|
||||
async def _flush(self) -> None:
|
||||
"""Wait for all queued records to be written."""
|
||||
if self._queue:
|
||||
await self._queue.join()
|
||||
|
||||
|
||||
_async_file_handler: Optional[_AsyncFileHandler] = None
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO", log_file: Path | None = None) -> None:
|
||||
"""
|
||||
Configure structlog with JSON output to stdout and optional file handler.
|
||||
|
||||
Uses stdlib logging as the backend so that standard-library integrations
|
||||
(e.g. uvicorn, aiohttp) produce structured JSON too.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
level : str
|
||||
Log level string (DEBUG, INFO, WARNING, ERROR).
|
||||
log_file : Path or None
|
||||
If set, a FileHandler is added to the root logger writing the
|
||||
same JSON lines to disk.
|
||||
"""
|
||||
global _async_file_handler
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper()),
|
||||
format="%(message)s",
|
||||
handlers=[],
|
||||
)
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(getattr(logging, level.upper()))
|
||||
root_logger.handlers.clear()
|
||||
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
console_handler.setLevel(getattr(logging, level.upper()))
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
structlog.configure(
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
getattr(logging, level.upper())
|
||||
),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
processors=[
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
)
|
||||
|
||||
if log_file:
|
||||
_async_file_handler = _AsyncFileHandler(log_file)
|
||||
_async_file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_async_file_handler.setLevel(getattr(logging, level.upper()))
|
||||
root_logger.addHandler(_async_file_handler)
|
||||
|
||||
root_logger.propagate = False
|
||||
|
||||
|
||||
async def close_logging() -> None:
|
||||
"""Flush and close the async file handler."""
|
||||
global _async_file_handler
|
||||
if _async_file_handler:
|
||||
await _async_file_handler._flush()
|
||||
_async_file_handler = None
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
"""Compare Python triangle enumeration with C output."""
|
||||
import json, sys
|
||||
|
||||
pairs = []
|
||||
with open("/tmp/pairs.jsonl") as f:
|
||||
for line in f:
|
||||
d = json.loads(line)
|
||||
if d.get("enableTrading") is not True:
|
||||
continue
|
||||
base = d.get("base", "")
|
||||
quote = d.get("quote", "")
|
||||
sym = d.get("symbol", "")
|
||||
if not all([sym, base, quote]):
|
||||
continue
|
||||
pairs.append({"symbol": sym, "base": base, "quote": quote})
|
||||
|
||||
# Filter excluded
|
||||
excluded = {"EUR", "BRL"}
|
||||
pairs = [p for p in pairs if p["base"] not in excluded and p["quote"] not in excluded]
|
||||
print(f"Total pairs after filter: {len(pairs)}", file=sys.stderr)
|
||||
|
||||
# Check duplicates
|
||||
from collections import Counter
|
||||
dup_counts = Counter()
|
||||
for p in pairs:
|
||||
key = frozenset([p["base"], p["quote"]])
|
||||
dup_counts[key] += 1
|
||||
dup_multi = {k: v for k, v in dup_counts.items() if v > 1}
|
||||
print(f"Duplicate currency pairs: {len(dup_multi)}", file=sys.stderr)
|
||||
for k, v in dup_multi.items():
|
||||
bases = list(k)
|
||||
my_pairs = [p for p in pairs if frozenset([p["base"], p["quote"]]) == k]
|
||||
print(f" {v}x: {bases} -> {[(p['symbol'], p['base'], p['quote'], p.get('feeCurrency','')) for p in my_pairs]}", file=sys.stderr)
|
||||
|
||||
# Build edge_map and pair_map (Python style)
|
||||
edge_map = {}
|
||||
for p in pairs:
|
||||
for c in [p["base"], p["quote"]]:
|
||||
if c not in edge_map:
|
||||
edge_map[c] = []
|
||||
edge_map[c].append(frozenset([p["base"], p["quote"]]))
|
||||
|
||||
pair_map = {}
|
||||
for p in pairs:
|
||||
pair_map[frozenset([p["base"], p["quote"]])] = p
|
||||
|
||||
hold_set = {"USDT", "USDC", "USD1"}
|
||||
all_currencies = sorted(edge_map.keys())
|
||||
seen = set()
|
||||
tri_count = 0
|
||||
unique_sets = []
|
||||
|
||||
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
|
||||
|
||||
unique_sets.append(tuple(sorted(currencies)))
|
||||
|
||||
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])]:
|
||||
tri_count += 1
|
||||
|
||||
unique_sets.sort()
|
||||
for s in unique_sets:
|
||||
print(",".join(s))
|
||||
|
||||
print(f"\nTOTAL_C_SETS: {len(unique_sets)}")
|
||||
print(f"TOTAL_TRIANGLES: {tri_count}")
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
live_mode: false
|
||||
|
||||
fused_engine:
|
||||
log_level: INFO
|
||||
signal_threshold_bps: 2
|
||||
excluded_currencies: [EUR, BRL]
|
||||
hold_currencies: [USDT, USDC, USD1]
|
||||
send_signals: true
|
||||
ws_url: wss://ws-api-spot.kucoin.com
|
||||
token_url: https://api.kucoin.com/api/v1/bullet-public
|
||||
reconnect_base_delay: 1.0
|
||||
reconnect_max_delay: 60.0
|
||||
heartbeat_interval: 18.0
|
||||
|
||||
executor:
|
||||
fill_timeout_ms: 1000
|
||||
log_level: INFO
|
||||
log_file: /tmp/executor.log
|
||||
socket_path: /tmp/executor.sock
|
||||
concurrent_slots: 1
|
||||
enforce_same_base_isolation: true
|
||||
enforce_pair_isolation: true
|
||||
rest_port: 8002
|
||||
initial_capital:
|
||||
USDT: 5
|
||||
USDC: 5
|
||||
USD1: 5
|
||||
|
||||
kucoin_api_key: ""
|
||||
kucoin_api_secret: ""
|
||||
kucoin_api_passphrase: ""
|
||||
|
|
@ -0,0 +1,766 @@
|
|||
# Fused Engine — Development Plan
|
||||
|
||||
## 1. Objective
|
||||
|
||||
Fuse the `fh_ob` (Feed Handler + Order Book) and `oe_em` (Opportunity Engine + Emission)
|
||||
Python components into a single C binary (`fused_engine`) that eliminates:
|
||||
|
||||
- Two-process IPC overhead (Unix socket serialization/deserialization between fh_ob → oe_em)
|
||||
- Three-way JSON serialization (WS parse → socket serialize → socket deserialize → signal serialize)
|
||||
- Python interpreter overhead on the hot path (object allocation, GIL, asyncio dispatch)
|
||||
- GC pressure from transient `OrderBookTop5`, `BookLevel`, and `dict` allocations
|
||||
|
||||
The **design maxima**: from the moment an order book update arrives, triangle
|
||||
re-evaluation and signal dispatch must be **immediate**. No blocking I/O on the hot path.
|
||||
|
||||
## 2. Current Architecture (for reference)
|
||||
|
||||
### 2.1 Process Layout
|
||||
|
||||
```
|
||||
[KuCoin WS] → [fh_ob] --Unix socket (JSON book snapshots)--> [oe_em] --Unix socket (JSON signals)--> [executor]
|
||||
```
|
||||
|
||||
Three processes, two IPC hops. The critical path is:
|
||||
|
||||
```
|
||||
WS frame arrives at fh_ob
|
||||
→ json.loads() (fh_ob/ws_client.py:239-240)
|
||||
→ BookStore.update() (fh_ob/book_store.py:42-82) [allocates OrderBookTop5, BookLevel objects]
|
||||
→ json.dumps(book) (fh_ob/book_store.py:86-91) [serializes to JSON]
|
||||
→ write to Unix socket (fh_ob/socket_server.py:107-114)
|
||||
→ oe_em reads from socket (oe_em/book_consumer.py:77-93)
|
||||
→ json.loads() (oe_em/book_consumer.py:84-91) [second parse]
|
||||
→ evaluate_triangles_for_pair() (oe_em/opportunity.py:152-230) [Python loop, dict lookups]
|
||||
→ json.dumps(signal) (oe_em/opportunity.py:290-310) [third serialization]
|
||||
→ write to executor socket (oe_em/socket_client.py:33-53)
|
||||
```
|
||||
|
||||
### 2.2 Key Python Files and Line References
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|---|---|---|
|
||||
| `fh_ob/ws_client.py` | 1-148 | KuCoin WS connection, multi-worker model, token fetch, subscribe/unsubscribe |
|
||||
| `fh_ob/ws_client.py` | 149-168 | Dynamic subscription loop (REST-triggered add/remove symbols) |
|
||||
| `fh_ob/ws_client.py` | 199-206 | Public token fetch via `/api/v1/bullet-public` |
|
||||
| `fh_ob/ws_client.py` | 208-227 | Batch subscribe (100 symbols per message, topic `/spotMarket/level2Depth5:...`) |
|
||||
| `fh_ob/ws_client.py` | 238-270 | Message handler: welcome/pong/ack/disconnect/message routing |
|
||||
| `fh_ob/book_store.py` | 18-25 | `BookLevel` dataclass (price, size as `Decimal`) |
|
||||
| `fh_ob/book_store.py` | 27-40 | `OrderBookTop5` dataclass (symbol, ts, sequence, bids/asks as lists) |
|
||||
| `fh_ob/book_store.py` | 42-82 | `BookStore.update()` — parse WS message, allocate objects, store in dict |
|
||||
| `fh_ob/book_store.py` | 84-91 | `to_json()` — serialize book snapshot for IPC |
|
||||
| `fh_ob/socket_server.py` | 1-114 | Unix socket server, broadcast to all connected oe_em clients |
|
||||
| `fh_ob/rest_server.py` | 1-189 | FastAPI REST: `/health`, `/book/{symbol}`, `/symbols` CRUD |
|
||||
| `fh_ob/__main__.py` | 1-196 | Startup: config load, WS client init, dynamic subscription setup, REST server |
|
||||
| `oe_em/book_consumer.py` | 1-93 | Unix socket client, connect to fh_ob, JSON parse, callback dispatch |
|
||||
| `oe_em/opportunity.py` | 47-109 | `FeeTable` — fetch from KuCoin `/api/v1/base-fee`, apply KCS discount |
|
||||
| `oe_em/opportunity.py` | 111-149 | `TriangleEnumerator` — enumerate triangles from pairs, filter by hold currencies |
|
||||
| `oe_em/opportunity.py` | 152-230 | `evaluate_triangles_for_pair()` — hot path: cumulative rate, fee factor, max_volume |
|
||||
| `oe_em/opportunity.py` | 232-288 | `create_signal()` — build signal dict with legs, books, metadata |
|
||||
| `oe_em/opportunity.py` | 290-310 | `format_signal_json()` — serialize signal for executor |
|
||||
| `oe_em/opportunity.py` | 312-330 | `check_cooldown()` — per-triangle cooldown enforcement |
|
||||
| `oe_em/triangle_enum.py` | 1-203 | Standalone triangle enumeration utility (not used at runtime) |
|
||||
| `oe_em/kucoin_api.py` | 1-107 | REST client for fee table, trading pairs fetch |
|
||||
| `oe_em/socket_client.py` | 1-53 | Unix socket client to executor, reconnect loop |
|
||||
| `oe_em/__main__.py` | 1-281 | Startup: config, fee table, triangle enum, book consumer, signal sender |
|
||||
| `executor/executor.py` | 204-227 | `handle_signal()` — entry point, exception safety |
|
||||
| `executor/executor.py` | 229-305 | `_handle_signal_impl()` — pause check, validation, stale rejection, slot check |
|
||||
| `executor/executor.py` | 307-398 | `_precheck_volume()` — backward-propagation minimum calculation |
|
||||
| `executor/executor.py` | 464-852 | `_execute_triangle()` — sequential 3-leg execution, paper/live mode |
|
||||
| `executor/executor.py` | 474-483 | Timing markers: `t-2_book_snapshot`, `t-1_signal_created`, `signal_received` |
|
||||
| `executor/executor.py` | 854-890 | `_emit_report()` — execution report with timing log |
|
||||
| `executor/config.py` | 1-81 | Executor settings: paper mode, concurrency, isolation, initial capital |
|
||||
| `executor/socket_server.py` | 1-90 | Unix socket server receiving signals from oe_em |
|
||||
| `common/config.py` | 1-59 | fh_ob settings (YAML schema): symbols, WS URL, heartbeat, reconnect |
|
||||
| `oe_em/config.py` | 1-84 | oe_em settings (YAML schema): threshold, cooldown, hold currencies, KCS discount |
|
||||
|
||||
### 2.3 KuCoin API References
|
||||
|
||||
| Endpoint / Feature | Doc URL | Notes |
|
||||
|---|---|---|
|
||||
| Public Token (Classic Spot) | https://www.kucoin.com/docs-new/websocket-api/base-info/get-public-token-spot-margin.md | POST `/api/v1/bullet-public`, returns token + `instanceServers` with `pingInterval`/`pingTimeout` |
|
||||
| WS Connection | https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md | `wss://ws-api-spot.kucoin.com?token=...&connectId=...`, welcome message on connect |
|
||||
| WS Heartbeat | https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md | Ping every `pingInterval` (18s), timeout after `pingTimeout` (10s). Any outgoing message resets timeout. |
|
||||
| WS Subscribe | https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md | `{"type":"subscribe","topic":"/spotMarket/level2Depth5:BTC-USDT,...","response":true}` |
|
||||
| WS Unsubscribe | https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md | `{"type":"unsubscribe","topic":"/spotMarket/level2Depth5:BTC-USDT","response":true}` |
|
||||
| WS Message Format | https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md | `{"type":"message","subject":"matchLevel2","topic":"/spotMarket/level2Depth5:SYMBOL","data":{"time":...,"sequence":...,"bids":[...],"asks":[...]}}` |
|
||||
| WS Topic Limit | https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md | **400 topics per connection**. Multiple connections needed for >400 symbols. |
|
||||
| WS Token TTL | https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md | Token valid 24 hours, connection disconnected after 24h. |
|
||||
| Get Symbols | https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-all-symbols.md | GET `/api/v1/symbols`, returns pair metadata (base, quote, min sizes, increments) |
|
||||
| Get Fee Table | https://www.kucoin.com/docs-new/rest/account-info/trade-fee/get-basic-fee-spot-margin.md | GET `/api/v1/base-fee`, returns taker/maker fees per currency |
|
||||
| Order Test | https://www.kucoin.com/docs-new/rest/spot-trading/orders/add-order-test.md | POST `/api/v1/orders/test`, validates order without placing |
|
||||
| Place Order | https://www.kucoin.com/docs-new/rest/spot-trading/orders/add-order.md | POST `/api/v1/orders`, places market/limit order |
|
||||
|
||||
### 2.4 WS Message Format (Classic API)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message",
|
||||
"subject": "matchLevel2",
|
||||
"topic": "/spotMarket/level2Depth5:BTC-USDT",
|
||||
"data": {
|
||||
"time": 1746789012345,
|
||||
"sequence": 123456789,
|
||||
"sequenceNum": 123456789,
|
||||
"bids": [
|
||||
["90701.1", "0.13918404"],
|
||||
["90700.0", "1.00000000"],
|
||||
["90699.5", "2.50000000"],
|
||||
["90698.0", "0.75000000"],
|
||||
["90697.5", "3.20000000"]
|
||||
],
|
||||
"asks": [
|
||||
["90701.2", "0.57715830"],
|
||||
["90702.0", "0.25000000"],
|
||||
["90703.5", "1.10000000"],
|
||||
["90704.0", "0.80000000"],
|
||||
["90705.0", "2.00000000"]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.5 Signal Format (oe_em → executor)
|
||||
|
||||
From `oe_em/opportunity.py:232-310`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "signal",
|
||||
"correlation_id": "abc123",
|
||||
"triangle_key": ["USDT", "BTC", "ETH"],
|
||||
"primary_quote": "USDT",
|
||||
"legs": [
|
||||
{
|
||||
"pair": "BTC-USDT",
|
||||
"input_currency": "USDT",
|
||||
"output_currency": "BTC",
|
||||
"fee_currency": "USDT",
|
||||
"fee_rate": 0.001,
|
||||
"exchange_rate": 0.00001102,
|
||||
"side": "buy"
|
||||
},
|
||||
{
|
||||
"pair": "ETH-BTC",
|
||||
"input_currency": "BTC",
|
||||
"output_currency": "ETH",
|
||||
"fee_currency": "BTC",
|
||||
"fee_rate": 0.001,
|
||||
"exchange_rate": 16.5,
|
||||
"side": "buy"
|
||||
},
|
||||
{
|
||||
"pair": "ETH-USDT",
|
||||
"input_currency": "ETH",
|
||||
"output_currency": "USDT",
|
||||
"fee_currency": "USDT",
|
||||
"fee_rate": 0.001,
|
||||
"exchange_rate": 90701.2,
|
||||
"side": "sell"
|
||||
}
|
||||
],
|
||||
"predicted_bps": 1.50,
|
||||
"max_volume": "100.00",
|
||||
"ts_ms": 1746789012349,
|
||||
"book_ts_ms": 1746789012345,
|
||||
"books": [
|
||||
{
|
||||
"symbol": "BTC-USDT",
|
||||
"bids": [{"price": "90701.1", "size": "0.139"}, ...],
|
||||
"asks": [{"price": "90701.2", "size": "0.577"}, ...],
|
||||
"ts_ms": 1746789012345
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The executor uses `books[i].asks[0].price` or `books[i].bids[0].price` for price lookup
|
||||
in `_precheck_volume()` (executor/executor.py:344-348) and `_execute_triangle()` (executor/executor.py:539-545).
|
||||
|
||||
## 3. Target Architecture
|
||||
|
||||
### 3.1 High-Level Diagram
|
||||
|
||||
```
|
||||
[KuCoin WS] ──▶ [ fused_engine (C binary) ] ──Unix socket──▶ [executor (Python)]
|
||||
│ │
|
||||
│ N WS connections │ triangle_index (precomputed)
|
||||
│ each ≤400 topics │ book[] (fixed array)
|
||||
│ single epoll loop │
|
||||
└── REST API (port 8000) ──┘
|
||||
```
|
||||
|
||||
Single binary replaces fh_ob + oe_em. Executor is **unchanged** except for reading
|
||||
two new timing fields from the signal.
|
||||
|
||||
### 3.2 Threading Model
|
||||
|
||||
Two threads, separated by hot/cold concern:
|
||||
|
||||
```
|
||||
Thread 1 (HOT — evaluation only) Thread 2 (COLD — I/O)
|
||||
───────────────────────────────── ─────────────────────
|
||||
epoll: epoll:
|
||||
[WS fd #1, WS fd #2, ..., WS fd #N] [unix_socket_server_fd,
|
||||
[timerfd for WS pings] rest_server_fd,
|
||||
eventfd_wakeup]
|
||||
|
||||
Incoming WS frame: Drains SPSC ring buffer:
|
||||
1. Decode WS frame (stack-only) → JSON write signal to executor socket
|
||||
2. jsmn parse (0 alloc) → Handle REST API requests
|
||||
3. Update book[sym_idx] in-place → HTTP token refresh, pair fetch
|
||||
4. clock_gettime(CLOCK_MONOTONIC) → t_arrive → WS reconnect I/O
|
||||
5. clock_gettime(CLOCK_MONOTONIC) → t_eval → Dynamic subscribe/unsubscribe
|
||||
6. for tri in tri_index[sym_idx]:
|
||||
compute net return (6 muls, 2 subs)
|
||||
if profitable && cooldown_ok:
|
||||
clock_gettime() → t_signal
|
||||
format signal JSON into fixed buffer
|
||||
push to SPSC ring buffer
|
||||
log latency line to stderr
|
||||
7. Return immediately (never blocks)
|
||||
```
|
||||
|
||||
**Guarantees:**
|
||||
- WS read + triangle evaluation is **never blocked** by signal dispatch or REST traffic
|
||||
- Signal enters SPSC queue within microseconds of detection
|
||||
- Thread 2 stalls → Thread 1 keeps processing WS messages
|
||||
- SPSC push is lock-free: single atomic increment, < 100ns
|
||||
|
||||
### 3.3 SPSC Ring Buffer
|
||||
|
||||
```
|
||||
Thread 1 pushes: atomic increment head → copy signal blob → atomic publish
|
||||
Thread 2 drains: atomic read tail → copy signal blob → atomic increment tail
|
||||
```
|
||||
|
||||
Bounded ring buffer of fixed-size entries (~4KB each, max 1024 entries).
|
||||
`eventfd` used to wake Thread 2 from epoll when buffer transitions from empty to non-empty.
|
||||
|
||||
### 3.4 File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
CMakeLists.txt — build configuration
|
||||
main.c — startup, config parse, thread spawn, signal handling
|
||||
config.c/h — YAML config parser (libyaml), reads fh_ob + oe_em sections
|
||||
hash.c/h — FNV-1a string hash, symbol table (sorted array + bsearch)
|
||||
http_client.c/h — raw HTTP GET/POST over TCP (token, pairs, symbols, fees)
|
||||
http_server.c/h — minimal HTTP/1.1 server (REST API endpoints)
|
||||
hmac.c/h — HMAC-SHA256 via OpenSSL EVP (for private endpoints if needed)
|
||||
queue.c/h — lock-free SPSC ring buffer + eventfd wakeup
|
||||
triangle.c/h — triangle enumeration, index builder, fee table
|
||||
ws_client.c/h — WebSocket frame parser, OpenSSL BIO TLS, subscribe/unsubscribe
|
||||
book.c/h — order book array (fixed-size, 0 alloc updates)
|
||||
evaluate.c/h — triangle evaluation loop (HOT PATH, all inline)
|
||||
signal.c/h — JSON signal formatter, pushes to SPSC queue
|
||||
events.c/h — epoll loop, timerfd, eventfd, signal handling
|
||||
jsmn.h — jsmn library (header-only, dropped in)
|
||||
config.json.example — example config (same schema as config.yaml)
|
||||
```
|
||||
|
||||
### 3.5 Dependencies
|
||||
|
||||
| Library | Purpose | Notes |
|
||||
|---|---|---|
|
||||
| **OpenSSL** | TLS (WS connections), HMAC-SHA256 | System-installed, `find_package(OpenSSL REQUIRED)` |
|
||||
| **libyaml** | Parse `config.yaml` at startup | Startup-only, not on hot path |
|
||||
| **jsmn** | JSON parsing (WS messages, token response, REST requests) | Header-only, 0 allocation, drop `jsmn.h` into `src/` |
|
||||
| **libc** | epoll, timerfd, eventfd, Unix sockets, pthreads, clock_gettime | Linux 6.12+ only |
|
||||
|
||||
### 3.6 Config
|
||||
|
||||
`config.yaml` is **unchanged** — read by both the C binary and the executor.
|
||||
The C binary uses libyaml to parse the `fh_ob` and `oe_em` sections at startup.
|
||||
|
||||
Relevant settings from `common/config.py:9-45` (fh_ob section):
|
||||
- `symbols` — initial symbol list (can be empty, dynamic add via REST)
|
||||
- `log_level` — logging verbosity
|
||||
- `socket_path` — Unix socket path for executor (still `/tmp/fh_ob.sock` for fh_ob→oe_em internal, but the C binary writes directly to executor socket at `/tmp/executor.sock`)
|
||||
- `rest_host`, `rest_port` — REST API bind address/port
|
||||
- `ws_url` — KuCoin WS endpoint (from token response, not hardcoded)
|
||||
- `token_url` — KuCoin public token endpoint
|
||||
- `reconnect_base_delay`, `reconnect_max_delay` — exponential backoff
|
||||
- `heartbeat_interval` — WS ping interval (from token response, default 18s)
|
||||
|
||||
Relevant settings from `oe_em/config.py:16-68` (oe_em section):
|
||||
- `signal_threshold_bps` — minimum net return in bps to fire signal
|
||||
- `hold_currencies` — base currencies for triangle enumeration (default `["USDT"]`)
|
||||
- `excluded_currencies` — currencies to exclude from enumeration
|
||||
- `kcs_discount_active` — multiply taker fees by 0.8
|
||||
- `executor_socket_path` — Unix socket path for executor signals (`/tmp/executor.sock`)
|
||||
- `send_signals` — whether to emit signals to executor
|
||||
|
||||
## 4. Data Structures
|
||||
|
||||
### 4.1 Symbol Table
|
||||
|
||||
```c
|
||||
// Sorted array of symbol entries, indexed by FNV-1a hash → bsearch
|
||||
typedef struct {
|
||||
char name[16]; // e.g. "BTC-USDT\0"
|
||||
uint16_t index; // index into book[] array
|
||||
} symbol_entry_t;
|
||||
|
||||
typedef struct {
|
||||
symbol_entry_t *entries;
|
||||
uint32_t count;
|
||||
} symbol_table_t;
|
||||
|
||||
// O(log N) lookup: hash string → bsearch in sorted array → return book index
|
||||
uint16_t symbol_table_lookup(symbol_table_t *table, const char *name);
|
||||
```
|
||||
|
||||
### 4.2 Order Book
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint16_t symbol_idx;
|
||||
int64_t ts_ms; // KuCoin's "time" field from WS message
|
||||
int64_t sequence; // sequence number for ordering
|
||||
double bids[5][2]; // [level][0] = price, [level][1] = size
|
||||
double asks[5][2];
|
||||
uint8_t bid_count; // actual number of bid levels (0-5)
|
||||
uint8_t ask_count; // actual number of ask levels (0-5)
|
||||
} order_book_t;
|
||||
|
||||
// Fixed-size array, indexed by symbol index
|
||||
// book[symbol_idx] gives O(1) access, cache-friendly
|
||||
#define MAX_SYMBOLS 2048
|
||||
order_book_t books[MAX_SYMBOLS];
|
||||
```
|
||||
|
||||
### 4.3 Triangle
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
uint16_t symbol_idx[3]; // book[] indices for each leg
|
||||
uint8_t use_bid[3]; // 1 = read bid (sell), 0 = read ask (buy)
|
||||
double fee_factor[3]; // precomputed: (1.0 - taker_fee * kcs_discount)
|
||||
uint16_t id; // unique triangle ID (for cooldown tracking)
|
||||
uint8_t currency_ids[3]; // compact currency identifiers
|
||||
uint8_t primary_quote_id; // index into hold_currencies[]
|
||||
char symbol_names[3][16];// symbol strings for signal JSON
|
||||
char base[16]; // base currency (e.g., "USDT")
|
||||
char mid[16]; // mid currency (e.g., "BTC")
|
||||
char quote[16]; // quote currency (e.g., "ETH")
|
||||
} triangle_t;
|
||||
```
|
||||
|
||||
### 4.4 Triangle Index
|
||||
|
||||
```c
|
||||
// For each symbol, offset/count into the flat triangle[] array
|
||||
// Triangles are sorted by their first symbol index, then second, then third
|
||||
// so all triangles containing a given symbol are contiguous
|
||||
typedef struct {
|
||||
uint32_t offset;
|
||||
uint32_t count;
|
||||
} tri_index_entry_t;
|
||||
|
||||
tri_index_entry_t tri_index[MAX_SYMBOLS];
|
||||
```
|
||||
|
||||
### 4.5 Signal (SPSC Queue Entry)
|
||||
|
||||
```c
|
||||
#define SIGNAL_MAX_SIZE 4096
|
||||
|
||||
typedef struct {
|
||||
char data[SIGNAL_MAX_SIZE]; // pre-formatted JSON
|
||||
uint32_t len;
|
||||
int64_t ts_ms; // signal creation timestamp
|
||||
} signal_entry_t;
|
||||
|
||||
#define SPSC_CAPACITY 1024
|
||||
signal_entry_t ring[SPSC_CAPACITY];
|
||||
atomic_uint head; // writer (Thread 1)
|
||||
atomic_uint tail; // reader (Thread 2)
|
||||
```
|
||||
|
||||
### 4.6 Cooldown State
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
int64_t last_signal_ms; // timestamp of last fired signal (ms)
|
||||
} cooldown_t;
|
||||
|
||||
cooldown_t cooldowns[MAX_TRIANGLES];
|
||||
```
|
||||
|
||||
## 5. Hot Path (evaluate.c)
|
||||
|
||||
The hot path runs inline in the WS message handler on Thread 1. Zero heap
|
||||
allocation. All data accessed by index into fixed arrays.
|
||||
|
||||
```c
|
||||
// Called from ws_client.c message handler, immediately after book update
|
||||
// sym_idx is the index of the symbol that just updated
|
||||
void evaluate_and_signal(uint16_t sym_idx) {
|
||||
// Timestamp: arrival (after WS decode + book update)
|
||||
struct timespec ts_arrive;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts_arrive);
|
||||
int64_t t_arrive_ms = ts_arrive.tv_sec * 1000 + ts_arrive.tv_nsec / 1e6;
|
||||
|
||||
tri_index_entry_t *idx = &tri_index[sym_idx];
|
||||
int64_t book_ts_ms = books[sym_idx].ts_ms;
|
||||
|
||||
for (uint32_t i = 0; i < idx->count; i++) {
|
||||
triangle_t *tri = &triangles[idx->offset + i];
|
||||
|
||||
// Timestamp: evaluation start
|
||||
struct timespec ts_eval;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts_eval);
|
||||
int64_t t_eval_ms = ts_eval.tv_sec * 1000 + ts_eval.tv_nsec / 1e6;
|
||||
|
||||
// Compute cumulative rate through all 3 legs
|
||||
double cum = 1.0;
|
||||
for (int leg = 0; leg < 3; leg++) {
|
||||
order_book_t *b = &books[tri->symbol_idx[leg]];
|
||||
double rate = tri->use_bid[leg]
|
||||
? b->bids[0][0] // sell leg: use best bid
|
||||
: 1.0 / b->asks[0][0]; // buy leg: use best ask (invert)
|
||||
cum *= rate * tri->fee_factor[leg];
|
||||
}
|
||||
|
||||
double net_bps = (cum - 1.0) * 10000.0;
|
||||
|
||||
if (net_bps > signal_threshold_bps
|
||||
&& cooldown_ok(tri->id)
|
||||
&& max_volume_ok(tri)) {
|
||||
|
||||
// Compute max_volume (chained minimum across 3 legs)
|
||||
double max_vol = compute_max_volume(tri);
|
||||
|
||||
// Timestamp: signal creation
|
||||
struct timespec ts_signal;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts_signal);
|
||||
int64_t t_signal_ms = ts_signal.tv_sec * 1000 + ts_signal.tv_nsec / 1e6;
|
||||
|
||||
// Format signal JSON into fixed buffer
|
||||
char buf[SIGNAL_MAX_SIZE];
|
||||
int len = format_signal(buf, sizeof(buf), tri, net_bps, max_vol,
|
||||
t_arrive_ms, t_eval_ms, t_signal_ms, book_ts_ms);
|
||||
|
||||
// Push to SPSC queue (lock-free, never blocks)
|
||||
spsc_push(buf, len, t_signal_ms);
|
||||
|
||||
// Log latency line (to stderr, not disk)
|
||||
fprintf(stderr,
|
||||
"SIGNAL corr=%s sym=%s tri=%s/%s/%s bps=%.2f "
|
||||
"t_exchange=%ld t_arrive=%ld t_eval=%ld t_signal=%ld\n",
|
||||
correlation_id, tri->symbol_names[0],
|
||||
tri->base, tri->mid, tri->quote,
|
||||
net_bps,
|
||||
(long)book_ts_ms, (long)t_arrive_ms,
|
||||
(long)t_eval_ms, (long)t_signal_ms);
|
||||
|
||||
set_cooldown(tri->id, t_signal_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Performance characteristics:**
|
||||
- Per triangle: 6 double multiplications, 2 subtractions, 3 array lookups
|
||||
- No heap allocation, no function call overhead (all `static inline`)
|
||||
- `clock_gettime(CLOCK_MONOTONIC)` is ~10ns on modern Linux
|
||||
- Worst case for a symbol in 50 triangles: ~50 × 1µs = 50µs total eval time
|
||||
|
||||
## 6. Signal JSON Format
|
||||
|
||||
Same as current oe_em → executor format, with two new timing fields.
|
||||
The executor reads `t_arrive_ms` and `t_eval_ms` (new) alongside existing
|
||||
`ts_ms` and `book_ts_ms`.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "signal",
|
||||
"correlation_id": "abc123",
|
||||
"triangle_key": ["USDT", "BTC", "ETH"],
|
||||
"primary_quote": "USDT",
|
||||
"legs": [
|
||||
{
|
||||
"pair": "BTC-USDT",
|
||||
"input_currency": "USDT",
|
||||
"output_currency": "BTC",
|
||||
"fee_currency": "USDT",
|
||||
"fee_rate": 0.001,
|
||||
"exchange_rate": 0.00001102,
|
||||
"side": "buy"
|
||||
},
|
||||
{
|
||||
"pair": "ETH-BTC",
|
||||
"input_currency": "BTC",
|
||||
"output_currency": "ETH",
|
||||
"fee_currency": "BTC",
|
||||
"fee_rate": 0.001,
|
||||
"exchange_rate": 16.5,
|
||||
"side": "buy"
|
||||
},
|
||||
{
|
||||
"pair": "ETH-USDT",
|
||||
"input_currency": "ETH",
|
||||
"output_currency": "USDT",
|
||||
"fee_currency": "USDT",
|
||||
"fee_rate": 0.001,
|
||||
"exchange_rate": 90701.2,
|
||||
"side": "sell"
|
||||
}
|
||||
],
|
||||
"predicted_bps": 1.50,
|
||||
"max_volume": "100.00",
|
||||
"book_ts_ms": 1746789012345,
|
||||
"t_arrive_ms": 1746789012347,
|
||||
"t_eval_ms": 1746789012347,
|
||||
"ts_ms": 1746789012349,
|
||||
"books": [
|
||||
{
|
||||
"symbol": "BTC-USDT",
|
||||
"bids": [{"price": "90701.1", "size": "0.13918404"}, ...],
|
||||
"asks": [{"price": "90701.2", "size": "0.57715830"}, ...],
|
||||
"ts_ms": 1746789012345
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.1 Executor Timing Derivation
|
||||
|
||||
The executor (executor/executor.py:474-483) currently derives:
|
||||
```python
|
||||
timings.append({"step": "t-2_book_snapshot", "elapsed_ms": -(executor_receive_ts_ms - book_ts_ms)})
|
||||
timings.append({"step": "t-1_signal_created", "elapsed_ms": -(executor_receive_ts_ms - signal_ts_ms)})
|
||||
```
|
||||
|
||||
With the two new fields, the executor derives:
|
||||
|
||||
| Metric | Formula | Meaning |
|
||||
|---|---|---|
|
||||
| Network latency | `t_arrive_ms - book_ts_ms` | KuCoin server → our doorstep |
|
||||
| Dispatch overhead | `t_eval_ms - t_arrive_ms` | Book update → eval start |
|
||||
| Eval time | `ts_ms - t_eval_ms` | Triangle evaluation loop |
|
||||
| Socket + queue latency | `executor_receive_ts_ms - ts_ms` | SPSC push → executor receive |
|
||||
| Total end-to-end | `executor_receive_ts_ms - book_ts_ms` | Full pipeline |
|
||||
|
||||
**Executor change required:** In `executor/executor.py:474-483`, read the two new
|
||||
fields and add derived timing entries. Approximately 5 lines of code.
|
||||
|
||||
## 7. WS Connection Model
|
||||
|
||||
### 7.1 Multi-Connection Architecture
|
||||
|
||||
KuCoin limits each WS connection to **400 topics** (KuCoin API docs:
|
||||
https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md).
|
||||
With ~100 symbols, a single connection suffices. But the design supports
|
||||
dynamic symbol addition that could exceed 400.
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
int socket_fd; // epoll'd file descriptor
|
||||
BIO *bio; // OpenSSL BIO for TLS
|
||||
SSL *ssl; // OpenSSL SSL context
|
||||
char buffer[65536]; // WS frame receive buffer
|
||||
int buffer_len;
|
||||
uint32_t topic_count; // current subscription count
|
||||
uint32_t max_topics; // typically 400
|
||||
int64_t last_ping_ms; // last ping timestamp
|
||||
int64_t ping_interval_ms; // from token response
|
||||
int64_t ping_timeout_ms; // from token response
|
||||
char token[512]; // public WS token
|
||||
char connect_id[64]; // unique connection ID
|
||||
uint16_t symbol_indices[400]; // subscribed symbol indices
|
||||
uint32_t symbol_count;
|
||||
} ws_connection_t;
|
||||
|
||||
#define MAX_WS_CONNECTIONS 8
|
||||
ws_connection_t ws_connections[MAX_WS_CONNECTIONS];
|
||||
uint32_t ws_connection_count;
|
||||
```
|
||||
|
||||
### 7.2 WS Frame Protocol
|
||||
|
||||
Classic KuCoin WS uses standard WebSocket framing over TLS. The C implementation
|
||||
handles framing manually (no libwebsockets dependency):
|
||||
|
||||
**Outgoing:**
|
||||
1. Construct WS frame: FIN=1, opcode=1 (text), masked, payload = JSON string
|
||||
2. Send via `BIO_write(bio, frame, frame_len)`
|
||||
|
||||
**Incoming:**
|
||||
1. Read from `BIO_read(bio, buf, sizeof(buf))`
|
||||
2. Parse frame header (2-14 bytes): FIN, RSV, opcode, mask, payload length
|
||||
3. Unmask payload (XOR with 4-byte masking key)
|
||||
4. Pass to jsmn parser
|
||||
|
||||
Frame parsing is ~50 lines of C. See `rfc6455` Section 5.2 for frame format.
|
||||
|
||||
### 7.3 Subscribe/Unsubscribe
|
||||
|
||||
From `fh_ob/ws_client.py:208-227`: subscribe in batches of 100 symbols.
|
||||
Topic format: `/spotMarket/level2Depth5:SYMBOL1,SYMBOL2,...`
|
||||
|
||||
```c
|
||||
// Subscribe symbols to a WS connection
|
||||
int ws_subscribe(ws_connection_t *conn, uint16_t *symbol_indices, uint32_t count);
|
||||
|
||||
// Unsubscribe symbols from a WS connection
|
||||
int ws_unsubscribe(ws_connection_t *conn, uint16_t *symbol_indices, uint32_t count);
|
||||
```
|
||||
|
||||
Dynamic subscribe/unsubscribe is triggered by REST API calls
|
||||
(`POST /symbols` and `DELETE /symbols/{symbol}`).
|
||||
|
||||
## 8. REST API
|
||||
|
||||
Kept from fh_ob for operational use. Same endpoints as `fh_ob/rest_server.py:1-189`.
|
||||
|
||||
| Endpoint | Method | Purpose | Ref |
|
||||
|---|---|---|---|
|
||||
| `/health` | GET | Status: WS connected, book count, symbol count, uptime | `fh_ob/rest_server.py:35-48` |
|
||||
| `/book/{symbol}` | GET | Current top-of-book for symbol (all 5 levels) | `fh_ob/rest_server.py:50-68` |
|
||||
| `/books` | GET | All books | `fh_ob/rest_server.py:70-80` |
|
||||
| `/symbols` | GET | List subscribed symbols | `fh_ob/rest_server.py:82-92` |
|
||||
| `/symbols` | POST | Add symbol (subscribe to WS, add to triangle eval) | `fh_ob/rest_server.py:94-120` |
|
||||
| `/symbols/{symbol}` | DELETE | Remove symbol (unsubscribe from WS) | `fh_ob/rest_server.py:122-145` |
|
||||
|
||||
HTTP/1.1 server is raw socket + simple parser. No libhttp dependency.
|
||||
Handles GET/POST/DELETE with Content-Type: application/json.
|
||||
Binds to `0.0.0.0:8000` (configurable via `fh_ob.rest_port`).
|
||||
|
||||
## 9. Triangle Enumeration
|
||||
|
||||
From `oe_em/opportunity.py:111-149` and `oe_em/triangle_enum.py:1-203`.
|
||||
|
||||
Algorithm:
|
||||
1. Fetch KuCoin symbols via `GET /api/v1/symbols` (oe_em/kucoin_api.py:39-56)
|
||||
2. Filter: `base != quote`, symbol is active
|
||||
3. For each pair of symbols (A, B) where A.quote == B.base or A.base == B.quote:
|
||||
- Find third pair (C) that completes the triangle (C.base == A.base, C.quote == B.quote, etc.)
|
||||
- Filter: triangle starts and ends in a hold currency (`oe_em/config.py:55-57`, default `["USDT"]`)
|
||||
- Exclude triangles containing excluded currencies (`oe_em/config.py:50-53`)
|
||||
4. Build `triangle_t` array with precomputed fee factors
|
||||
5. Sort triangles, build `tri_index` for O(1) lookup by symbol
|
||||
|
||||
Fee table from `GET /api/v1/base-fee` (oe_em/kucoin_api.py:60-78).
|
||||
KCS discount: if `kcs_discount_active`, multiply taker fee by 0.8 (oe_em/opportunity.py:50-64).
|
||||
|
||||
## 10. Startup Sequence
|
||||
|
||||
```
|
||||
1. Parse config.yaml (libyaml) — read fh_ob + oe_em sections
|
||||
2. Fetch KuCoin symbols: HTTP GET /api/v1/symbols
|
||||
3. Fetch KuCoin fee table: HTTP GET /api/v1/base-fee
|
||||
4. Enumerate triangles → build sorted triangle[] + tri_index[]
|
||||
5. Fetch public WS token: HTTP POST /api/v1/bullet-public
|
||||
6. Create WS connections (N connections, each ≤400 topics)
|
||||
7. Subscribe to initial symbols (from config.yaml fh_ob.symbols)
|
||||
8. Bind Unix socket at executor_socket_path (oe_em config)
|
||||
9. Start HTTP server on rest_port (fh_ob config)
|
||||
10. Spawn Thread 1 (hot epoll loop: WS fds + timerfd)
|
||||
11. Spawn Thread 2 (cold epoll loop: Unix socket, REST, eventfd)
|
||||
12. Enter event loops
|
||||
```
|
||||
|
||||
## 11. Executor Compatibility
|
||||
|
||||
### 11.1 Unix Socket Protocol
|
||||
|
||||
The executor's `SignalSocketServer` (executor/socket_server.py:1-90) expects:
|
||||
- Unix domain socket at `/tmp/executor.sock`
|
||||
- JSON messages, one per connection message
|
||||
- Message format: `{"type":"signal", ...}` followed by newline
|
||||
|
||||
The C binary writes to the same socket path. No changes needed to the
|
||||
executor's socket server.
|
||||
|
||||
### 11.2 Signal Payload
|
||||
|
||||
The signal JSON is identical to the current format, with two additional fields:
|
||||
- `t_arrive_ms` — monotonic timestamp when WS frame decode completed
|
||||
- `t_eval_ms` — monotonic timestamp when triangle evaluation started
|
||||
|
||||
The executor reads these in `executor/executor.py:474-483`. Required change:
|
||||
|
||||
```python
|
||||
# In executor/executor.py, around line 474:
|
||||
t_arrive_ms = signal.get("t_arrive_ms", 0)
|
||||
t_eval_ms = signal.get("t_eval_ms", 0)
|
||||
if t_arrive_ms > 0:
|
||||
timings.append({"step": "t-3_ws_arrive", "elapsed_ms": -(executor_receive_ts_ms - t_arrive_ms)})
|
||||
if t_eval_ms > 0:
|
||||
timings.append({"step": "t-4_eval_start", "elapsed_ms": -(executor_receive_ts_ms - t_eval_ms)})
|
||||
```
|
||||
|
||||
### 11.3 Executor Disconnect Handling
|
||||
|
||||
The C binary keeps the executor Unix socket open and waits for reconnection.
|
||||
If the executor disconnects, Thread 2 logs a warning and keeps the server
|
||||
socket listening. New executor connections are accepted normally.
|
||||
(Signal queue entries for disconnected executor are dropped.)
|
||||
|
||||
## 12. Implementation Order
|
||||
|
||||
### Phase 1: Foundation (Week 1)
|
||||
1. `CMakeLists.txt` — build config, OpenSSL + libyaml dependencies
|
||||
2. `config.c/h` — YAML parser, reads fh_ob + oe_em sections
|
||||
3. `hash.c/h` — FNV-1a hash, symbol table
|
||||
4. `http_client.c/h` — raw HTTP GET/POST (token, pairs, fees)
|
||||
5. `triangle.c/h` — triangle enumeration, index builder
|
||||
6. `book.c/h` — order book array, update function
|
||||
7. Unit tests: config parse, symbol table, triangle enum
|
||||
|
||||
### Phase 2: WS + Hot Path (Week 2)
|
||||
8. `jsmn.h` — drop in library
|
||||
9. `ws_client.c/h` — WS frame parser, OpenSSL BIO TLS, subscribe/unsubscribe
|
||||
10. `evaluate.c/h` — triangle evaluation loop (inline, zero alloc)
|
||||
11. `signal.c/h` — JSON signal formatter
|
||||
12. Integration test: connect to KuCoin WS, verify book updates
|
||||
|
||||
### Phase 3: IPC + REST (Week 3)
|
||||
13. `queue.c/h` — SPSC ring buffer, eventfd
|
||||
14. `http_server.c/h` — REST API server
|
||||
15. `events.c/h` — epoll loops, timerfd, signal handling
|
||||
16. `main.c` — startup, thread spawn, signal handling
|
||||
17. Integration test: full pipeline, signal to executor
|
||||
|
||||
### Phase 4: Polish (Week 4)
|
||||
18. Dynamic subscribe/unsubscribe via REST
|
||||
19. Reconnection logic (exponential backoff)
|
||||
20. Timing field integration in executor
|
||||
21. Performance benchmarking
|
||||
22. Edge case handling, error paths
|
||||
23. Deployment script update
|
||||
|
||||
## 13. Performance Targets
|
||||
|
||||
| Metric | Current (Python) | Target (C) |
|
||||
|---|---|---|
|
||||
| WS arrive → eval start | ~500µs (asyncio dispatch + IPC) | <1µs (inline) |
|
||||
| Eval 50 triangles | ~5ms (Python loop) | <50µs (C loop) |
|
||||
| Signal dispatch | ~1ms (JSON + socket write) | <10µs (SPSC push) |
|
||||
| Total end-to-end (exchange → executor) | ~15-30ms | ~5-10ms (network-bound) |
|
||||
|
||||
The dominant latency component will be network RTT to KuCoin (~5-20ms).
|
||||
The C binary aims to add <100µs of processing latency on top.
|
||||
|
||||
## 14. Files Affected
|
||||
|
||||
### New Files
|
||||
- `src/` directory with all C source files (listed in §3.4)
|
||||
|
||||
### Modified Files
|
||||
- `executor/executor.py` — add `t_arrive_ms` and `t_eval_ms` to timing derivation (~5 lines)
|
||||
|
||||
### Unchanged Files
|
||||
- `config.yaml` — read by both C binary and executor
|
||||
- `executor/` (except `executor.py` timing change) — fully compatible
|
||||
- `common/log.py` — still used by executor
|
||||
- `deploy.sh` — updated to deploy compiled binary instead of Python files
|
||||
|
||||
### Deleted Files
|
||||
- `fh_ob/` — entire directory (replaced by C binary)
|
||||
- `oe_em/` — entire directory (replaced by C binary)
|
||||
- `common/config.py` — fh_ob settings moved to C config parser; oe_em settings merged into C config
|
||||
|
|
@ -0,0 +1,463 @@
|
|||
# KUCOIN API
|
||||
|
||||
## Docs
|
||||
- [Introduction](https://www.kucoin.com/docs-new/introduction.md):
|
||||
- [Authentication](https://www.kucoin.com/docs-new/authentication.md):
|
||||
- [Enums Definitions](https://www.kucoin.com/docs-new/enums-definitions.md):
|
||||
- [Terms Definitions](https://www.kucoin.com/docs-new/terms-definitions.md):
|
||||
- [SDK](https://www.kucoin.com/docs-new/sdk.md):
|
||||
- [OpenClaw](https://www.kucoin.com/docs-new/kucoin_skills_hub.md):
|
||||
- [Rate Limit](https://www.kucoin.com/docs-new/rate-limit.md):
|
||||
- [Change Log](https://www.kucoin.com/docs-new/change-log.md):
|
||||
- User Service [Market Making Incentive Scheme](https://www.kucoin.com/docs-new/user-service/market-making-incentive-scheme.md):
|
||||
- User Service [VIP Fast Track](https://www.kucoin.com/docs-new/user-service/vip-fast-track.md):
|
||||
- User Service [Broker Program](https://www.kucoin.com/docs-new/user-service/broker-program.md):
|
||||
- Pro REST [Introduction](https://www.kucoin.com/docs-new/rest/ua/introduction.md):
|
||||
- Pro REST > VIP Lending [Introduction](https://www.kucoin.com/docs-new/rest/ua/vip-lending/introduction.md):
|
||||
- Pro WebSocket > Base Info [Introduction](https://www.kucoin.com/docs-new/websocket-api/base-info/introduction-uta.md):
|
||||
- Classic REST > Futures Trading [Introduction](https://www.kucoin.com/docs-new/rest/futures-trading/introduction.md):
|
||||
- Classic REST > VIP Lending [Introduction](https://www.kucoin.com/docs-new/rest/vip-lending/introduction.md):
|
||||
- Classic REST > Affiliate [Introduction](https://www.kucoin.com/docs-new/rest/affiliate/introduction.md):
|
||||
- Classic REST > Broker [Introduction](https://www.kucoin.com/docs-new/rest/broker/introduction/main.md):
|
||||
- Classic REST > Broker [Broker Application](https://www.kucoin.com/docs-new/rest/broker/broker-application.md):
|
||||
- Classic REST > Broker [Instructions](https://www.kucoin.com/docs-new/rest/broker/instructions.md):
|
||||
- Classic REST > Broker > Broker Pro [Introduction](https://www.kucoin.com/docs-new/rest/broker/api-broker/introduction.md):
|
||||
- Classic REST > Broker > Broker Pro [Broker Fast API Service](https://www.kucoin.com/docs-new/rest/broker/api-broker/fast-api.md):
|
||||
- Classic REST > Broker > Exchange Broker [Introduction](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/introduction.md):
|
||||
- Classic REST > Copy Trading [Introduction](https://www.kucoin.com/docs-new/rest/copy-trading/introduction.md):
|
||||
- Classic REST > Convert [Introduction](https://www.kucoin.com/docs-new/rest/convert/introduction.md):
|
||||
- Classic WebSocket > Base Info [Introduction](https://www.kucoin.com/docs-new/websocket-api/base-info/introduction.md):
|
||||
- Web3 Wallet [Browser Extension Wallet](https://www.kucoin.com/docs-new/web3/browser-extension-wallet.md):
|
||||
- Error Code [HTTP](https://www.kucoin.com/docs-new/error-code/http.md):
|
||||
- Error Code [Spot](https://www.kucoin.com/docs-new/error-code/spot.md):
|
||||
- Error Code [Margin](https://www.kucoin.com/docs-new/error-code/margin.md):
|
||||
- Error Code [Futures](https://www.kucoin.com/docs-new/error-code/futures.md):
|
||||
- Error Code [Earn](https://www.kucoin.com/docs-new/error-code/earn.md):
|
||||
- Error Code [Broker](https://www.kucoin.com/docs-new/error-code/broker.md):
|
||||
- Error Code [CopyTrading](https://www.kucoin.com/docs-new/error-code/copytrading.md):
|
||||
- Error Code [Websocket](https://www.kucoin.com/docs-new/error-code/websocket.md):
|
||||
- Error Code [Pro API](https://www.kucoin.com/docs-new/error-code/pro-api.md):
|
||||
- Abandoned Endpoints [Introduction](https://www.kucoin.com/docs-new/abandoned-endpoints/introduction.md):
|
||||
- Developing [Introduction](https://www.kucoin.com/docs-new/338210m0.md):
|
||||
|
||||
## API Docs
|
||||
- Pro REST > Market Data [Get Announcements](https://www.kucoin.com/docs-new/rest/ua/get-announcements.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Currency](https://www.kucoin.com/docs-new/rest/ua/get-currency.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Currencies](https://www.kucoin.com/docs-new/rest/ua/get-currencies.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Symbol](https://www.kucoin.com/docs-new/rest/ua/get-symbol.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Ticker](https://www.kucoin.com/docs-new/rest/ua/get-ticker.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get OrderBook](https://www.kucoin.com/docs-new/rest/ua/get-orderbook.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Klines](https://www.kucoin.com/docs-new/rest/ua/get-klines.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Trades](https://www.kucoin.com/docs-new/rest/ua/get-trades.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Collateral Ratio](https://www.kucoin.com/docs-new/rest/ua/get-collateral-ratio.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Cross Margin Config](https://www.kucoin.com/docs-new/rest/ua/get-cross-margin-config.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Index Price](https://www.kucoin.com/docs-new/rest/ua/get-index-price.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Current Funding Rate](https://www.kucoin.com/docs-new/rest/ua/get-current-funding-rate.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get History Funding Rate](https://www.kucoin.com/docs-new/rest/ua/get-history-funding-rate.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Position Tiers](https://www.kucoin.com/docs-new/rest/ua/get-position-tiers.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Futures Open Interest](https://www.kucoin.com/docs-new/rest/ua/get-futures-open-interset.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Service Status](https://www.kucoin.com/docs-new/rest/ua/get-service-status.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Third-Party Custody Currencies](https://www.kucoin.com/docs-new/rest/ua/get-oes-settlement-currency.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get Borrowable Currencies](https://www.kucoin.com/docs-new/rest/ua/get-borrowable-currencies.md): :::info[Description]
|
||||
- Pro REST > Market Data [Get KYC Regions](https://www.kucoin.com/docs-new/rest/ua/get-kyc-region.md): :::info[Description]
|
||||
- Pro REST > Account [Get Account Overview (UTA)](https://www.kucoin.com/docs-new/rest/ua/account/get-account-overview-uta.md): :::info[Description]
|
||||
- Pro REST > Account [Get Account Currency Assets (UTA)](https://www.kucoin.com/docs-new/rest/ua/get-account-currency-assets-uta.md): :::info[Description]
|
||||
- Pro REST > Account [Get Account Currency Assets (Classic)](https://www.kucoin.com/docs-new/rest/ua/get-account-currency-assets-classic.md): :::info[Description]
|
||||
- Pro REST > Account [Get Sub Account Currency Assets](https://www.kucoin.com/docs-new/rest/ua/get-sub-account-currency-assets.md): :::info[Description]
|
||||
- Pro REST > Account [Get Transfer Quotas](https://www.kucoin.com/docs-new/rest/ua/get-transfer-quotas.md): :::info[Description]
|
||||
- Pro REST > Account [Flex Transfer](https://www.kucoin.com/docs-new/rest/ua/flex-transfer.md): :::info[Description]
|
||||
- Pro REST > Account [Set Sub Account Transfer Permission](https://www.kucoin.com/docs-new/rest/ua/set-sub-account-transfer-permission.md): :::info[Description]
|
||||
- Pro REST > Account [Get Account Mode](https://www.kucoin.com/docs-new/rest/ua/get-account-mode.md): :::info[Description]
|
||||
- Pro REST > Account [Set Account Mode](https://www.kucoin.com/docs-new/rest/ua/set-account-mode.md): :::info[Description]
|
||||
- Pro REST > Account [Get Fee Rate](https://www.kucoin.com/docs-new/rest/ua/get-actual-fee.md): :::info[Description]
|
||||
- Pro REST > Account [Get Account Ledger](https://www.kucoin.com/docs-new/rest/ua/get-account-ledger.md): :::info[Description]
|
||||
- Pro REST > Account [Get Interest History (UTA)](https://www.kucoin.com/docs-new/rest/ua/get-interest-history-uta.md): :::info[Description]
|
||||
- Pro REST > Account [Modify Futures Leverage (UTA)](https://www.kucoin.com/docs-new/rest/ua/modify-leverage-uta.md): :::info[Description]
|
||||
- Pro REST > Account [Get Deposit Address](https://www.kucoin.com/docs-new/rest/ua/get-deposit-address.md): :::info[Description]
|
||||
- Pro REST > Account [Get Third-Party Custody Account Currency Limits](https://www.kucoin.com/docs-new/rest/ua/get-oes-custody-quota.md): :::info[Description]
|
||||
- Pro REST > Account [Modify Leverage Margin Cross (UTA)](https://www.kucoin.com/docs-new/rest/ua/modify-cross-margin-leverage-uta.md): :::info[Description]
|
||||
- Pro REST > Account [Get Leverage (UTA)](https://www.kucoin.com/docs-new/rest/ua/get-leverage.md): :::info[Description]
|
||||
- Pro REST > Account [Get Borrowing Rates and Limits](https://www.kucoin.com/docs-new/rest/ua/get-borrowing-rates-and-limits.md): :::info[Description]
|
||||
- Pro REST > Account [Get Apikey Info](https://www.kucoin.com/docs-new/rest/ua/get-apikey-info.md): :::info[Description]
|
||||
- Pro REST > Account [Add sub-account](https://www.kucoin.com/docs-new/rest/ua/add-sub-account.md): :::info[Description]
|
||||
- Pro REST > Account [Get sub-account API List](https://www.kucoin.com/docs-new/rest/ua/get-sub-account-api-list.md): :::info[Description]
|
||||
- Pro REST > Account [Add sub-account API](https://www.kucoin.com/docs-new/rest/ua/add-sub-account-api.md): :::info[Description]
|
||||
- Pro REST > Account [Delete sub-account API](https://www.kucoin.com/docs-new/rest/ua/delete-sub-account-api.md): :::info[Description]
|
||||
- Pro REST > Account [Get Withdrawal Quotas](https://www.kucoin.com/docs-new/rest/ua/get-withdrawal-quotas.md): :::info[Description]
|
||||
- Pro REST > Account [Withdraw](https://www.kucoin.com/docs-new/rest/ua/withdrawal.md): :::info[Description]
|
||||
- Pro REST > Account [Cancel Withdrawal](https://www.kucoin.com/docs-new/rest/ua/cancel-withdrawal.md): :::info[Description]
|
||||
- Pro REST > Account [Get Client IP Address](https://www.kucoin.com/docs-new/rest/ua/get-client-ip-address.md): :::info[Description]
|
||||
- Pro REST > Orders [Place Order](https://www.kucoin.com/docs-new/rest/ua/place-order.md): :::info[Description]
|
||||
- Pro REST > Orders [Batch Place Order (Classic)](https://www.kucoin.com/docs-new/rest/ua/batch-place-order-classic.md): :::info[Description]
|
||||
- Pro REST > Orders [Cancel Order](https://www.kucoin.com/docs-new/rest/ua/cancel-order.md): :::info[Description]
|
||||
- Pro REST > Orders [Batch Cancel Orders By ID](https://www.kucoin.com/docs-new/rest/ua/batch-cancel-order-by-id.md): :::info[Description]
|
||||
- Pro REST > Orders [Batch Cancel Orders By Symbol](https://www.kucoin.com/docs-new/rest/ua/batch-cancel-order-by-symbol.md): :::info[Description]
|
||||
- Pro REST > Orders [Get Order Details ](https://www.kucoin.com/docs-new/rest/ua/get-order-details.md): :::info[Description]
|
||||
- Pro REST > Orders [Get Open Order List](https://www.kucoin.com/docs-new/rest/ua/get-open-order-list.md): :::info[Description]
|
||||
- Pro REST > Orders [Get Order History](https://www.kucoin.com/docs-new/rest/ua/get-order-history.md): :::info[Description]
|
||||
- Pro REST > Orders [Get Trade History](https://www.kucoin.com/docs-new/rest/ua/get-trade-history.md): :::info[Description]
|
||||
- Pro REST > Orders [Set DCP (Classic)](https://www.kucoin.com/docs-new/rest/ua/set-dcp-classic.md): :::info[Description]
|
||||
- Pro REST > Orders [Get DCP (Classic)](https://www.kucoin.com/docs-new/rest/ua/get-dcp-classic.md): :::info[Description]
|
||||
- Pro REST > Positions [Get Position List (UTA)](https://www.kucoin.com/docs-new/rest/ua/get-position-list-uta.md): :::info[Description]
|
||||
- Pro REST > Positions [Get Positions History (UTA)](https://www.kucoin.com/docs-new/rest/ua/get-position-history-uta.md): :::info[Description]
|
||||
- Pro REST > Positions [Get Account Position Tiers](https://www.kucoin.com/docs-new/rest/ua/get-account-position-tiers.md): :::info[Description]
|
||||
- Pro REST > Positions [Get Private Funding Fee History](https://www.kucoin.com/docs-new/rest/ua/get-private-funding-fee-history.md): :::info[Description]
|
||||
- Pro REST > VIP Lending [Get Collateral Ratio](https://www.kucoin.com/docs-new/rest/ua/vip-lending/get-collateral-ratio.md): :::info[Description]
|
||||
- Pro REST > VIP Lending [Get Loan Info](https://www.kucoin.com/docs-new/rest/ua/vip-lending/get-loan-info.md): :::info[Description]
|
||||
- Pro REST > VIP Lending [Get Accounts](https://www.kucoin.com/docs-new/rest/ua/vip-lending/get-accounts.md): :::info[Description]
|
||||
- Pro WebSocket > Base Info [Get Private Token - Pro API Private Channels](https://www.kucoin.com/docs-new/websocket-api/base-info/get-private-token-uta.md): :::info[Description]
|
||||
- Pro WebSocket > Public Channels [Kline](https://www.kucoin.com/docs-new/3470223w0.md): :::info[Description]
|
||||
- Pro WebSocket > Public Channels [Ticker](https://www.kucoin.com/docs-new/3470222w0.md): :::info[Description]
|
||||
- Pro WebSocket > Public Channels [Orderbook](https://www.kucoin.com/docs-new/3470221w0.md): :::info[Description]
|
||||
- Pro WebSocket > Public Channels [Trade](https://www.kucoin.com/docs-new/3470224w0.md): :::info[Description]
|
||||
- Pro WebSocket > Private Channels [Order](https://www.kucoin.com/docs-new/3470228w0.md): :::info[Description]
|
||||
- Pro WebSocket > Private Channels [Balance](https://www.kucoin.com/docs-new/3470231w0.md): :::info[Description]
|
||||
- Pro WebSocket > Private Channels [Execution](https://www.kucoin.com/docs-new/3470232w0.md): :::info[Description]
|
||||
- Pro WebSocket > Private Channels [Execution Lite](https://www.kucoin.com/docs-new/3470264w0.md): :::info[Description]
|
||||
- Pro WebSocket > Private Channels [Position](https://www.kucoin.com/docs-new/3470233w0.md): :::info[Description]
|
||||
- Pro WebSocket > Private Channels [Leverage](https://www.kucoin.com/docs-new/3470237w0.md): :::info[Description]
|
||||
- Pro WebSocket > Private Channels [LiquidationWarning](https://www.kucoin.com/docs-new/3470236w0.md): ## Topic:/liquidationWarning
|
||||
- Pro WebSocket > Add/Cancel Order [Add Order](https://www.kucoin.com/docs-new/3470133w0.md): :::info[Description]
|
||||
- Pro WebSocket > Add/Cancel Order [Cancel Order](https://www.kucoin.com/docs-new/3470134w0.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account Summary Info](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-summary-info.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Apikey Info](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-apikey-info.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account Type - Spot ](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-type-spot.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account List - Spot](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-list-spot.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account Detail - Spot](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-detail-spot.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account - Cross Margin](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-cross-margin.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account - Isolated Margin](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-isolated-margin.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account - Futures](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-futures.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account Ledgers - Spot/Margin](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-ledgers-spot-margin.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account Ledgers - Trade_hf](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-ledgers-tradehf.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account Ledgers - Margin_hf](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-ledgers-marginhf.md): :::info[Description]
|
||||
- Classic REST > Account Info > Account & Funding [Get Account Ledgers - Futures](https://www.kucoin.com/docs-new/rest/account-info/account-funding/get-account-ledgers-futures.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account [Add sub-account](https://www.kucoin.com/docs-new/rest/account-info/sub-account/add-subaccount.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account [Add sub-account Margin Permission](https://www.kucoin.com/docs-new/rest/account-info/sub-account/add-subaccount-margin-permission.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account [Add sub-account Futures Permission](https://www.kucoin.com/docs-new/rest/account-info/sub-account/add-subaccount-futures-permission.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account [Get sub-account List - Summary Info](https://www.kucoin.com/docs-new/rest/account-info/sub-account/get-subaccount-list-summary-info.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account [Get sub-account Detail - Balance](https://www.kucoin.com/docs-new/rest/account-info/sub-account/get-subaccount-detail-balance.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account [Get sub-account List - Spot Balance (V2)](https://www.kucoin.com/docs-new/rest/account-info/sub-account/get-subaccount-list-spot-balance-v2.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account [Get sub-account List - Futures Balance (V2)](https://www.kucoin.com/docs-new/rest/account-info/sub-account/get-subaccount-list-futures-balance-v2.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account API [Get sub-account API List](https://www.kucoin.com/docs-new/rest/account-info/sub-account-api/get-subaccount-api-list.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account API [Add sub-account API](https://www.kucoin.com/docs-new/rest/account-info/sub-account-api/add-subaccount-api.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account API [Modify sub-account API](https://www.kucoin.com/docs-new/rest/account-info/sub-account-api/modify-subaccount-api.md): :::info[Description]
|
||||
- Classic REST > Account Info > Sub Account API [Delete sub-account API](https://www.kucoin.com/docs-new/rest/account-info/sub-account-api/delete-subaccount-api.md): :::info[Description]
|
||||
- Classic REST > Account Info > Deposit [Add Deposit Address (V3)](https://www.kucoin.com/docs-new/rest/account-info/deposit/add-deposit-address-v3.md): :::info[Description]
|
||||
- Classic REST > Account Info > Deposit [Get Deposit Address (V3)](https://www.kucoin.com/docs-new/rest/account-info/deposit/get-deposit-address-v3/en.md): :::info[Description]
|
||||
- Classic REST > Account Info > Deposit [Get Deposit History](https://www.kucoin.com/docs-new/rest/account-info/deposit/get-deposit-history.md): :::info[Description]
|
||||
- Classic REST > Account Info > Withdrawals [Get Withdrawal Quotas](https://www.kucoin.com/docs-new/rest/account-info/withdrawals/get-withdrawal-quotas.md): :::info[Description]
|
||||
- Classic REST > Account Info > Withdrawals [Withdraw (V3)](https://www.kucoin.com/docs-new/rest/account-info/withdrawals/withdraw-v3.md): :::info[Description]
|
||||
- Classic REST > Account Info > Withdrawals [Cancel Withdrawal](https://www.kucoin.com/docs-new/rest/account-info/withdrawals/cancel-withdrawal.md): :::info[Description]
|
||||
- Classic REST > Account Info > Withdrawals [Get Withdrawal History](https://www.kucoin.com/docs-new/rest/account-info/withdrawals/get-withdrawal-history.md): :::info[Description]
|
||||
- Classic REST > Account Info > Withdrawals [Get Withdrawal History By ID](https://www.kucoin.com/docs-new/rest/account-info/withdrawals/get-withdrawal-by-id.md): :::info[Description]
|
||||
- Classic REST > Account Info > Transfer [Get Transfer Quotas](https://www.kucoin.com/docs-new/rest/account-info/transfer/get-transfer-quotas.md): :::info[Description]
|
||||
- Classic REST > Account Info > Transfer [Flex Transfer](https://www.kucoin.com/docs-new/rest/account-info/transfer/flex-transfer.md): :::info[Description]
|
||||
- Classic REST > Account Info > Trade Fee [Get Basic Fee - Spot/Margin](https://www.kucoin.com/docs-new/rest/account-info/trade-fee/get-basic-fee-spot-margin.md): :::info[Description]
|
||||
- Classic REST > Account Info > Trade Fee [Get Actual Fee - Spot/Margin](https://www.kucoin.com/docs-new/rest/account-info/trade-fee/get-actual-fee-spot-margin.md): :::info[Description]
|
||||
- Classic REST > Account Info > Trade Fee [Get Actual Fee - Futures](https://www.kucoin.com/docs-new/rest/account-info/trade-fee/get-actual-fee-futures.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Announcements](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-announcements.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Currency](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-currency.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get All Currencies](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-all-currencies.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Symbol ](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-symbol.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get All Symbols](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-all-symbols.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Ticker](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-ticker.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get All Tickers](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-all-tickers.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Trade History](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-trade-history.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Klines](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-klines.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Part OrderBook](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-part-orderbook.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Full OrderBook](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-full-orderbook.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Call Auction Part OrderBook](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-call-auction-part-orderbook.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Call Auction Info](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-call-auction-info.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Fiat Price](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-fiat-price.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get 24hr Stats](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-24hr-stats.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Market List](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-market-list.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Client IP Address](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-client-ip-address.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Server Time](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-server-time.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get Service Status](https://www.kucoin.com/docs-new/rest/spot-trading/market-data/get-service-status.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Market Data [Get KYC Regions](https://www.kucoin.com/docs-new/rest/account-info/withdrawals/get-kyc-regions.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Add Order](https://www.kucoin.com/docs-new/rest/spot-trading/orders/add-order.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Add Order Sync](https://www.kucoin.com/docs-new/rest/spot-trading/orders/add-order-sync.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Add Order Test](https://www.kucoin.com/docs-new/rest/spot-trading/orders/add-order-test.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Batch Add Orders](https://www.kucoin.com/docs-new/rest/spot-trading/orders/batch-add-orders.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Batch Add Orders Sync](https://www.kucoin.com/docs-new/rest/spot-trading/orders/batch-add-orders-sync.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel Order By OrderId](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel Order By OrderId Sync](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-order-by-orderld-sync.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel Order By ClientOid](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel Order By ClientOid Sync](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-order-by-clientoid-sync.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel Partial Order](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-partial-order.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel All Orders By Symbol](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-all-orders-by-symbol.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel All Orders](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-all-orders.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Modify Order](https://www.kucoin.com/docs-new/rest/spot-trading/orders/modify-order.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Order By OrderId](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Order By ClientOid](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Symbols With Open Order](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-symbols-with-open-order.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Open Orders](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-open-orders.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Open Orders By Page](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-open-orders-by-page.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Closed Orders](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-closed-orders.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Trade History](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-trade-history.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get DCP](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-dcp.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Set DCP](https://www.kucoin.com/docs-new/rest/spot-trading/orders/set-dcp.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Add Stop Order](https://www.kucoin.com/docs-new/rest/spot-trading/orders/add-stop-order.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel Stop Order By ClientOid](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-stop-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel Stop Order By OrderId](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-stop-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Batch Cancel Stop Orders](https://www.kucoin.com/docs-new/rest/spot-trading/orders/batch-cancel-stop-orders.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Stop Orders List](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-stop-orders-list.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Stop Order By OrderId](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-stop-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get Stop Order By ClientOid](https://www.kucoin.com/docs-new/rest/spot-trading/get-stop-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Add OCO Order](https://www.kucoin.com/docs-new/rest/spot-trading/orders/add-oco-order.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel OCO Order By OrderId](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-oco-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Cancel OCO Order By ClientOid](https://www.kucoin.com/docs-new/rest/spot-trading/orders/cancel-oco-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Batch Cancel OCO Order](https://www.kucoin.com/docs-new/rest/spot-trading/orders/batch-cancel-oco-order.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get OCO Order By OrderId](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-oco-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get OCO Order By ClientOid](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-oco-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get OCO Order Detail By OrderId](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-oco-order-detail-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Spot Trading > Orders [Get OCO Order List](https://www.kucoin.com/docs-new/rest/spot-trading/orders/get-oco-order-list.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Market Data [Get Symbols - Cross Margin](https://www.kucoin.com/docs-new/rest/margin-trading/market-data/get-symbols-cross-margin.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Market Data [Get Symbols - Isolated Margin](https://www.kucoin.com/docs-new/rest/margin-trading/market-data/get-symbols-isolated-margin.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Market Data [Get Mark Price Detail](https://www.kucoin.com/docs-new/rest/margin-trading/market-data/get-mark-price-detail.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Market Data [Get Margin Config](https://www.kucoin.com/docs-new/rest/margin-trading/market-data/get-margin-config.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Market Data [Get Mark Price List](https://www.kucoin.com/docs-new/rest/margin-trading/market-data/get-mark-price-list.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Market Data [Get Margin Collateral Ratio](https://www.kucoin.com/docs-new/rest/margin-trading/market-data/get-margin-collateral-ratio.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Market Data [Get Market Available Inventory ](https://www.kucoin.com/docs-new/rest/margin-trading/market-data/get-margin.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Add Order](https://www.kucoin.com/docs-new/rest/margin-trading/orders/add-order.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Add Order Test](https://www.kucoin.com/docs-new/rest/margin-trading/orders/add-order-test.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Cancel Order By OrderId](https://www.kucoin.com/docs-new/rest/margin-trading/orders/cancel-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Cancel Order By ClientOid](https://www.kucoin.com/docs-new/rest/margin-trading/orders/cancel-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Cancel All Orders By Symbol](https://www.kucoin.com/docs-new/rest/margin-trading/orders/cancel-all-orders-by-symbol.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Symbols With Open Order](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-symbols-with-open-order.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Open Orders](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-open-orders.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Closed Orders](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-closed-orders.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Trade History](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-trade-history.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Order By OrderId](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Order By ClientOid](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Add Stop Order](https://www.kucoin.com/docs-new/rest/margin-trading/orders/add-stop-order.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Cancel Stop Order By OrderId](https://www.kucoin.com/docs-new/rest/margin-trading/orders/cancel-stop-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Cancel Stop Order By ClientOid](https://www.kucoin.com/docs-new/rest/margin-trading/orders/cancel-stop-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Batch Cancel Stop Orders](https://www.kucoin.com/docs-new/rest/margin-trading/orders/batch-cancel-stop-orders.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Stop Orders List](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-stop-order-list.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Stop Order By OrderId](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-stop-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get Stop Order By ClientOid](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-stop-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Add OCO Order](https://www.kucoin.com/docs-new/rest/margin-trading/orders/add-oco-order.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Cancel OCO Order By OrderId](https://www.kucoin.com/docs-new/rest/margin-trading/orders/cancel-oco-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Cancel OCO Order By ClientOid](https://www.kucoin.com/docs-new/rest/margin-trading/orders/cancel-oco-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Batch Cancel OCO Order](https://www.kucoin.com/docs-new/rest/margin-trading/orders/batch-cancel-oco-orders.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get OCO Order By OrderId](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-oco-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get OCO Order By ClientOid](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-oco-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get OCO Order Detail By OrderId](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-oco-order-detail-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Orders [Get OCO Order List](https://www.kucoin.com/docs-new/rest/margin-trading/orders/get-oco-order-list.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Debit [Get Borrow Interest Rate](https://www.kucoin.com/docs-new/rest/margin-trading/debit/get-borrow-interest-rate.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Debit [Borrow](https://www.kucoin.com/docs-new/rest/margin-trading/debit/borrow.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Debit [Get Borrow History](https://www.kucoin.com/docs-new/rest/margin-trading/debit/get-borrow-history.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Debit [Repay](https://www.kucoin.com/docs-new/rest/margin-trading/debit/repay.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Debit [Get Repay History](https://www.kucoin.com/docs-new/rest/margin-trading/debit/get-repay-history.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Debit [Get Interest History](https://www.kucoin.com/docs-new/rest/margin-trading/debit/get-interest-history.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Debit [Modify Leverage](https://www.kucoin.com/docs-new/rest/margin-trading/debit/modify-leverage.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Credit [Get Loan Market](https://www.kucoin.com/docs-new/rest/margin-trading/credit/get-loan-market.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Credit [Get Loan Market Interest Rate](https://www.kucoin.com/docs-new/rest/margin-trading/credit/get-loan-market-interest-rate.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Credit [Purchase](https://www.kucoin.com/docs-new/rest/margin-trading/credit/purchase.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Credit [Modify Purchase](https://www.kucoin.com/docs-new/rest/margin-trading/credit/modify-purchase.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Credit [Get Purchase Orders](https://www.kucoin.com/docs-new/rest/margin-trading/credit/get-purchase-orders.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Credit [Redeem](https://www.kucoin.com/docs-new/rest/margin-trading/credit/redeem.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Credit [Get Redeem Orders](https://www.kucoin.com/docs-new/rest/margin-trading/credit/get-redeem-orders.md): :::info[Description]
|
||||
- Classic REST > Margin Trading > Risk Limit [Get Margin Risk Limit](https://www.kucoin.com/docs-new/rest/margin-trading/risk-limit/get-margin-risk-limit.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Market Data [Get Symbol](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-symbol.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Market Data [Get All Symbols](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-all-symbols.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Market Data [Get Ticker](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-ticker.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Market Data [Get All Tickers](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-all-tickers.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Market Data [Get Full OrderBook](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-full-orderbook.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get Part OrderBook](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-part-orderbook.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get Trade History](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-trade-history.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get Klines](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-klines.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Market Data [Get Mark Price](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-mark-price.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get Spot Index Price](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-spot-index-price.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get Interest Rate Index](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-interest-rate-index.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get Premium Index](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-premium-index.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get 24hr stats](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-24hr-stats.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get Server Time](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-server-time.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Market Data [Get Service Status](https://www.kucoin.com/docs-new/rest/futures-trading/market-data/get-service-status.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Orders [Add Order](https://www.kucoin.com/docs-new/rest/futures-trading/orders/add-order.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Add Order Test](https://www.kucoin.com/docs-new/rest/futures-trading/orders/add-order-test.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Batch Add Orders](https://www.kucoin.com/docs-new/rest/futures-trading/orders/batch-add-orders.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Add Take Profit And Stop Loss Order](https://www.kucoin.com/docs-new/rest/futures-trading/orders/add-take-profit-and-stop-loss-order.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Cancel Order By OrderId](https://www.kucoin.com/docs-new/rest/futures-trading/orders/cancel-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Cancel Order By ClientOid](https://www.kucoin.com/docs-new/rest/futures-trading/orders/cancel-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [rest/futures-trading/orders/batch-cancel-orders](https://www.kucoin.com/docs-new/3470241e0.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Cancel All Orders](https://www.kucoin.com/docs-new/rest/futures-trading/orders/cancel-all-orders.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Cancel All Stop orders](https://www.kucoin.com/docs-new/rest/futures-trading/orders/cancel-all-stop-orders.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Get Order By OrderId](https://www.kucoin.com/docs-new/rest/futures-trading/orders/get-order-by-orderld.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Get Order By ClientOid](https://www.kucoin.com/docs-new/rest/futures-trading/get-stop-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Get Order List](https://www.kucoin.com/docs-new/rest/futures-trading/orders/get-order-list.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Get Recent Closed Orders](https://www.kucoin.com/docs-new/rest/futures-trading/orders/get-recent-closed-orders.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Get Stop Order List](https://www.kucoin.com/docs-new/rest/futures-trading/orders/get-stop-order-list.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Get Open Order Value](https://www.kucoin.com/docs-new/rest/futures-trading/orders/get-open-order-value.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Get Recent Trade History](https://www.kucoin.com/docs-new/rest/futures-trading/orders/get-recent-trade-history.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Orders [Get Trade History](https://www.kucoin.com/docs-new/rest/futures-trading/orders/get-trade-history.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Margin Mode](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-margin-mode.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Switch Margin Mode](https://www.kucoin.com/docs-new/rest/futures-trading/positions/switch-margin-mode.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Batch Switch Margin Mode](https://www.kucoin.com/docs-new/rest/futures-trading/positions/batch-switch-margin-mode.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Position Mode](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-position-mode.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Switch Position Mode](https://www.kucoin.com/docs-new/rest/futures-trading/positions/switch-position-mode.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Max Open Size](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-max-open-size.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Position Details](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-position-details.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Position List](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-position-list.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Positions History](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-positions-history.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Max Withdraw Margin](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-max-withdraw-margin.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Cross Margin Leverage](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-cross-margin-leverage.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Modify Cross Margin Leverage](https://www.kucoin.com/docs-new/rest/futures-trading/positions/modify-cross-margin-leverage.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Add Isolated Margin](https://www.kucoin.com/docs-new/rest/futures-trading/positions/add-isolated-margin.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Remove Isolated Margin](https://www.kucoin.com/docs-new/rest/futures-trading/positions/remove-isolated-margin.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Cross Margin Risk Limit](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-cross-margin-risk-limit.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Get Cross Margin Requirement](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-cross-margin-requirement.md): :::info[Discription]
|
||||
- Classic REST > Futures Trading > Positions [Get Isolated Margin Risk Limit](https://www.kucoin.com/docs-new/rest/futures-trading/positions/get-isolated-margin-risk-limit.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Positions [Modify Isolated Margin Risk Limit](https://www.kucoin.com/docs-new/rest/futures-trading/positions/modify-isolated-margin-risk-limit.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Funding Fees [Get Current Funding Rate](https://www.kucoin.com/docs-new/rest/futures-trading/funding-fees/get-current-funding-rate.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Funding Fees [Get Public Funding History](https://www.kucoin.com/docs-new/rest/futures-trading/funding-fees/get-public-funding-history.md): :::info[Description]
|
||||
- Classic REST > Futures Trading > Funding Fees [Get Private Funding History](https://www.kucoin.com/docs-new/rest/futures-trading/funding-fees/get-private-funding-history.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Purchase](https://www.kucoin.com/docs-new/rest/earn/purchase.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Get Redeem Preview](https://www.kucoin.com/docs-new/rest/earn/get-redeem-preview.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Redeem](https://www.kucoin.com/docs-new/rest/earn/redeem.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Get Savings Products](https://www.kucoin.com/docs-new/rest/earn/get-savings-products.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Get Promotion Products](https://www.kucoin.com/docs-new/rest/earn/get-promotion-products.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Get Staking Products](https://www.kucoin.com/docs-new/rest/earn/get-staking-products.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Get KCS Staking Products](https://www.kucoin.com/docs-new/rest/earn/get-kcs-staking-products.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Get ETH Staking Products](https://www.kucoin.com/docs-new/rest/earn/get-eth-staking-products.md): :::info[Description]
|
||||
- Classic REST > Earn > Simple Earn [Get Account Holding](https://www.kucoin.com/docs-new/rest/earn/get-account-holding.md): :::info[Description]
|
||||
- Classic REST > Earn > Structured Earn - Dual [Structured Product Purchase](https://www.kucoin.com/docs-new/rest/earn/structured-product-purchase.md): :::info[Description]
|
||||
- Classic REST > Earn > Structured Earn - Dual [Get Dual Investment Products](https://www.kucoin.com/docs-new/rest/earn/get-dual-investment-products.md): :::info[Description]
|
||||
- Classic REST > Earn > Structured Earn - Dual [Get Structured Product Orders](https://www.kucoin.com/docs-new/rest/earn/get-structured-product-orders.md): :::info[Description]
|
||||
- Classic REST > VIP Lending [Get Collateral Ratio](https://www.kucoin.com/docs-new/rest/vip-lending/get-collateral-ratio.md): :::info[Description]
|
||||
- Classic REST > VIP Lending [Get Loan Info](https://www.kucoin.com/docs-new/rest/vip-lending/get-account-detail.md): :::info[Description]
|
||||
- Classic REST > VIP Lending [Get Accounts](https://www.kucoin.com/docs-new/rest/vip-lending/get-accounts.md): :::info[Description]
|
||||
- Classic REST > Affiliate [Get Invited](https://www.kucoin.com/docs-new/rest/affiliate/get-invited.md): :::info[Description]
|
||||
- Classic REST > Affiliate [Get Commission](https://www.kucoin.com/docs-new/rest/affiliate/get-commission.md): :::info[Description]
|
||||
- Classic REST > Affiliate [Get Trade History](https://www.kucoin.com/docs-new/rest/affiliate/get-trade-history.md): :::info[Description]
|
||||
- Classic REST > Affiliate [Get Transaction](https://www.kucoin.com/docs-new/rest/affiliate/get-transaction.md): :::info[Description]
|
||||
- Classic REST > Affiliate [Get Kumining](https://www.kucoin.com/docs-new/rest/affiliate/get-kumining.md): :::info[Description]
|
||||
- Classic REST > Broker > Broker Pro [Get Broker Rebate](https://www.kucoin.com/docs-new/rest/broker/api-broker/get-broker-rebate.md): :::info[Description]
|
||||
- Classic REST > Broker > Broker Pro [Get Commission](https://www.kucoin.com/docs-new/rest/broker/api-broker/get-commission.md): :::info[Description]
|
||||
- Classic REST > Broker > Broker Pro [Get User List](https://www.kucoin.com/docs-new/rest/broker/api-broker/get-user-list.md): :::info[Description]
|
||||
- Classic REST > Broker > Broker Pro [Get User Transactions](https://www.kucoin.com/docs-new/rest/broker/api-broker/get-user-transactions.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Submit KYC](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/submit-kyc.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get KYC Status](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-kyc-status.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get KYC Status List](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-kyc-status-list.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get Broker Info](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-broker-info.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Add sub-account](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/add-subaccount.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get sub-account](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-subaccount.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Add sub-account API](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/add-subaccount-api.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get sub-account API](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-subaccount-api.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Modify sub-account API](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/modify-subaccount-api.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Delete sub-account API](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/delete-subaccount-api.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Transfer](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/transfer.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get Transfer History](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-transfer-history.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get Deposit List](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-deposit-list.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get Deposit Detail](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-deposit-detail.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get Withdraw Detail](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-withdraw-detail.md): :::info[Description]
|
||||
- Classic REST > Broker > Exchange Broker [Get Broker Rebate](https://www.kucoin.com/docs-new/rest/broker/exchange-broker/get-broker-rebate.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Add Order](https://www.kucoin.com/docs-new/rest/copy-trading/add-order.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Add Order Test](https://www.kucoin.com/docs-new/rest/copy-trading/add-order-test.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Add Take Profit And Stop Loss Order](https://www.kucoin.com/docs-new/rest/copy-trading/add-take-profit-and-stop-loss-order.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Cancel Order By OrderId](https://www.kucoin.com/docs-new/rest/copy-trading/cancel-order-by-orderid.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Cancel Order By ClientOid](https://www.kucoin.com/docs-new/rest/copy-trading/cancel-order-by-clientoid.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Get Max Open Size](https://www.kucoin.com/docs-new/rest/copy-trading/get-max-open-size.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Get Max Withdraw Margin](https://www.kucoin.com/docs-new/rest/copy-trading/get-max-withdraw-margin.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Add Isolated Margin](https://www.kucoin.com/docs-new/rest/copy-trading/add-isolated-margin.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Remove Isolated Margin](https://www.kucoin.com/docs-new/rest/copy-trading/remove-isolated-margin.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Modify Isolated Margin Risk Limit](https://www.kucoin.com/docs-new/rest/copy-trading/modify-isolated-margin-risk-limit.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Modify Isolated Margin Auto-Deposit Status](https://www.kucoin.com/docs-new/rest/copy-trading/modify-isolated-margin-auto-deposit-status.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Switch Margin Mode](https://www.kucoin.com/docs-new/rest/copy-trading/switch-margin-mode.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Modify Cross Margin Leverage](https://www.kucoin.com/docs-new/rest/copy-trading/modify-cross-margin-leverage.md): :::info[Description]
|
||||
- Classic REST > Copy Trading [Get Cross Margin Requirement](https://www.kucoin.com/docs-new/rest/copy-trading/get-cross-margin-requirement.md): :::info[Discription]
|
||||
- Classic REST > Copy Trading [Switch Position Mode](https://www.kucoin.com/docs-new/rest/copy-trading/switch-position-mode.md): :::info[Description]
|
||||
- Classic REST > Convert [Get Convert Symbol](https://www.kucoin.com/docs-new/rest/convert/get-convert-symbol.md): :::info[Description]
|
||||
- Classic REST > Convert [Get Convert Currencies](https://www.kucoin.com/docs-new/rest/convert/get-convert-currencies.md): :::info[Description]
|
||||
- Classic REST > Convert [Get Convert Quote](https://www.kucoin.com/docs-new/rest/convert/get-convert-quote.md): :::info[Description]
|
||||
- Classic REST > Convert [Add Convert Order](https://www.kucoin.com/docs-new/rest/convert/add-convert-order.md): :::info[Description]
|
||||
- Classic REST > Convert [Get Convert Order Detail](https://www.kucoin.com/docs-new/rest/convert/get-convert-order-detail.md): :::info[Description]
|
||||
- Classic REST > Convert [Get Convert Order History](https://www.kucoin.com/docs-new/rest/convert/get-convert-order-history.md): :::info[Description]
|
||||
- Classic REST > Convert [Add Convert Limit Order](https://www.kucoin.com/docs-new/rest/convert/add-convert-limit-order.md): :::info[Description]
|
||||
- Classic REST > Convert [Get Convert Limit Quote](https://www.kucoin.com/docs-new/rest/convert/get-convert-limit-quote.md): :::info[Description]
|
||||
- Classic REST > Convert [Get Convert Limit Order Detail](https://www.kucoin.com/docs-new/rest/convert/get-convert-limit-order-detail.md): :::info[Description]
|
||||
- Classic REST > Convert [Get Convert Limit Orders](https://www.kucoin.com/docs-new/rest/convert/get-convert-limit-orders.md): :::info[Description]
|
||||
- Classic REST > Convert [Cancel Convert Limit Order](https://www.kucoin.com/docs-new/rest/convert/cancel-convert-limit-order.md): :::info[Description]
|
||||
- Classic WebSocket > Base Info [Get Public Token - Classic Spot/Margin](https://www.kucoin.com/docs-new/websocket-api/base-info/get-public-token-spot-margin.md): :::info[Description]
|
||||
- Classic WebSocket > Base Info [Get Private Token - Classic Spot/Margin](https://www.kucoin.com/docs-new/websocket-api/base-info/get-private-token-spot-margin.md): :::info[Description]
|
||||
- Classic WebSocket > Base Info [Get Public Token - Classic Futures](https://www.kucoin.com/docs-new/websocket-api/base-info/get-public-token-futures.md): :::info[Description]
|
||||
- Classic WebSocket > Base Info [Get Private Token - Classic Futures](https://www.kucoin.com/docs-new/websocket-api/base-info/get-private-token-futures.md): :::info[Description]
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Ticker](https://www.kucoin.com/docs-new/3470063w0.md): ## Topic: /market/ticker:{symbol},{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [All Tickers](https://www.kucoin.com/docs-new/3470064w0.md): ## Topic: /market/ticker:all
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Orderbook - Level 1](https://www.kucoin.com/docs-new/3470067w0.md): ## Topic: /spotMarket/level1:{symbol},{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Orderbook - Level 5](https://www.kucoin.com/docs-new/3470069w0.md): ## Topic:/spotMarket/level2Depth5:{symbol},{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Orderbook - Level 50](https://www.kucoin.com/docs-new/3470070w0.md): ## Topic:/spotMarket/level2Depth50:{symbol},{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Orderbook - Increment](https://www.kucoin.com/docs-new/3470068w0.md): ## Topic:/market/level2:{symbol},{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Call Auction Orderbook - Level 50](https://www.kucoin.com/docs-new/3470137w0.md): ## Topic:/callauction/level2Depth50:{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Call Auction Data](https://www.kucoin.com/docs-new/3470138w0.md): ## Topic: /callauction/callauctionData:{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Klines](https://www.kucoin.com/docs-new/3470071w0.md): ## Topic:/market/candles:{symbol}_{type}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Trade](https://www.kucoin.com/docs-new/3470072w0.md): ## Topic:/market/match:{symbol},{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Symbol Snapshot](https://www.kucoin.com/docs-new/3470065w0.md): ## Topic: /market/snapshot:{symbol}
|
||||
- Classic WebSocket > Spot Trading > Public Channels [Market Snapshot ](https://www.kucoin.com/docs-new/3470066w0.md): ## Topic: /market/snapshot:{market}
|
||||
- Classic WebSocket > Spot Trading > Private Channels [Order V2](https://www.kucoin.com/docs-new/3470073w0.md): ## Topic:/spotMarket/tradeOrdersV2
|
||||
- Classic WebSocket > Spot Trading > Private Channels [Order V1](https://www.kucoin.com/docs-new/3470074w0.md): ## Topic:/spotMarket/tradeOrders
|
||||
- Classic WebSocket > Spot Trading > Private Channels [Balance](https://www.kucoin.com/docs-new/3470075w0.md): ## Topic:/account/balance
|
||||
- Classic WebSocket > Spot Trading > Private Channels [Stop Order](https://www.kucoin.com/docs-new/3470139w0.md): ## Topic:/spotMarket/advancedOrders
|
||||
- Classic WebSocket > Margin Trading > Public Channels [Index Price](https://www.kucoin.com/docs-new/3470076w0.md): ## Topic: /indicator/index:{symbol0},{symbol1}
|
||||
- Classic WebSocket > Margin Trading > Public Channels [Mark Price](https://www.kucoin.com/docs-new/3470077w0.md): ## Topic:/indicator/markPrice:{symbol0},{symbol1}
|
||||
- Classic WebSocket > Margin Trading > Private Channels [Order V2](https://www.kucoin.com/docs-new/3470256w0.md): ## Topic:/spotMarket/tradeOrdersV2
|
||||
- Classic WebSocket > Margin Trading > Private Channels [Order V1](https://www.kucoin.com/docs-new/3470257w0.md): ## Topic:/spotMarket/tradeOrders
|
||||
- Classic WebSocket > Margin Trading > Private Channels [Balance](https://www.kucoin.com/docs-new/3470258w0.md): ## Topic:/account/balance
|
||||
- Classic WebSocket > Margin Trading > Private Channels [Stop Order](https://www.kucoin.com/docs-new/3470259w0.md): ## Topic:/spotMarket/advancedOrders
|
||||
- Classic WebSocket > Margin Trading > Private Channels [Cross Margin Position](https://www.kucoin.com/docs-new/3470078w0.md): ## Topic: /margin/position
|
||||
- Classic WebSocket > Margin Trading > Private Channels [Isolated Margin Position](https://www.kucoin.com/docs-new/3470079w0.md): ## Topic: /margin/isolatedPosition:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Ticker V2](https://www.kucoin.com/docs-new/3470080w0.md): ## Topic: /contractMarket/tickerV2:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Ticker V1](https://www.kucoin.com/docs-new/3470081w0.md): ## Topic: /contractMarket/ticker:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Orderbook - Level 5](https://www.kucoin.com/docs-new/3470083w0.md): ## Topic: /contractMarket/level2Depth5:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Orderbook - Level 50](https://www.kucoin.com/docs-new/3470097w0.md): ## Topic: /contractMarket/level2Depth50:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Orderbook - Increment](https://www.kucoin.com/docs-new/3470082w0.md): ## Topic: /contractMarket/level2:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Klines](https://www.kucoin.com/docs-new/3470086w0.md): ## Topic: /contractMarket/limitCandle:{symbol}_{type}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Trade](https://www.kucoin.com/docs-new/3470084w0.md): ## Topic: /contractMarket/execution:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Instrument](https://www.kucoin.com/docs-new/3470087w0.md): ## Topic: /contract/instrument:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Funding Fee Settlement](https://www.kucoin.com/docs-new/3470088w0.md): ## Topic: /contract/announcement:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Public Channels [Symbol Snapshot](https://www.kucoin.com/docs-new/3470089w0.md): ## Topic: /contractMarket/snapshot:{symbol}
|
||||
- Classic WebSocket > Futures Trading > Private Channels [Orders](https://www.kucoin.com/docs-new/3470090w0.md): ## Topic: /contractMarket/tradeOrders
|
||||
- Classic WebSocket > Futures Trading > Private Channels [Balance](https://www.kucoin.com/docs-new/3470092w0.md): ## Topic:/contractAccount/wallet
|
||||
- Classic WebSocket > Futures Trading > Private Channels [Positions](https://www.kucoin.com/docs-new/3470093w0.md): ## Topic: /contract/positionAll
|
||||
- Classic WebSocket > Futures Trading > Private Channels [Margin Mode](https://www.kucoin.com/docs-new/3470095w0.md): ## Topic: /contract/marginMode
|
||||
- Classic WebSocket > Futures Trading > Private Channels [Cross Margin Leverage](https://www.kucoin.com/docs-new/3470096w0.md): ## Topic: /contract/crossLeverage
|
||||
- Classic WebSocket > Futures Trading > Private Channels [Stop Orders](https://www.kucoin.com/docs-new/3470091w0.md): ## Topic: /contractMarket/advancedOrders
|
||||
- Classic WebSocket > Add/Cancel Order [Add Order](https://www.kucoin.com/docs-new/3470252w0.md): :::info[Description]
|
||||
- Classic WebSocket > Add/Cancel Order [Cancel Order](https://www.kucoin.com/docs-new/3470253w0.md): :::info[Description]
|
||||
- Abandoned Endpoints > Account & Funding [Get sub-account List - Summary Info (V1)](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/get-subacconut-list-summary-info-v1.md): :::tip[]
|
||||
- Abandoned Endpoints > Account & Funding [Get sub-account List - Spot Balance (V1)](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/get-subacconut-list-spot-balance-v1.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Get Deposit Addresses (V2)](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/get-deposit-addresses-v2.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Get Deposit Addresses - V1](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/get-deposit-addresses-v1.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Sub-account Transfer](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/subaccount-transfer.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Get Deposit History - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/get-deposit-history-old.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Internal Transfer](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/inner-transfer.md): :::warning[Warning]
|
||||
- Abandoned Endpoints > Account & Funding [Get Futures Account Transfer Out Ledger](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/get-futures-account-transfer-out-ledger.md): :::info[Description]
|
||||
- Abandoned Endpoints > Account & Funding [Get Withdrawal History - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/get-withdrawal-history-old.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Futures Account Transfer Out](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/futures-account-transfer-out.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Futures Account Transfer In](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/futures-account-transfer-in.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Add Deposit Address - V1](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/add-deposit-address-v1.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Account & Funding [Withdraw - V1](https://www.kucoin.com/docs-new/abandoned-endpoints/account-funding/withdraw-v1.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Add Order - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/add-order-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Add Order Test - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/add-order-test-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Batch Add Orders - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/batch-add-orders-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Cancel Order By OrderId - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/cancel-order-by-orderld-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Cancel Order By ClientOid - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/cancel-order-by-clientoid-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Batch Cancel Order - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/batch-cancel-order-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Get Orders List - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/get-orders-list-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Get Recent Orders List - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/get-recent-orders-list-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Get Order By OrderId - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/get-order-by-orderld-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Get Order By ClientOid - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/get-order-by-clientoid-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Get Trade History - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/get-trade-history-old.md): :::tip[]
|
||||
- Abandoned Endpoints > Spot Trading > Orders [Get Recent Trade History - Old](https://www.kucoin.com/docs-new/abandoned-endpoints/spot-trading/orders/get-recent-trade-history-old.md): re
|
||||
- Abandoned Endpoints > Margin Trading [Get ETF Info](https://www.kucoin.com/docs-new/abandoned-endpoints/rest/margin-trading/market-data/get-etf-info.md): :::info[Description]
|
||||
- Abandoned Endpoints > Margin Trading [Get Account Detail - Margin](https://www.kucoin.com/docs-new/abandoned-endpoints/margin-trading/get-account-detail-margin.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Margin Trading [Add Order - V1](https://www.kucoin.com/docs-new/abandoned-endpoints/margin-trading/add-order-v1.md): :::warning[Warning]
|
||||
- Abandoned Endpoints > Margin Trading [Add Order Test - V1](https://www.kucoin.com/docs-new/abandoned-endpoints/margin-trading/add-order-test-v1.md): :::warning[Warning]
|
||||
- Abandoned Endpoints > Margin Trading [Get Account List - Isolated Margin - V1](https://www.kucoin.com/docs-new/abandoned-endpoints/margin-trading/get-account-list-isolated-margin-v1.md): :::warning[Warning]
|
||||
- Abandoned Endpoints > Margin Trading [Get Account Detail - Isolated Margin - V1](https://www.kucoin.com/docs-new/abandoned-endpoints/margin-trading/get-account-detail-isolated-margin-v1.md): :::warning[Warning]
|
||||
- Abandoned Endpoints > Futures Trading [Modify Isolated Margin Auto - Deposit Status](https://www.kucoin.com/docs-new/abandoned-endpoints/futures-trading/modify-isolated-margin-auto-deposit-status.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Futures Trading [Cancel All Orders - V1](https://www.kucoin.com/docs-new/abandoned-endpoints/futures-trading/cancel-all-orders-v1.md): :::tip[TIPS]
|
||||
- Abandoned Endpoints > Futures Trading [Get Position Details - V1](https://www.kucoin.com/docs-new/3475249e0.md): :::info[Description]
|
||||
- Abandoned Endpoints > Affiliate [Get Account](https://www.kucoin.com/docs-new/abandoned-endpoints/affiliate/get-account.md): :::info[Description]
|
||||
- Abandoned Endpoints > Broker > Broker Pro [Get Broker Rebate](https://www.kucoin.com/docs-new/3470280e0.md): :::tip[TIPS]
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
## Plan: Move volume computation from executor to fused_engine
|
||||
|
||||
### Goal
|
||||
fused_engine computes per-leg order parameters (`funds`/`size`), precision-rounded.
|
||||
Executor becomes a thin dispatcher — validate+simulate (paper) or place+record (live).
|
||||
|
||||
### New signal schema
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "signal",
|
||||
"live": false,
|
||||
"legs": [
|
||||
{"pair": "BTC-USDT", "side": "buy", "funds": "5.01", "base_increment": "0.00001", "quote_increment": "0.01", "base_min": "0.0001"},
|
||||
{"pair": "ETH-BTC", "side": "sell", "size": "0.0495", "base_increment": "0.001", "quote_increment": "0.01", "base_min": "0.001"},
|
||||
{"pair": "ETH-USDT", "side": "sell", "size": "0.0498", "base_increment": "0.001", "quote_increment": "0.01", "base_min": "0.001"}
|
||||
],
|
||||
"starting_volume": "5.01",
|
||||
"predicted_bps": 12.5,
|
||||
"books": [{"symbol": "BTC-USDT", "bids": [...], "asks": [...]}, ...],
|
||||
"ts_ms": 1779400000000,
|
||||
"book_ts_ms": 1779400000000,
|
||||
"t_arrive_ms": 1779400000000,
|
||||
"t_eval_ms": 1779400000000,
|
||||
"triangle_key": ["USDT","BTC","ETH"],
|
||||
"correlation_id": "abc123"
|
||||
}
|
||||
```
|
||||
|
||||
- `live=true`: no `books`, no `size`/`funds` — executor places leg 0 with `starting_volume`, KuCoin fills drive the chain
|
||||
- `live=false`: `books` present for simulation, `funds`/`size` pre-computed, executor passes them straight to `order_test()`
|
||||
|
||||
### fused_engine changes
|
||||
|
||||
#### 1. Store symbol precision metadata (`symbols_api.c`)
|
||||
Already fetches `/api/v2/symbols`. Add to `trading_pair_t`:
|
||||
```
|
||||
char base_increment[32];
|
||||
char quote_increment[32];
|
||||
char base_min_size[32];
|
||||
```
|
||||
Parse from `baseIncrement`, `quoteIncrement`, `baseMinSize` fields in the API response.
|
||||
|
||||
#### 2. Per-leg volume computation (`evaluate.c`)
|
||||
After computing `max_volume` in evaluate_symbol, add a new function that runs the executor's current volume logic in C:
|
||||
|
||||
```
|
||||
compute_leg_volumes(
|
||||
triangle, books, max_volume, fee_rates,
|
||||
out_leg_funds_or_size[3],
|
||||
out_leg_side[3]
|
||||
)
|
||||
```
|
||||
|
||||
Steps (mirrors executor.py:550-608):
|
||||
- `starting = cost_to_precision(max_volume, leg0.quote_increment)`
|
||||
- For leg 0 (buy): `net = starting * (1 - fee)`, `base = floor(net / ask, base_inc)`, recompute `funds = cost_precision(base * ask, quote_inc)`
|
||||
- For leg 1: `input = leg0.base_output`. Same buy/sell logic
|
||||
- For leg 2: same
|
||||
- Store `funds` for buys, `size` for sells
|
||||
|
||||
The computation is pure arithmetic (no I/O): Decimal-style operations using `strtod`/`sprintf` with integer math or floating point with epsilon guards. Or use a lightweight bignum lib.
|
||||
|
||||
#### 3. Signal JSON format (`evaluate.c` and `events.c`)
|
||||
Update both `format_signal_json` (evaluate.c, unused today — keep for debugging) and `send_signal_to_executor` (events.c) to emit the new schema, including per-leg `funds`/`size` and increment fields.
|
||||
|
||||
Remove `fee_rate` and `exchange_rate` from leg JSON — executor no longer needs them.
|
||||
|
||||
#### 4. Live vs paper mode selection
|
||||
Config: add `live_mode: bool` to config.yaml (default false). fused_engine reads it and conditionalizes signal content.
|
||||
|
||||
### Executor changes (`executor.py`)
|
||||
|
||||
#### 1. Remove dead code
|
||||
- `get_symbol_meta()` and `SymbolMeta` (kucoin_api.py:226-228 + dataclass)
|
||||
- Volume propagation (executor.py:550-558)
|
||||
- Precision rounding per leg (executor.py:564-608)
|
||||
- `_precheck_volume()` → simplify to just check `starting_volume >= min_size` per leg using the `base_min`/`base_increment` from the signal
|
||||
- Fee computation in paper fill simulation (executor.py:823) → use fee_currency from signal leg
|
||||
- `legs[i - 1].get("fee_rate")` → executor no longer knows fee rates, use from signal
|
||||
|
||||
#### 2. New signal parsing
|
||||
Extract `live`, `legs` with pre-computed `funds`/`size`, increment fields, `starting_volume`.
|
||||
|
||||
#### 3. live=true path
|
||||
- Leg 0: `order_place(funds=starting_volume)`, await fill
|
||||
- Subsequent legs: `order_place(funds=actual_propagated)` — no, the actual fill drives the next leg. Keep current propagation (actual fill.filled_volume feeds next leg input) — this stays because KuCoin returns real fills.
|
||||
|
||||
#### 4. live=false path (paper)
|
||||
- Each leg: `order_test(funds=leg.funds)` or `order_test(size=leg.size)` — pass through
|
||||
- Paper fill simulation: use signal `books` + leg `base_increment`/`quote_increment` to compute `deal_funds` ← this is the only volume math remaining in the executor
|
||||
- Simulate: apply fee to base_or_quote, round to precision, compute effective deal_funds
|
||||
- profit = `fills[2].deal_funds - fills[0].deal_funds`
|
||||
|
||||
#### 5. Paper fill simulation (what stays)
|
||||
The executor must still:
|
||||
- Read `books[i].asks[0].price` / `bids[0].price`
|
||||
- For buys: compute how much base you'd get for `funds` at that top-of-book price, after fee, rounded to `base_increment`
|
||||
- For sells: compute how much quote you'd get for `size` at that top-of-book price, after fee, rounded to `quote_increment`
|
||||
- This is ~20 lines per leg, no external dependencies
|
||||
|
||||
### Migration order
|
||||
1. Add precision fields to `trading_pair_t` and parse from API
|
||||
2. Add volume computation in `evaluate.c`
|
||||
3. Update signal JSON format (both functions)
|
||||
4. Strip executor down
|
||||
5. Test paper mode, then live mode
|
||||
|
||||
### Risk: Decimal precision in C
|
||||
The executor uses Python `Decimal` for exact precision. In C, we can:
|
||||
- Use `double` with `ceil()`/`floor()` and `1e-10` epsilon guards (simple, adequate for crypto tick sizes)
|
||||
- Or use integer math: multiply by 10^decimals, do integer rounding, convert back
|
||||
|
||||
For crypto tick sizes (0.00001, 0.01, 0.1), doubles are precise enough. No need for bignum.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
Executor package.
|
||||
|
||||
Re-exports the Executor class used by the fused_engine process.
|
||||
"""
|
||||
|
||||
from executor.executor import Executor
|
||||
|
||||
__all__ = ["Executor"]
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
"""
|
||||
Executor process entry point.
|
||||
|
||||
Starts the Unix-socket signal server, REST API control interface,
|
||||
and orchestrates clean shutdown on SIGTERM/SIGINT.
|
||||
"""
|
||||
import asyncio
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
import uvicorn
|
||||
from common.log import configure_logging
|
||||
|
||||
from executor.config import Settings
|
||||
from executor.executor import Executor
|
||||
from executor.kucoin_api import KuCoinAPI
|
||||
from executor.rest_api import create_app
|
||||
from executor.socket_server import SignalSocketServer
|
||||
from executor.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.executor.log_level) # file I/O handled by Executor's _DualLogger
|
||||
|
||||
log = structlog.get_logger().bind(component="executor")
|
||||
|
||||
log.info("executor_starting", live_mode=settings.live_mode)
|
||||
|
||||
# Always initialise KuCoinAPI even in paper mode — symbol metadata is
|
||||
# needed for size/precision validation regardless of execution mode.
|
||||
api = KuCoinAPI(
|
||||
api_key=settings.kucoin_api_key,
|
||||
api_secret=settings.kucoin_api_secret,
|
||||
api_passphrase=settings.kucoin_api_passphrase,
|
||||
)
|
||||
await api.fetch_symbols()
|
||||
|
||||
ws_client: Optional[KuCoinWSClient] = None
|
||||
if settings.live_mode:
|
||||
# Live mode requires the private WebSocket client to receive fill events.
|
||||
ws_client = KuCoinWSClient(
|
||||
kucoin_api=api,
|
||||
private_token_url=settings.executor.private_token_url,
|
||||
)
|
||||
|
||||
executor = Executor(
|
||||
kucoin_api=api,
|
||||
settings=settings.executor,
|
||||
ws_client=ws_client,
|
||||
log_file=settings.executor.log_file,
|
||||
live_mode=settings.live_mode,
|
||||
)
|
||||
await executor.start()
|
||||
|
||||
should_exit = asyncio.Event()
|
||||
|
||||
def shutdown_callback() -> None:
|
||||
should_exit.set()
|
||||
|
||||
rest_app = create_app(executor, shutdown_callback=shutdown_callback)
|
||||
rest_config = uvicorn.Config(
|
||||
rest_app,
|
||||
host="127.0.0.1",
|
||||
port=settings.executor.rest_port,
|
||||
log_level="warning",
|
||||
)
|
||||
rest_server = uvicorn.Server(rest_config)
|
||||
|
||||
socket_server = SignalSocketServer(
|
||||
socket_path=settings.executor.socket_path,
|
||||
on_signal=executor.handle_signal,
|
||||
)
|
||||
|
||||
socket_task: asyncio.Task | None = None
|
||||
rest_task: asyncio.Task | None = None
|
||||
exit_task: asyncio.Task | None = None
|
||||
ws_task: asyncio.Task | None = None
|
||||
|
||||
async def shutdown(sig: signal.Signals) -> None:
|
||||
"""Clean up on shutdown signal: pause executor, cancel tasks, close server."""
|
||||
log.info("shutdown_signal_received", signal=sig.name)
|
||||
await executor.pause()
|
||||
await executor.close()
|
||||
if socket_task is not None and not socket_task.done():
|
||||
socket_task.cancel()
|
||||
if rest_task is not None:
|
||||
rest_server.should_exit = True
|
||||
if ws_client is not None:
|
||||
await ws_client.stop()
|
||||
should_exit.set()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
# Register signal handlers so shutdown runs in the asyncio event loop
|
||||
# rather than in a plain threading context.
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(shutdown(s)))
|
||||
|
||||
socket_task = asyncio.create_task(socket_server.start())
|
||||
rest_task = asyncio.create_task(rest_server.serve())
|
||||
exit_task = asyncio.create_task(should_exit.wait())
|
||||
|
||||
if ws_client is not None:
|
||||
ws_task = asyncio.create_task(ws_client.start())
|
||||
|
||||
log.info(
|
||||
"executor_ready",
|
||||
rest_endpoint=f"http://127.0.0.1:{settings.executor.rest_port}",
|
||||
socket_path=str(settings.executor.socket_path),
|
||||
live_mode=settings.live_mode,
|
||||
)
|
||||
|
||||
tasks = {t for t in (socket_task, rest_task, exit_task, ws_task) if t is not None}
|
||||
try:
|
||||
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
except asyncio.CancelledError:
|
||||
log.info("executor_cancelled")
|
||||
finally:
|
||||
rest_server.should_exit = True
|
||||
for t in tasks:
|
||||
if not t.done():
|
||||
await asyncio.wait({t}, timeout=3.0)
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
log.info("executor_shutdown_complete")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""
|
||||
Executor configuration loaded from config.yaml.
|
||||
|
||||
Defines settings for the triangular arbitrage executor including
|
||||
Unix socket path, concurrency limits, KuCoin credentials, and logging.
|
||||
|
||||
live_mode at the top level controls whether the executor places real
|
||||
orders (live) or validates via order_test and simulates fills (paper).
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
log = logging.getLogger("executor-config")
|
||||
|
||||
|
||||
class ExecutorSettings(BaseModel):
|
||||
"""Settings that control executor runtime behaviour."""
|
||||
|
||||
socket_path: Path = Field(
|
||||
default=Path("/tmp/executor.sock"),
|
||||
description="Unix domain socket path for fused_engine -> executor",
|
||||
)
|
||||
concurrent_slots: int = Field(default=1, ge=1, description="Max concurrent triangle executions")
|
||||
enforce_same_base_isolation: bool = Field(
|
||||
default=True,
|
||||
description="Block concurrent triangles sharing the same base currency",
|
||||
)
|
||||
enforce_pair_isolation: bool = Field(
|
||||
default=True,
|
||||
description="Block concurrent triangles that share any trading pair symbol",
|
||||
)
|
||||
log_file: Path = Field(
|
||||
default=Path("/tmp/executor.log"),
|
||||
description="Path to log file",
|
||||
)
|
||||
log_level: str = Field(default="INFO", description="Logging level")
|
||||
rest_port: int = Field(
|
||||
default=8002,
|
||||
description="HTTP REST API port (8000=fh_ob, 8001=oe_em)",
|
||||
)
|
||||
initial_capital: dict[str, Decimal] = Field(
|
||||
default_factory=lambda: {"USDT": Decimal("10")},
|
||||
description="Starting capital per currency. Caps the max_volume for each triangle's primary_quote.",
|
||||
)
|
||||
private_token_url: str = Field(
|
||||
default="https://api.kucoin.com/api/v1/bullet-private",
|
||||
description="KuCoin private bullet token endpoint",
|
||||
)
|
||||
fill_timeout_ms: float = Field(
|
||||
default=1000,
|
||||
description="Per-leg fill wait timeout in milliseconds",
|
||||
)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Top-level settings parsed from config.yaml."""
|
||||
|
||||
# live_mode is the master switch: False = paper (order_test + simulated fills),
|
||||
# True = real orders on KuCoin. Set at top level of config.yaml.
|
||||
live_mode: bool = Field(default=False, description="false = paper; true = live orders")
|
||||
executor: ExecutorSettings = Field(default_factory=ExecutorSettings)
|
||||
kucoin_api_key: str = Field(default="", description="KuCoin API key")
|
||||
kucoin_api_secret: str = Field(default="", description="KuCoin API secret")
|
||||
kucoin_api_passphrase: str = Field(default="", description="KuCoin API passphrase")
|
||||
|
||||
@classmethod
|
||||
async def from_yaml(cls, path: Path) -> "Settings":
|
||||
"""Load settings from a YAML file, ignoring unknown keys."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _read() -> dict:
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
|
||||
data = await loop.run_in_executor(None, _read)
|
||||
known = {"live_mode", "fused_engine", "executor", "kucoin_api_key", "kucoin_api_secret", "kucoin_api_passphrase"}
|
||||
unknown = set(data.keys()) - known
|
||||
if unknown:
|
||||
log.warning("ignoring unknown config keys: %s", sorted(unknown))
|
||||
return cls(**data)
|
||||
|
||||
model_config = {"env_prefix": "TRIArb_", "extra": "ignore"}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,434 @@
|
|||
"""
|
||||
KuCoin REST API client for the executor.
|
||||
|
||||
Covers symbol metadata fetch, HF order-test endpoint for paper mode,
|
||||
real order placement for live mode, and private WebSocket token
|
||||
acquisition required by ws_client.py.
|
||||
"""
|
||||
import aiohttp
|
||||
import base64
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import structlog
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
logger = structlog.get_logger().bind(component="executor-kucoin")
|
||||
|
||||
KUCoin_SYMBOLS_URL = "https://api.kucoin.com/api/v1/symbols"
|
||||
KUCoin_ORDER_TEST_URL = "https://api.kucoin.com/api/v1/hf/orders/test"
|
||||
KUCoin_ORDER_PLACE_URL = "https://api.kucoin.com/api/v1/hf/orders"
|
||||
KUCoin_SERVER_TIME_URL = "https://api.kucoin.com/api/v1/time"
|
||||
KUCoin_BULLET_PRIVATE_URL = "https://api.kucoin.com/api/v1/bullet-private"
|
||||
|
||||
|
||||
def _signRequest(timestamp: str, method: str, path: str, secret: str, data: str = "") -> str:
|
||||
"""
|
||||
Compute the HMAC-SHA256 signature for a KuCoin API request.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
timestamp : str
|
||||
Millisecond Unix timestamp.
|
||||
method : str
|
||||
HTTP method (e.g. "POST").
|
||||
path : str
|
||||
API endpoint path (e.g. "/api/v1/hf/orders/test").
|
||||
secret : str
|
||||
API secret as returned by the user.
|
||||
data : str
|
||||
Request body. Empty string for GET requests.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Base64-encoded HMAC-SHA256 digest suitable for the KC-API-SIGN header.
|
||||
"""
|
||||
# KuCoin signs timestamp + method + path + body concatenated.
|
||||
message = timestamp + method + path + data
|
||||
mac = hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256)
|
||||
return base64.b64encode(mac.digest()).decode("utf-8")
|
||||
|
||||
|
||||
def _encryptPassphrase(passphrase: str, secret: str) -> str:
|
||||
"""
|
||||
Encrypt the API passphrase for KuCoin.
|
||||
|
||||
KuCoin requires the passphrase to be encrypted with HMAC-SHA256
|
||||
using the API secret as the key, then base64-encoded.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Base64-encoded encrypted passphrase for the KC-API-PASSPHRASE header.
|
||||
"""
|
||||
mac = hmac.new(secret.encode("utf-8"), passphrase.encode("utf-8"), hashlib.sha256)
|
||||
return base64.b64encode(mac.digest()).decode("utf-8")
|
||||
|
||||
|
||||
class SymbolMeta:
|
||||
"""
|
||||
Cached metadata for a single KuCoin trading pair.
|
||||
|
||||
Fetched once at startup from /api/v1/symbols and held in memory
|
||||
for the lifetime of the process.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
symbol: str,
|
||||
base: str,
|
||||
quote: str,
|
||||
base_min_size: Decimal,
|
||||
quote_min_size: Decimal,
|
||||
base_increment: Decimal,
|
||||
quote_increment: Decimal,
|
||||
min_funds: Decimal,
|
||||
taker_fee: Decimal,
|
||||
maker_fee: Decimal,
|
||||
incr_min_size: Decimal,
|
||||
base_precision: int,
|
||||
quote_precision: int,
|
||||
) -> None:
|
||||
self.symbol = symbol
|
||||
self.base = base
|
||||
self.quote = quote
|
||||
self.base_min_size = base_min_size
|
||||
self.quote_min_size = quote_min_size
|
||||
self.base_increment = base_increment
|
||||
self.quote_increment = quote_increment
|
||||
self.min_funds = min_funds
|
||||
self.taker_fee = taker_fee
|
||||
self.maker_fee = maker_fee
|
||||
self.incr_min_size = incr_min_size
|
||||
self.base_precision = base_precision
|
||||
self.quote_precision = quote_precision
|
||||
|
||||
|
||||
|
||||
def _cost_to_precision(amount: Decimal, quote_increment: Decimal) -> Decimal:
|
||||
"""
|
||||
Round a quote-currency amount *up* to the nearest valid increment.
|
||||
|
||||
KuCoin requires quote amounts to be multiples of quote_increment. When
|
||||
computing the minimum order size in quote currency we therefore round up
|
||||
so we never underestimate the minimum.
|
||||
"""
|
||||
if quote_increment <= 0:
|
||||
return amount
|
||||
mult = Decimal("1") / quote_increment
|
||||
return (amount * mult).to_integral_value(rounding="ROUND_UP") / mult
|
||||
|
||||
|
||||
def _amount_to_precision(
|
||||
amount: Decimal,
|
||||
base_increment: Decimal,
|
||||
round_down: bool = False,
|
||||
) -> Decimal:
|
||||
"""
|
||||
Round a base-currency amount to the nearest valid increment.
|
||||
|
||||
Use round_down=True for sell orders so the resulting size never
|
||||
exceeds the available balance. Use round_down=False (default) for
|
||||
buy orders and minimum-size computations where rounding up is safe.
|
||||
"""
|
||||
if base_increment <= 0:
|
||||
return amount
|
||||
mult = Decimal("1") / base_increment
|
||||
rounding = "ROUND_DOWN" if round_down else "ROUND_UP"
|
||||
return (amount * mult).to_integral_value(rounding=rounding) / mult
|
||||
|
||||
|
||||
class KuCoinAPI:
|
||||
"""
|
||||
Thin client for KuCoin REST API calls needed by the executor.
|
||||
|
||||
Handles HMAC signing, symbol metadata caching, order placement
|
||||
(live) and order validation (paper), plus private token fetch
|
||||
for the WebSocket client.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str = "", api_secret: str = "", api_passphrase: str = "") -> None:
|
||||
self._api_key = api_key
|
||||
self._api_secret = api_secret
|
||||
self._api_passphrase = api_passphrase
|
||||
# Pre-encrypt the passphrase at init time so we don't recompute per request.
|
||||
self._encrypted_passphrase = _encryptPassphrase(api_passphrase, api_secret) if api_passphrase and api_secret else ""
|
||||
self._symbols: dict[str, SymbolMeta] = {}
|
||||
self._log = logger
|
||||
|
||||
async def fetch_symbols(self) -> None:
|
||||
"""
|
||||
Fetch all trading pair metadata from KuCoin and populate _symbols.
|
||||
|
||||
Logs a warning for any symbol that fails to parse and skips it.
|
||||
"""
|
||||
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", "")
|
||||
if not all([symbol, base, quote]):
|
||||
continue
|
||||
|
||||
raw_base_min = item.get("baseMinSize") or "0"
|
||||
raw_quote_min = item.get("quoteMinSize") or "0"
|
||||
raw_base_inc = item.get("baseIncrement") or "0"
|
||||
raw_quote_inc = item.get("quoteIncrement") or "0"
|
||||
raw_min_funds = item.get("minFunds") or "0"
|
||||
raw_maker = item.get("makerFeeRate") or "0"
|
||||
raw_taker = item.get("takerFeeRate") or "0"
|
||||
raw_incr_min = item.get("incrMinSize") or "0"
|
||||
raw_base_prec = item.get("basePrecision") or "0"
|
||||
raw_quote_prec = item.get("quotePrecision") or "0"
|
||||
|
||||
try:
|
||||
base_min_size = Decimal(raw_base_min)
|
||||
quote_min_size = Decimal(raw_quote_min)
|
||||
base_increment = Decimal(raw_base_inc)
|
||||
quote_increment = Decimal(raw_quote_inc)
|
||||
min_funds = Decimal(raw_min_funds)
|
||||
taker_fee = Decimal(raw_taker)
|
||||
maker_fee = Decimal(raw_maker)
|
||||
incr_min_size = Decimal(raw_incr_min)
|
||||
base_precision = int(raw_base_prec) if raw_base_prec else 0
|
||||
quote_precision = int(raw_quote_prec) if raw_quote_prec else 0
|
||||
except Exception as e:
|
||||
self._log.warning("symbol_field_parse_error", symbol=symbol, error=str(e),
|
||||
baseMinSize=repr(raw_base_min), quoteMinSize=repr(raw_quote_min),
|
||||
baseIncrement=repr(raw_base_inc), quoteIncrement=repr(raw_quote_inc),
|
||||
minFunds=repr(raw_min_funds), makerFeeRate=repr(raw_maker),
|
||||
takerFeeRate=repr(raw_taker), incrMinSize=repr(raw_incr_min),
|
||||
basePrecision=repr(raw_base_prec), quotePrecision=repr(raw_quote_prec))
|
||||
continue
|
||||
|
||||
self._symbols[symbol] = SymbolMeta(
|
||||
symbol=symbol,
|
||||
base=base,
|
||||
quote=quote,
|
||||
base_min_size=base_min_size,
|
||||
quote_min_size=quote_min_size,
|
||||
base_increment=base_increment,
|
||||
quote_increment=quote_increment,
|
||||
min_funds=min_funds,
|
||||
taker_fee=taker_fee,
|
||||
maker_fee=maker_fee,
|
||||
incr_min_size=incr_min_size,
|
||||
base_precision=base_precision,
|
||||
quote_precision=quote_precision,
|
||||
)
|
||||
|
||||
self._log.info("symbols_loaded", count=len(self._symbols))
|
||||
|
||||
def get_symbol_meta(self, symbol: str) -> Optional[SymbolMeta]:
|
||||
"""Return cached SymbolMeta for a pair, or None if not yet loaded."""
|
||||
return self._symbols.get(symbol)
|
||||
|
||||
async def order_test(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
symbol: str,
|
||||
side: str,
|
||||
order_type: str,
|
||||
price: Optional[Decimal] = None,
|
||||
size: Optional[Decimal] = None,
|
||||
funds: Optional[Decimal] = None,
|
||||
) -> tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Validate an order against KuCoin's paper-trading endpoint.
|
||||
|
||||
In paper mode the executor uses this instead of a real order so that
|
||||
fill simulation can proceed without risking capital.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session : aiohttp.ClientSession
|
||||
Shared session for connection pooling.
|
||||
symbol : str
|
||||
KuCoin symbol e.g. "BTC-USDT".
|
||||
side : str
|
||||
"buy" or "sell".
|
||||
order_type : str
|
||||
Order type string (e.g. "market").
|
||||
price, size, funds : Optional[Decimal]
|
||||
Order parameters; at least one must be provided.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[bool, str, Optional[str]]
|
||||
(success, error_message, order_id). order_id is populated on success.
|
||||
"""
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
path = "/api/v1/hf/orders/test"
|
||||
body: dict = {
|
||||
"symbol": symbol,
|
||||
"type": order_type,
|
||||
"side": side,
|
||||
"clientOid": f"paper-{timestamp}-{uuid.uuid4().hex[:8]}",
|
||||
}
|
||||
if price is not None:
|
||||
body["price"] = str(price)
|
||||
if size is not None:
|
||||
body["size"] = str(size)
|
||||
if funds is not None:
|
||||
body["funds"] = str(funds)
|
||||
|
||||
raw_body = json.dumps(body, separators=(",", ":"))
|
||||
sign = _signRequest(timestamp, "POST", path, self._api_secret, raw_body)
|
||||
headers = {
|
||||
"KC-API-TIMESTAMP": timestamp,
|
||||
"KC-API-SIGN": sign,
|
||||
"KC-API-KEY": self._api_key,
|
||||
"KC-API-PASSPHRASE": self._encrypted_passphrase,
|
||||
"KC-API-SIGN-TYPE": "2",
|
||||
"KC-API-KEY-VERSION": "3",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.post(
|
||||
KUCoin_ORDER_TEST_URL,
|
||||
data=raw_body.encode(),
|
||||
headers=headers,
|
||||
) as resp:
|
||||
text = await resp.text()
|
||||
if resp.status == 200:
|
||||
data = json.loads(text)
|
||||
order_id = data.get("data", {}).get("orderId", "")
|
||||
return True, "", order_id
|
||||
else:
|
||||
data = json.loads(text)
|
||||
code = data.get("code", "")
|
||||
msg = data.get("msg", text)
|
||||
return False, f"{code}: {msg}", None
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
return False, f"invalid_response: {e}", None
|
||||
except (aiohttp.ClientError, OSError) as e:
|
||||
return False, f"http_error: {e}", None
|
||||
|
||||
async def order_place(
|
||||
self,
|
||||
session: aiohttp.ClientSession,
|
||||
symbol: str,
|
||||
side: str,
|
||||
order_type: str,
|
||||
price: Optional[Decimal] = None,
|
||||
size: Optional[Decimal] = None,
|
||||
funds: Optional[Decimal] = None,
|
||||
client_oid: str = "",
|
||||
) -> tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Place a real market order on KuCoin.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
session : aiohttp.ClientSession
|
||||
Shared session for connection pooling.
|
||||
symbol : str
|
||||
KuCoin symbol e.g. "BTC-USDT".
|
||||
side : str
|
||||
"buy" or "sell".
|
||||
order_type : str
|
||||
Order type string (e.g. "market").
|
||||
price, size, funds : Optional[Decimal]
|
||||
Order parameters; at least one must be provided.
|
||||
client_oid : str
|
||||
Client-order ID for matching with WS fill events.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[bool, str, Optional[str]]
|
||||
(success, error_message, order_id). order_id is populated on success.
|
||||
"""
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
path = "/api/v1/hf/orders"
|
||||
body: dict = {
|
||||
"symbol": symbol,
|
||||
"type": order_type,
|
||||
"side": side,
|
||||
"clientOid": client_oid or f"live-{timestamp}-{uuid.uuid4().hex[:8]}",
|
||||
}
|
||||
if price is not None:
|
||||
body["price"] = str(price)
|
||||
if size is not None:
|
||||
body["size"] = str(size)
|
||||
if funds is not None:
|
||||
body["funds"] = str(funds)
|
||||
|
||||
raw_body = json.dumps(body, separators=(",", ":"))
|
||||
sign = _signRequest(timestamp, "POST", path, self._api_secret, raw_body)
|
||||
headers = {
|
||||
"KC-API-TIMESTAMP": timestamp,
|
||||
"KC-API-SIGN": sign,
|
||||
"KC-API-KEY": self._api_key,
|
||||
"KC-API-PASSPHRASE": self._encrypted_passphrase,
|
||||
"KC-API-SIGN-TYPE": "2",
|
||||
"KC-API-KEY-VERSION": "3",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.post(
|
||||
KUCoin_ORDER_PLACE_URL,
|
||||
data=raw_body.encode(),
|
||||
headers=headers,
|
||||
) as resp:
|
||||
text = await resp.text()
|
||||
self._log.info("order_place_raw_response", status=resp.status, response=text[:300])
|
||||
data = json.loads(text)
|
||||
if resp.status == 200 and data.get("code") == "200000":
|
||||
order_id = data.get("data", {}).get("orderId", "")
|
||||
return True, "", order_id
|
||||
else:
|
||||
code = data.get("code", resp.status)
|
||||
msg = data.get("msg", text)
|
||||
return False, f"{code}: {msg}", None
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
return False, f"invalid_response: {e}", None
|
||||
except (aiohttp.ClientError, OSError) as e:
|
||||
return False, f"http_error: {e}", None
|
||||
|
||||
async def get_private_token(self, session: aiohttp.ClientSession) -> Optional[dict]:
|
||||
"""
|
||||
Request a private WebSocket token from KuCoin.
|
||||
|
||||
Returns the full data dict from the bullet-private response, containing
|
||||
'token', 'instanceServers' with endpoint, pingInterval, pingTimeout.
|
||||
Returns None on error.
|
||||
"""
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
path = "/api/v1/bullet-private"
|
||||
sign = _signRequest(timestamp, "POST", path, self._api_secret, "")
|
||||
headers = {
|
||||
"KC-API-TIMESTAMP": timestamp,
|
||||
"KC-API-SIGN": sign,
|
||||
"KC-API-KEY": self._api_key,
|
||||
"KC-API-PASSPHRASE": self._encrypted_passphrase,
|
||||
"KC-API-SIGN-TYPE": "2",
|
||||
"KC-API-KEY-VERSION": "3",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
async with session.post(
|
||||
KUCoin_BULLET_PRIVATE_URL,
|
||||
data=b"",
|
||||
headers=headers,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
return data.get("data")
|
||||
else:
|
||||
text = await resp.text()
|
||||
self._log.error("private_token_failed", status=resp.status, response=text[:200])
|
||||
return None
|
||||
except (aiohttp.ClientError, OSError) as e:
|
||||
self._log.error("private_token_http_error", error=str(e))
|
||||
return None
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
"""
|
||||
FastAPI control interface for the executor.
|
||||
|
||||
Exposes endpoints for status inspection, configuration changes, execution
|
||||
cancellation, and pause/resume. Intended for operator use; not used in the
|
||||
normal signal flow.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from executor.executor import Executor
|
||||
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
"""Current executor configuration."""
|
||||
live_mode: bool
|
||||
concurrent_slots: int
|
||||
enforce_same_base_isolation: bool
|
||||
socket_path: str
|
||||
|
||||
|
||||
class ConfigPatchRequest(BaseModel):
|
||||
"""Request body for configuration update endpoints."""
|
||||
live_mode: Optional[bool] = None
|
||||
concurrent_slots: Optional[int] = None
|
||||
enforce_same_base_isolation: Optional[bool] = None
|
||||
|
||||
|
||||
class ConfigPatchResponse(BaseModel):
|
||||
"""Response after a configuration update, echoing the new values."""
|
||||
ok: bool
|
||||
live_mode: Optional[bool] = None
|
||||
concurrent_slots: Optional[int] = None
|
||||
enforce_same_base_isolation: Optional[bool] = None
|
||||
|
||||
|
||||
class PauseResponse(BaseModel):
|
||||
"""Response after a pause request."""
|
||||
ok: bool
|
||||
|
||||
|
||||
class ResumeResponse(BaseModel):
|
||||
"""Response after a resume request."""
|
||||
ok: bool
|
||||
|
||||
|
||||
class CancelResponse(BaseModel):
|
||||
"""Response after a cancellation request."""
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
"""Runtime status of the executor."""
|
||||
status: str
|
||||
version: str
|
||||
live_mode: bool
|
||||
slots_used: int
|
||||
slots_total: int
|
||||
uptime_seconds: float
|
||||
|
||||
|
||||
class ShutdownResponse(BaseModel):
|
||||
"""Response after a shutdown request."""
|
||||
ok: bool
|
||||
|
||||
|
||||
def create_app(executor: Executor, shutdown_callback) -> FastAPI:
|
||||
"""
|
||||
Build the FastAPI application and wire all routes to the executor.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
executor : Executor
|
||||
The live Executor instance to control.
|
||||
shutdown_callback : callable
|
||||
Called when /api/v1/shutdown is hit to begin clean shutdown.
|
||||
|
||||
Returns
|
||||
-------
|
||||
FastAPI
|
||||
Configured app ready to be passed to uvicorn.
|
||||
"""
|
||||
app = FastAPI(title="Executor API", description="Control interface for the triangular arbitrage executor")
|
||||
|
||||
@app.get("/api/v1/status", response_model=StatusResponse)
|
||||
async def get_status() -> StatusResponse:
|
||||
config = executor.get_config()
|
||||
in_flight = executor.get_in_flight()
|
||||
return StatusResponse(
|
||||
status="ok",
|
||||
version="0.1.0",
|
||||
live_mode=config["live_mode"],
|
||||
slots_used=len(in_flight),
|
||||
slots_total=config["concurrent_slots"],
|
||||
uptime_seconds=executor.get_uptime_seconds(),
|
||||
)
|
||||
|
||||
@app.get("/api/v1/config", response_model=ConfigResponse)
|
||||
async def get_config() -> ConfigResponse:
|
||||
cfg = executor.get_config()
|
||||
return ConfigResponse(
|
||||
paper_mode=cfg["paper_mode"],
|
||||
concurrent_slots=cfg["concurrent_slots"],
|
||||
enforce_same_base_isolation=cfg["enforce_same_base_isolation"],
|
||||
socket_path=cfg["socket_path"],
|
||||
)
|
||||
|
||||
@app.patch("/api/v1/config/live_mode", response_model=ConfigPatchResponse)
|
||||
async def patch_live_mode(req: ConfigPatchRequest) -> ConfigPatchResponse:
|
||||
if req.live_mode is None:
|
||||
raise HTTPException(status_code=400, detail="live_mode required")
|
||||
executor._live_mode = req.live_mode
|
||||
return ConfigPatchResponse(ok=True, live_mode=req.live_mode)
|
||||
|
||||
@app.patch("/api/v1/config/concurrent_slots", response_model=ConfigPatchResponse)
|
||||
async def patch_concurrent_slots(req: ConfigPatchRequest) -> ConfigPatchResponse:
|
||||
if req.concurrent_slots is None or req.concurrent_slots < 1:
|
||||
raise HTTPException(status_code=400, detail="concurrent_slots must be >= 1")
|
||||
executor.set_concurrent_slots(req.concurrent_slots)
|
||||
return ConfigPatchResponse(ok=True, concurrent_slots=req.concurrent_slots)
|
||||
|
||||
@app.patch("/api/v1/config/enforce_same_base_isolation", response_model=ConfigPatchResponse)
|
||||
async def patch_isolation(req: ConfigPatchRequest) -> ConfigPatchResponse:
|
||||
if req.enforce_same_base_isolation is None:
|
||||
raise HTTPException(status_code=400, detail="enforce_same_base_isolation required")
|
||||
executor._settings.enforce_same_base_isolation = req.enforce_same_base_isolation
|
||||
return ConfigPatchResponse(ok=True, enforce_same_base_isolation=req.enforce_same_base_isolation)
|
||||
|
||||
@app.get("/api/v1/executions")
|
||||
async def get_executions() -> list[dict]:
|
||||
return executor.get_in_flight()
|
||||
|
||||
@app.get("/api/v1/executions/{correlation_id}")
|
||||
async def get_execution(correlation_id: str) -> dict:
|
||||
in_flight = executor.get_in_flight()
|
||||
for inf in in_flight:
|
||||
if inf["correlation_id"] == correlation_id:
|
||||
return inf
|
||||
raise HTTPException(status_code=404, detail="Execution not found")
|
||||
|
||||
@app.post("/api/v1/cancel/{correlation_id}", response_model=CancelResponse)
|
||||
async def cancel_execution(correlation_id: str) -> CancelResponse:
|
||||
ok = await executor.cancel_execution(correlation_id)
|
||||
if ok:
|
||||
return CancelResponse(ok=True, message=f"Cancellation requested for {correlation_id}")
|
||||
raise HTTPException(status_code=404, detail="Execution not found")
|
||||
|
||||
@app.post("/api/v1/pause", response_model=PauseResponse)
|
||||
async def pause() -> PauseResponse:
|
||||
await executor.pause()
|
||||
return PauseResponse(ok=True)
|
||||
|
||||
@app.post("/api/v1/resume", response_model=ResumeResponse)
|
||||
async def resume() -> ResumeResponse:
|
||||
await executor.resume()
|
||||
return ResumeResponse(ok=True)
|
||||
|
||||
@app.post("/api/v1/shutdown", response_model=ShutdownResponse)
|
||||
async def shutdown() -> ShutdownResponse:
|
||||
await executor.pause()
|
||||
shutdown_callback()
|
||||
return ShutdownResponse(ok=True)
|
||||
|
||||
@app.get("/api/v1/reports")
|
||||
async def get_reports(limit: int = 50) -> list[dict]:
|
||||
return executor.get_reports(limit=limit)
|
||||
|
||||
return app
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"""
|
||||
Unix-domain socket server that receives opportunity signals from oe_em.
|
||||
|
||||
Each JSON line on the socket is parsed and dispatched as an asyncio task
|
||||
to avoid blocking the reader.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger().bind(component="signal_socket_server")
|
||||
|
||||
|
||||
class SignalSocketServer:
|
||||
"""
|
||||
Accepts JSON-serialized signals over a Unix domain socket.
|
||||
|
||||
Every valid "signal" message is wrapped in create_task so processing
|
||||
is concurrent and a slow handler never blocks new connections.
|
||||
"""
|
||||
|
||||
def __init__(self, socket_path: Path, on_signal) -> None:
|
||||
self._socket_path = socket_path
|
||||
self._on_signal = on_signal
|
||||
self._log = logger
|
||||
self._server: asyncio.Server | None = None
|
||||
self._running = False
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Remove any stale socket file and start accepting connections."""
|
||||
if self._socket_path.exists():
|
||||
self._socket_path.unlink()
|
||||
|
||||
self._running = True
|
||||
self._server = await asyncio.start_unix_server(
|
||||
self._accept_client,
|
||||
path=str(self._socket_path),
|
||||
)
|
||||
self._log.info("signal_socket_server_started", path=str(self._socket_path))
|
||||
async with self._server:
|
||||
await self._server.serve_forever()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the server and remove the socket file."""
|
||||
self._running = False
|
||||
if self._server:
|
||||
self._server.close()
|
||||
await self._server.wait_closed()
|
||||
if self._socket_path.exists():
|
||||
self._socket_path.unlink()
|
||||
self._log.info("signal_socket_server_stopped")
|
||||
|
||||
async def _accept_client(
|
||||
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
"""
|
||||
Handle one client connection: read lines, parse JSON, dispatch signals.
|
||||
|
||||
The client is assumed to be oe_em's SignalSocketClient. Any line that
|
||||
is not JSON or is not type="signal" is silently ignored.
|
||||
"""
|
||||
self._log.info("client_connected", addr=writer.get_extra_info("peername"))
|
||||
try:
|
||||
while self._running:
|
||||
try:
|
||||
line = await reader.readline()
|
||||
except (ConnectionResetError, BrokenPipeError, asyncio.CancelledError):
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
if not line:
|
||||
break
|
||||
|
||||
arrived_ms = int(time.time() * 1000)
|
||||
|
||||
try:
|
||||
data = json.loads(line.decode())
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
self._log.warning("invalid_json", line=line[:50], error=str(e))
|
||||
continue
|
||||
|
||||
if data.get("type") == "signal":
|
||||
data["_receiver_ts_ms"] = arrived_ms
|
||||
asyncio.create_task(self._on_signal(data))
|
||||
else:
|
||||
self._log.debug("ignored_non_signal", type=data.get("type"))
|
||||
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self._log.warning("client_error", error=str(e))
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await asyncio.wait_for(writer.wait_closed(), timeout=1.0)
|
||||
except Exception:
|
||||
pass
|
||||
self._log.info("client_disconnected")
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
"""
|
||||
KuCoin private WebSocket client for fill event delivery.
|
||||
|
||||
Manages the private WebSocket connection, authenticates via bullet-private,
|
||||
subscribes to /spotMarket/tradeOrdersV2, and dispatches fill events to
|
||||
waiting executor coroutines.
|
||||
|
||||
KuCoin market orders placed with ``funds`` often complete with a tiny
|
||||
unfilled remainder, emitting ``canceled`` + ``status=done`` rather than
|
||||
``filled``. The message handler in ``_handle_message`` treats this
|
||||
terminal combination as a successful fill when match events have been
|
||||
accumulated.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
import websockets
|
||||
|
||||
from executor.kucoin_api import KuCoinAPI
|
||||
|
||||
logger = structlog.get_logger().bind(component="executor-ws")
|
||||
_D0 = Decimal("0")
|
||||
|
||||
|
||||
class FillAccumulator:
|
||||
"""Accumulate match events for a single order, compute aggregated totals."""
|
||||
|
||||
def __init__(self, client_oid: str, order_id: str = "") -> None:
|
||||
self.client_oid = client_oid
|
||||
self.order_id = order_id
|
||||
self.total_size = _D0
|
||||
self.total_funds = _D0
|
||||
self.match_count = 0
|
||||
self.side = ""
|
||||
self.symbol = ""
|
||||
|
||||
def add_match(self, data: dict) -> None:
|
||||
match_price = Decimal(str(data.get("matchPrice", "0")))
|
||||
match_size = Decimal(str(data.get("matchSize", "0")))
|
||||
self.total_size += match_size
|
||||
self.total_funds += match_price * match_size
|
||||
self.match_count += 1
|
||||
if not self.side:
|
||||
self.side = data.get("side", "")
|
||||
if not self.symbol:
|
||||
self.symbol = data.get("symbol", "")
|
||||
if not self.order_id:
|
||||
self.order_id = data.get("orderId", "")
|
||||
|
||||
@property
|
||||
def weighted_avg_price(self) -> Decimal:
|
||||
if self.total_size <= 0:
|
||||
return _D0
|
||||
return self.total_funds / self.total_size
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"total_size": self.total_size,
|
||||
"total_funds": self.total_funds,
|
||||
"weighted_avg_price": self.weighted_avg_price,
|
||||
"match_count": self.match_count,
|
||||
"order_id": self.order_id,
|
||||
"side": self.side,
|
||||
"symbol": self.symbol,
|
||||
}
|
||||
|
||||
|
||||
class KuCoinWSClient:
|
||||
"""
|
||||
Private WebSocket client for KuCoin execution events.
|
||||
|
||||
Subscribes to /spotMarket/tradeOrdersV2 (global topic) and dispatches
|
||||
fill events to awaiting executor coroutines via await_fill().
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
kucoin_api: KuCoinAPI,
|
||||
private_token_url: str = "https://api.kucoin.com/api/v1/bullet-private",
|
||||
reconnect_base_delay: float = 1.0,
|
||||
reconnect_max_delay: float = 30.0,
|
||||
) -> None:
|
||||
self._api = kucoin_api
|
||||
self._private_token_url = private_token_url
|
||||
self._reconnect_base_delay = reconnect_base_delay
|
||||
self._reconnect_max_delay = reconnect_max_delay
|
||||
self._reconnect_delay = reconnect_base_delay
|
||||
self._log = logger
|
||||
self._running = False
|
||||
self._ws: Optional[websockets.WebSocketClientProtocol] = None
|
||||
self._connected = False
|
||||
self._ping_interval: float = 18.0
|
||||
self._ping_timeout: float = 10.0
|
||||
self._fill_futures: dict[str, asyncio.Future] = {}
|
||||
self._accumulators: dict[str, FillAccumulator] = {}
|
||||
self._worker_task: Optional[asyncio.Task] = None
|
||||
self._subscribe_ack_event: Optional[asyncio.Event] = None
|
||||
self._subscribe_ack_id: Optional[str] = None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the WebSocket connection worker with reconnection loop."""
|
||||
self._running = True
|
||||
self._worker_task = asyncio.create_task(self._connection_worker())
|
||||
try:
|
||||
await self._worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the WebSocket connection and resolve any pending futures."""
|
||||
self._running = False
|
||||
for future in self._fill_futures.values():
|
||||
if not future.done():
|
||||
future.set_result((False, {}))
|
||||
self._fill_futures.clear()
|
||||
self._accumulators.clear()
|
||||
if self._worker_task is not None and not self._worker_task.done():
|
||||
self._worker_task.cancel()
|
||||
if self._ws is not None:
|
||||
try:
|
||||
await self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._connected = False
|
||||
self._log.debug("ws_client_stopped")
|
||||
|
||||
async def await_fill(
|
||||
self, client_oid: str, timeout_ms: float
|
||||
) -> tuple[bool, dict]:
|
||||
"""
|
||||
Wait for the order identified by client_oid to be fully filled.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
client_oid : str
|
||||
The client-order ID used when placing the order.
|
||||
timeout_ms : float
|
||||
Maximum wait time in milliseconds.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[bool, dict]
|
||||
(success, aggregated_fill_data) on fill.
|
||||
(False, {}) on timeout, failure, or disconnected.
|
||||
"""
|
||||
if not self._connected:
|
||||
self._log.warning(
|
||||
"await_fill_not_connected",
|
||||
client_oid=client_oid,
|
||||
)
|
||||
return (False, {})
|
||||
|
||||
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||
self._fill_futures[client_oid] = future
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(future, timeout=timeout_ms / 1000.0)
|
||||
except asyncio.TimeoutError:
|
||||
self._fill_futures.pop(client_oid, None)
|
||||
self._log.warning(
|
||||
"fill_timeout",
|
||||
client_oid=client_oid,
|
||||
timeout_ms=timeout_ms,
|
||||
accumulator=(
|
||||
self._accumulators.get(client_oid).to_dict()
|
||||
if client_oid in self._accumulators
|
||||
else None
|
||||
),
|
||||
)
|
||||
return (False, {})
|
||||
except asyncio.CancelledError:
|
||||
self._fill_futures.pop(client_oid, None)
|
||||
raise
|
||||
|
||||
result = future.result()
|
||||
self._fill_futures.pop(client_oid, None)
|
||||
if client_oid in self._accumulators:
|
||||
del self._accumulators[client_oid]
|
||||
return result
|
||||
|
||||
async def _connection_worker(self) -> None:
|
||||
"""Main connection loop with exponential backoff reconnection."""
|
||||
while self._running:
|
||||
try:
|
||||
await self._connect_and_run()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
if not self._running:
|
||||
break
|
||||
self._connected = False
|
||||
self._log.warning(
|
||||
"ws_reconnecting",
|
||||
error=str(e),
|
||||
delay=self._reconnect_delay,
|
||||
)
|
||||
await asyncio.sleep(self._reconnect_delay)
|
||||
self._reconnect_delay = min(
|
||||
self._reconnect_delay * 2,
|
||||
self._reconnect_max_delay,
|
||||
)
|
||||
|
||||
async def _connect_and_run(self) -> None:
|
||||
"""Authenticate, connect, subscribe, and run the message loop."""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
token_data = await self._api.get_private_token(session)
|
||||
if not token_data:
|
||||
raise RuntimeError("Failed to obtain private token")
|
||||
|
||||
token = token_data.get("token", "")
|
||||
servers = token_data.get("instanceServers", [])
|
||||
if not servers:
|
||||
raise RuntimeError("No instance servers in token response")
|
||||
|
||||
server = servers[0]
|
||||
endpoint = server.get("endpoint", "")
|
||||
self._ping_interval = server.get("pingInterval", 18000) / 1000.0
|
||||
self._ping_timeout = server.get("pingTimeout", 10000) / 1000.0
|
||||
|
||||
ws_url = f"{endpoint}?token={token}&connectId={uuid.uuid4()}"
|
||||
self._log.debug("ws_connecting", url=ws_url[:80])
|
||||
|
||||
self._ws = await websockets.connect(
|
||||
ws_url,
|
||||
ping_interval=self._ping_interval,
|
||||
ping_timeout=self._ping_timeout,
|
||||
)
|
||||
self._connected = True
|
||||
self._reconnect_delay = self._reconnect_base_delay
|
||||
self._log.info("ws_connected")
|
||||
|
||||
sub_task = asyncio.create_task(self._subscribe())
|
||||
|
||||
try:
|
||||
async for msg in self._ws:
|
||||
await self._handle_message(msg)
|
||||
except websockets.ConnectionClosed as e:
|
||||
self._log.warning(
|
||||
"ws_connection_closed",
|
||||
code=e.code,
|
||||
reason=e.reason,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
sub_task.cancel()
|
||||
raise
|
||||
except Exception as e:
|
||||
self._log.error("ws_message_loop_error", error=str(e))
|
||||
finally:
|
||||
self._connected = False
|
||||
|
||||
async def _subscribe(self) -> None:
|
||||
"""Subscribe to the global tradeOrdersV2 topic."""
|
||||
if self._ws is None:
|
||||
return
|
||||
|
||||
ack_id = int(time.time() * 1000)
|
||||
self._subscribe_ack_id = str(ack_id)
|
||||
self._subscribe_ack_event = asyncio.Event()
|
||||
|
||||
sub_msg = {
|
||||
"id": ack_id,
|
||||
"type": "subscribe",
|
||||
"topic": "/spotMarket/tradeOrdersV2",
|
||||
"response": True,
|
||||
"privateChannel": "true",
|
||||
}
|
||||
|
||||
await self._ws.send(json.dumps(sub_msg))
|
||||
self._log.info("subscribe_sent", topic="/spotMarket/tradeOrdersV2")
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(self._subscribe_ack_event.wait(), timeout=15.0)
|
||||
self._log.info("subscription_confirmed", topic="/spotMarket/tradeOrdersV2")
|
||||
except asyncio.TimeoutError:
|
||||
self._log.warning("subscription_ack_timeout", topic="/spotMarket/tradeOrdersV2")
|
||||
|
||||
async def _handle_message(self, msg: str) -> None:
|
||||
"""Parse incoming WS message and dispatch fill events."""
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except json.JSONDecodeError:
|
||||
self._log.warning("ws_raw_message_parse_error", raw=msg[:500])
|
||||
return
|
||||
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "welcome":
|
||||
return
|
||||
|
||||
if msg_type == "pong":
|
||||
return
|
||||
|
||||
if msg_type == "ack":
|
||||
ack_id = str(data.get("id", ""))
|
||||
if ack_id == self._subscribe_ack_id and self._subscribe_ack_event is not None:
|
||||
self._subscribe_ack_event.set()
|
||||
self._subscribe_ack_event = None
|
||||
self._subscribe_ack_id = None
|
||||
return
|
||||
|
||||
if msg_type != "message":
|
||||
return
|
||||
|
||||
subject = data.get("subject", "")
|
||||
if subject != "orderChange":
|
||||
return
|
||||
|
||||
payload = data.get("data", {})
|
||||
event_type = payload.get("type", "")
|
||||
client_oid = payload.get("clientOid", "")
|
||||
order_id = payload.get("orderId", "")
|
||||
status = payload.get("status", "")
|
||||
|
||||
if not client_oid:
|
||||
return
|
||||
|
||||
if event_type == "match":
|
||||
if client_oid not in self._accumulators:
|
||||
self._accumulators[client_oid] = FillAccumulator(client_oid, order_id)
|
||||
self._accumulators[client_oid].add_match(payload)
|
||||
|
||||
elif event_type == "filled" and status == "done":
|
||||
if client_oid in self._accumulators:
|
||||
acc = self._accumulators[client_oid]
|
||||
acc.order_id = order_id or acc.order_id
|
||||
fill_data = acc.to_dict()
|
||||
self._log.debug(
|
||||
"fill_received",
|
||||
client_oid=client_oid,
|
||||
order_id=order_id,
|
||||
total_size=fill_data["total_size"],
|
||||
total_funds=fill_data["total_funds"],
|
||||
avg_price=fill_data["weighted_avg_price"],
|
||||
match_count=fill_data["match_count"],
|
||||
)
|
||||
else:
|
||||
# Shouldn't happen in normal flow, but handle defensively.
|
||||
fill_data = {
|
||||
"total_size": Decimal(str(payload.get("filledSize", "0"))),
|
||||
"total_funds": _D0,
|
||||
"weighted_avg_price": _D0,
|
||||
"match_count": 0,
|
||||
"order_id": order_id,
|
||||
"side": payload.get("side", ""),
|
||||
"symbol": payload.get("symbol", ""),
|
||||
}
|
||||
self._log.warning(
|
||||
"filled_without_matches",
|
||||
client_oid=client_oid,
|
||||
order_id=order_id,
|
||||
)
|
||||
|
||||
if client_oid in self._fill_futures:
|
||||
future = self._fill_futures[client_oid]
|
||||
if not future.done():
|
||||
future.set_result((True, fill_data))
|
||||
|
||||
elif event_type in ("canceled", "failed"):
|
||||
self._log.warning(
|
||||
"ws_terminal_event_full_payload",
|
||||
client_oid=client_oid,
|
||||
event_type=event_type,
|
||||
status=status,
|
||||
full_payload=json.dumps(payload, indent=2),
|
||||
)
|
||||
# Market orders with `funds` send type="canceled" + status="done" when
|
||||
# the order completes with a tiny remainder. If we have accumulated
|
||||
# matches, this is actually a successful fill.
|
||||
if (
|
||||
event_type == "canceled"
|
||||
and status == "done"
|
||||
and client_oid in self._accumulators
|
||||
):
|
||||
acc = self._accumulators[client_oid]
|
||||
if acc.match_count > 0:
|
||||
acc.order_id = order_id or acc.order_id
|
||||
fill_data = acc.to_dict()
|
||||
self._log.info(
|
||||
"fill_via_cancel_done",
|
||||
client_oid=client_oid,
|
||||
order_id=order_id,
|
||||
total_size=fill_data["total_size"],
|
||||
total_funds=fill_data["total_funds"],
|
||||
avg_price=fill_data["weighted_avg_price"],
|
||||
match_count=fill_data["match_count"],
|
||||
)
|
||||
if client_oid in self._fill_futures:
|
||||
future = self._fill_futures[client_oid]
|
||||
if not future.done():
|
||||
future.set_result((True, fill_data))
|
||||
return
|
||||
|
||||
if client_oid in self._fill_futures:
|
||||
future = self._fill_futures[client_oid]
|
||||
if not future.done():
|
||||
future.set_result((False, {}))
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"""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",
|
||||
]
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
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())
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
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()
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
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
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
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)
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
"""
|
||||
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())
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
"""
|
||||
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")
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"""
|
||||
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"}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"""
|
||||
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"]]
|
||||
|
|
@ -0,0 +1,408 @@
|
|||
"""
|
||||
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))
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"""
|
||||
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
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"""
|
||||
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
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
"""
|
||||
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)
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
[project]
|
||||
name = "triangular-arb"
|
||||
version = "0.1.0"
|
||||
description = "Triangular arbitrage bot for KuCoin"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"websockets>=12.0",
|
||||
"aiohttp>=3.9",
|
||||
"structlog>=24.0",
|
||||
"PyYAML>=6.0",
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.27",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# System build dependencies
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
cmake build-essential pkg-config \
|
||||
libssl-dev libyaml-dev
|
||||
|
||||
# Python virtualenv (for executor)
|
||||
python3 -m venv "$PROJECT_DIR/.venv"
|
||||
source "$PROJECT_DIR/.venv/bin/activate"
|
||||
pip install --upgrade pip
|
||||
pip install -e "$PROJECT_DIR[dev]"
|
||||
|
||||
# Build fused_engine C binary
|
||||
cd "$PROJECT_DIR"
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake ../src -DCMAKE_BUILD_TYPE=Release
|
||||
make -j"$(nproc)"
|
||||
|
||||
echo "Done. Activate Python venv with: source $PROJECT_DIR/.venv/bin/activate"
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
cmake_minimum_required(VERSION 3.22)
|
||||
project(fused_engine C)
|
||||
|
||||
set(CMAKE_C_STANDARD 17)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
find_package(OpenSSL REQUIRED)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(YAML REQUIRED yaml-0.1)
|
||||
|
||||
file(GLOB SRCS "*.c")
|
||||
list(REMOVE_ITEM SRCS "${CMAKE_CURRENT_SOURCE_DIR}/jsmn.c")
|
||||
|
||||
add_executable(fused_engine ${SRCS})
|
||||
target_include_directories(fused_engine PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${OPENSSL_INCLUDE_DIR} ${YAML_INCLUDE_DIRS})
|
||||
target_link_libraries(fused_engine PRIVATE ${OPENSSL_LIBRARIES} ${YAML_LIBRARIES} pthread rt m)
|
||||
|
||||
target_compile_options(fused_engine PRIVATE
|
||||
-O3 -march=native -Wall -Wextra -Wpedantic
|
||||
-Wno-unused-parameter
|
||||
)
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* book.c - Global order book storage
|
||||
*
|
||||
* Maintains a flat array of order books indexed by symbol index.
|
||||
* The ws_client parse_book_update directly writes to client->books[],
|
||||
* bypassing this module. This file provides a legacy API for
|
||||
* manual updates from string arrays.
|
||||
*/
|
||||
|
||||
#include "book.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static order_book_t g_books[MAX_SYMBOLS];
|
||||
static uint32_t g_book_count = 0;
|
||||
|
||||
void book_init(void) {
|
||||
memset(g_books, 0, sizeof(g_books));
|
||||
g_book_count = 0;
|
||||
}
|
||||
|
||||
order_book_t *book_get(uint16_t symbol_idx) {
|
||||
if (symbol_idx >= g_book_count) return NULL;
|
||||
return &g_books[symbol_idx];
|
||||
}
|
||||
|
||||
uint32_t book_count(void) {
|
||||
return g_book_count;
|
||||
}
|
||||
|
||||
/*
|
||||
* Update book from string-encoded price/size arrays.
|
||||
* bids/asks are flattened [price0, size0, price1, size1, ...] string arrays.
|
||||
*/
|
||||
order_book_t *book_update(uint16_t symbol_idx, int64_t ts_ms, int64_t sequence,
|
||||
const char **bids, uint32_t bid_count,
|
||||
const char **asks, uint32_t ask_count) {
|
||||
if (symbol_idx >= MAX_SYMBOLS) return NULL;
|
||||
|
||||
order_book_t *book = &g_books[symbol_idx];
|
||||
book->symbol_idx = symbol_idx;
|
||||
book->ts_ms = ts_ms;
|
||||
book->sequence = sequence;
|
||||
|
||||
book->bid_count = bid_count > MAX_BOOK_LEVELS ? MAX_BOOK_LEVELS : bid_count;
|
||||
for (uint32_t i = 0; i < book->bid_count; i++) {
|
||||
book->bids[i][0] = atof(bids[i * 2]);
|
||||
book->bids[i][1] = atof(bids[i * 2 + 1]);
|
||||
}
|
||||
|
||||
book->ask_count = ask_count > MAX_BOOK_LEVELS ? MAX_BOOK_LEVELS : ask_count;
|
||||
for (uint32_t i = 0; i < book->ask_count; i++) {
|
||||
book->asks[i][0] = atof(asks[i * 2]);
|
||||
book->asks[i][1] = atof(asks[i * 2 + 1]);
|
||||
}
|
||||
|
||||
if (symbol_idx >= g_book_count) {
|
||||
g_book_count = symbol_idx + 1;
|
||||
}
|
||||
|
||||
return book;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#ifndef FUSED_BOOK_H
|
||||
#define FUSED_BOOK_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#define SYMBOL_NAME_LEN 16
|
||||
#define CURRENCY_NAME_LEN 8
|
||||
#define MAX_BOOK_LEVELS 5
|
||||
#define MAX_SYMBOLS 2048
|
||||
|
||||
/* In-memory order book snapshot for a single trading pair */
|
||||
typedef struct {
|
||||
uint16_t symbol_idx; /* index into the symbol table */
|
||||
int64_t ts_ms; /* book timestamp (milliseconds) */
|
||||
int64_t sequence; /* exchange sequence number */
|
||||
double bids[MAX_BOOK_LEVELS][2]; /* bid levels [price, size] */
|
||||
double asks[MAX_BOOK_LEVELS][2]; /* ask levels [price, size] */
|
||||
uint8_t bid_count; /* number of valid bid levels */
|
||||
uint8_t ask_count; /* number of valid ask levels */
|
||||
char symbol[SYMBOL_NAME_LEN]; /* trading pair symbol name */
|
||||
} order_book_t;
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,306 @@
|
|||
/*
|
||||
Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef cJSON__h
|
||||
#define cJSON__h
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"
|
||||
{
|
||||
#endif
|
||||
|
||||
#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
|
||||
#define __WINDOWS__
|
||||
#endif
|
||||
|
||||
#ifdef __WINDOWS__
|
||||
|
||||
/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention. For windows you have 3 define options:
|
||||
|
||||
CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
|
||||
CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
|
||||
CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
|
||||
|
||||
For *nix builds that support visibility attribute, you can define similar behavior by
|
||||
|
||||
setting default visibility to hidden by adding
|
||||
-fvisibility=hidden (for gcc)
|
||||
or
|
||||
-xldscope=hidden (for sun cc)
|
||||
to CFLAGS
|
||||
|
||||
then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
|
||||
|
||||
*/
|
||||
|
||||
#define CJSON_CDECL __cdecl
|
||||
#define CJSON_STDCALL __stdcall
|
||||
|
||||
/* export symbols by default, this is necessary for copy pasting the C and header file */
|
||||
#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
|
||||
#define CJSON_EXPORT_SYMBOLS
|
||||
#endif
|
||||
|
||||
#if defined(CJSON_HIDE_SYMBOLS)
|
||||
#define CJSON_PUBLIC(type) type CJSON_STDCALL
|
||||
#elif defined(CJSON_EXPORT_SYMBOLS)
|
||||
#define CJSON_PUBLIC(type) __declspec(dllexport) type CJSON_STDCALL
|
||||
#elif defined(CJSON_IMPORT_SYMBOLS)
|
||||
#define CJSON_PUBLIC(type) __declspec(dllimport) type CJSON_STDCALL
|
||||
#endif
|
||||
#else /* !__WINDOWS__ */
|
||||
#define CJSON_CDECL
|
||||
#define CJSON_STDCALL
|
||||
|
||||
#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
|
||||
#define CJSON_PUBLIC(type) __attribute__((visibility("default"))) type
|
||||
#else
|
||||
#define CJSON_PUBLIC(type) type
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* project version */
|
||||
#define CJSON_VERSION_MAJOR 1
|
||||
#define CJSON_VERSION_MINOR 7
|
||||
#define CJSON_VERSION_PATCH 19
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* cJSON Types: */
|
||||
#define cJSON_Invalid (0)
|
||||
#define cJSON_False (1 << 0)
|
||||
#define cJSON_True (1 << 1)
|
||||
#define cJSON_NULL (1 << 2)
|
||||
#define cJSON_Number (1 << 3)
|
||||
#define cJSON_String (1 << 4)
|
||||
#define cJSON_Array (1 << 5)
|
||||
#define cJSON_Object (1 << 6)
|
||||
#define cJSON_Raw (1 << 7) /* raw json */
|
||||
|
||||
#define cJSON_IsReference 256
|
||||
#define cJSON_StringIsConst 512
|
||||
|
||||
/* The cJSON structure: */
|
||||
typedef struct cJSON
|
||||
{
|
||||
/* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
|
||||
struct cJSON *next;
|
||||
struct cJSON *prev;
|
||||
/* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
|
||||
struct cJSON *child;
|
||||
|
||||
/* The type of the item, as above. */
|
||||
int type;
|
||||
|
||||
/* The item's string, if type==cJSON_String and type == cJSON_Raw */
|
||||
char *valuestring;
|
||||
/* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
|
||||
int valueint;
|
||||
/* The item's number, if type==cJSON_Number */
|
||||
double valuedouble;
|
||||
|
||||
/* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
|
||||
char *string;
|
||||
} cJSON;
|
||||
|
||||
typedef struct cJSON_Hooks
|
||||
{
|
||||
/* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
|
||||
void *(CJSON_CDECL *malloc_fn)(size_t sz);
|
||||
void (CJSON_CDECL *free_fn)(void *ptr);
|
||||
} cJSON_Hooks;
|
||||
|
||||
typedef int cJSON_bool;
|
||||
|
||||
/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
|
||||
* This is to prevent stack overflows. */
|
||||
#ifndef CJSON_NESTING_LIMIT
|
||||
#define CJSON_NESTING_LIMIT 1000
|
||||
#endif
|
||||
|
||||
/* Limits the length of circular references can be before cJSON rejects to parse them.
|
||||
* This is to prevent stack overflows. */
|
||||
#ifndef CJSON_CIRCULAR_LIMIT
|
||||
#define CJSON_CIRCULAR_LIMIT 10000
|
||||
#endif
|
||||
|
||||
/* returns the version of cJSON as a string */
|
||||
CJSON_PUBLIC(const char*) cJSON_Version(void);
|
||||
|
||||
/* Supply malloc, realloc and free functions to cJSON */
|
||||
CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
|
||||
|
||||
/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
|
||||
/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
|
||||
/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
|
||||
/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
|
||||
|
||||
/* Render a cJSON entity to text for transfer/storage. */
|
||||
CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
|
||||
/* Render a cJSON entity to text for transfer/storage without any formatting. */
|
||||
CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
|
||||
/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
|
||||
CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
|
||||
/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
|
||||
/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
|
||||
/* Delete a cJSON entity and all subentities. */
|
||||
CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
|
||||
|
||||
/* Returns the number of items in an array (or object). */
|
||||
CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
|
||||
/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
|
||||
/* Get item "string" from object. Case insensitive. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
|
||||
/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
|
||||
CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
|
||||
|
||||
/* Check item type and return its value */
|
||||
CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
|
||||
CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
|
||||
|
||||
/* These functions check the type of an item */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
|
||||
|
||||
/* These calls create a cJSON item of the appropriate type. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
|
||||
/* raw json */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
|
||||
|
||||
/* Create a string where valuestring references a string so
|
||||
* it will not be freed by cJSON_Delete */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
|
||||
/* Create an object/array that only references it's elements so
|
||||
* they will not be freed by cJSON_Delete */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
|
||||
|
||||
/* These utilities create an Array of count items.
|
||||
* The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
|
||||
|
||||
/* Append item to the specified array/object. */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
|
||||
/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
|
||||
* WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
|
||||
* writing to `item->string` */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
|
||||
/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
|
||||
|
||||
/* Remove/Detach items from Arrays/Objects. */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
|
||||
CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
|
||||
CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
|
||||
CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
|
||||
CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
|
||||
|
||||
/* Update array items. */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
|
||||
|
||||
/* Duplicate a cJSON item */
|
||||
CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
|
||||
/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
|
||||
* need to be released. With recurse!=0, it will duplicate any children connected to the item.
|
||||
* The item->next and ->prev pointers are always zero on return from Duplicate. */
|
||||
/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
|
||||
* case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
|
||||
CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
|
||||
|
||||
/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
|
||||
* The input pointer json cannot point to a read-only address area, such as a string constant,
|
||||
* but should point to a readable and writable address area. */
|
||||
CJSON_PUBLIC(void) cJSON_Minify(char *json);
|
||||
|
||||
/* Helper functions for creating and adding items to an object at the same time.
|
||||
* They return the added item or NULL on failure. */
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
|
||||
CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
|
||||
|
||||
/* When assigning an integer value, it needs to be propagated to valuedouble too. */
|
||||
#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
|
||||
/* helper for the cJSON_SetNumberValue macro */
|
||||
CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
|
||||
#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
|
||||
/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
|
||||
CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
|
||||
|
||||
/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/
|
||||
#define cJSON_SetBoolValue(object, boolValue) ( \
|
||||
(object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \
|
||||
(object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \
|
||||
cJSON_Invalid\
|
||||
)
|
||||
|
||||
/* Macro for iterating over an array or object */
|
||||
#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
|
||||
|
||||
/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
|
||||
CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
|
||||
CJSON_PUBLIC(void) cJSON_free(void *object);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
/*
|
||||
* config.c - libyaml-based config parser for fused_engine
|
||||
*
|
||||
* Parses config.yaml, supporting three levels:
|
||||
* - Top level: api keys, live mode flag
|
||||
* - fused_engine section: thresholds, symbols, hold currencies, connection params
|
||||
* - executor section: ignored (consumed by separate executor binary)
|
||||
*
|
||||
* Uses a state machine (parse_state_t) tracking section, mapping depth,
|
||||
* sequence membership (symbols/hold/excluded lists), and key/value alternation.
|
||||
*/
|
||||
|
||||
#include "log.h"
|
||||
#include "config.h"
|
||||
#include <yaml.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
static void copy_string(const char *src, char *dst, size_t dst_len) {
|
||||
if (!src) return;
|
||||
strncpy(dst, src, dst_len - 1);
|
||||
dst[dst_len - 1] = '\0';
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
config_t *cfg;
|
||||
char section[32]; // current YAML section name (e.g. "fused_engine")
|
||||
char current_key[64]; // last seen scalar key
|
||||
bool in_symbols; // inside fused_engine.symbols sequence
|
||||
bool in_hold; // inside fused_engine.hold_currencies
|
||||
bool in_excluded; // inside fused_engine.excluded_currencies
|
||||
bool in_capital_map; // inside fused_engine.initial_capital mapping
|
||||
char capital_currency[CURRENCY_NAME_LEN]; // current currency key in capital map
|
||||
bool expect_key; // true = next scalar is a key, false = next is a value
|
||||
int mapping_depth; // tracks nesting depth for section detection
|
||||
} parse_state_t;
|
||||
|
||||
/*
|
||||
* Dispatch a parsed key-value pair to the appropriate config field.
|
||||
* Section determines which field group to look up.
|
||||
*/
|
||||
static void handle_value(parse_state_t *st, const char *val) {
|
||||
const char *key = st->current_key;
|
||||
|
||||
if (strcmp(st->section, "fused_engine") == 0) {
|
||||
if (st->in_symbols) {
|
||||
if (st->cfg->symbol_count < MAX_SYMBOLS) {
|
||||
copy_string(val, st->cfg->symbols[st->cfg->symbol_count], SYMBOL_NAME_LEN);
|
||||
st->cfg->symbol_count++;
|
||||
}
|
||||
} else if (st->in_hold) {
|
||||
if (st->cfg->hold_currency_count < MAX_HOLD_CURRENCIES) {
|
||||
copy_string(val, st->cfg->hold_currencies[st->cfg->hold_currency_count], CURRENCY_NAME_LEN);
|
||||
st->cfg->hold_currency_count++;
|
||||
}
|
||||
} else if (st->in_excluded) {
|
||||
if (st->cfg->excluded_currency_count < MAX_EXCLUDED_CURRENCIES) {
|
||||
copy_string(val, st->cfg->excluded_currencies[st->cfg->excluded_currency_count], CURRENCY_NAME_LEN);
|
||||
st->cfg->excluded_currency_count++;
|
||||
}
|
||||
} else if (strcmp(key, "log_level") == 0) {
|
||||
copy_string(val, st->cfg->log_level, sizeof(st->cfg->log_level));
|
||||
} else if (strcmp(key, "socket_path") == 0) {
|
||||
copy_string(val, st->cfg->socket_path, sizeof(st->cfg->socket_path));
|
||||
} else if (strcmp(key, "rest_host") == 0) {
|
||||
copy_string(val, st->cfg->rest_host, sizeof(st->cfg->rest_host));
|
||||
} else if (strcmp(key, "rest_port") == 0) {
|
||||
st->cfg->rest_port = atoi(val);
|
||||
} else if (strcmp(key, "ws_url") == 0) {
|
||||
copy_string(val, st->cfg->ws_url, sizeof(st->cfg->ws_url));
|
||||
} else if (strcmp(key, "token_url") == 0) {
|
||||
copy_string(val, st->cfg->token_url, sizeof(st->cfg->token_url));
|
||||
} else if (strcmp(key, "reconnect_base_delay") == 0) {
|
||||
st->cfg->reconnect_base_delay = atof(val);
|
||||
} else if (strcmp(key, "reconnect_max_delay") == 0) {
|
||||
st->cfg->reconnect_max_delay = atof(val);
|
||||
} else if (strcmp(key, "heartbeat_interval") == 0) {
|
||||
st->cfg->heartbeat_interval = atof(val);
|
||||
} else if (strcmp(key, "signal_threshold_bps") == 0) {
|
||||
st->cfg->signal_threshold_bps = atof(val);
|
||||
} else if (strcmp(key, "kcs_discount_active") == 0) {
|
||||
st->cfg->kcs_discount_active = (strcmp(val, "true") == 0 || strcmp(val, "yes") == 0);
|
||||
} else if (strcmp(key, "executor_socket_path") == 0) {
|
||||
copy_string(val, st->cfg->executor_socket_path, sizeof(st->cfg->executor_socket_path));
|
||||
} else if (strcmp(key, "send_signals") == 0) {
|
||||
st->cfg->send_signals = (strcmp(val, "true") == 0 || strcmp(val, "yes") == 0);
|
||||
} else if (strcmp(key, "cooldown_seconds") == 0) {
|
||||
st->cfg->cooldown_seconds = atof(val);
|
||||
} else if (strcmp(key, "stats_interval_seconds") == 0) {
|
||||
st->cfg->stats_interval_seconds = atof(val);
|
||||
}
|
||||
} else if (strcmp(st->section, "executor") == 0) {
|
||||
return;
|
||||
} else if (st->section[0] == '\0') {
|
||||
// Top-level keys (outside any named section)
|
||||
if (strcmp(key, "kucoin_api_key") == 0) {
|
||||
copy_string(val, st->cfg->kucoin_api_key, sizeof(st->cfg->kucoin_api_key));
|
||||
} else if (strcmp(key, "kucoin_api_secret") == 0) {
|
||||
copy_string(val, st->cfg->kucoin_api_secret, sizeof(st->cfg->kucoin_api_secret));
|
||||
} else if (strcmp(key, "kucoin_api_passphrase") == 0) {
|
||||
copy_string(val, st->cfg->kucoin_api_passphrase, sizeof(st->cfg->kucoin_api_passphrase));
|
||||
} else if (strcmp(key, "live_mode") == 0) {
|
||||
st->cfg->live_mode = (strcmp(val, "true") == 0 || strcmp(val, "yes") == 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Load and parse a YAML config file into config_t.
|
||||
* Uses the libyaml pull-parser API with a state machine to track:
|
||||
* - Section nesting (mapping_depth)
|
||||
* - Key/value alternation within mappings
|
||||
* - Sequence membership for arrays (symbols, hold_currencies, excluded_currencies)
|
||||
*
|
||||
* Sets sensible defaults before parsing so the file only needs to override.
|
||||
*/
|
||||
int config_load(const char *path, config_t *cfg) {
|
||||
if (!path || !cfg) return -1;
|
||||
|
||||
memset(cfg, 0, sizeof(config_t));
|
||||
copy_string("INFO", cfg->log_level, sizeof(cfg->log_level));
|
||||
copy_string("/tmp/fh_ob.sock", cfg->socket_path, sizeof(cfg->socket_path));
|
||||
copy_string("0.0.0.0", cfg->rest_host, sizeof(cfg->rest_host));
|
||||
cfg->rest_port = 8000;
|
||||
copy_string("wss://ws-api-spot.kucoin.com", cfg->ws_url, sizeof(cfg->ws_url));
|
||||
copy_string("https://api.kucoin.com/api/v1/bullet-public", cfg->token_url, sizeof(cfg->token_url));
|
||||
cfg->reconnect_base_delay = 1.0;
|
||||
cfg->reconnect_max_delay = 60.0;
|
||||
cfg->heartbeat_interval = 18.0;
|
||||
|
||||
cfg->signal_threshold_bps = 0.2;
|
||||
copy_string("USDT", cfg->hold_currencies[0], CURRENCY_NAME_LEN);
|
||||
cfg->hold_currency_count = 1;
|
||||
cfg->kcs_discount_active = false;
|
||||
copy_string("/tmp/executor.sock", cfg->executor_socket_path, sizeof(cfg->executor_socket_path));
|
||||
cfg->send_signals = false;
|
||||
cfg->cooldown_seconds = 0.0;
|
||||
cfg->stats_interval_seconds = 60.0;
|
||||
cfg->live_mode = false;
|
||||
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) {
|
||||
log_write("config: cannot open '%s'\n", path);
|
||||
return -1;
|
||||
}
|
||||
|
||||
yaml_parser_t parser;
|
||||
if (!yaml_parser_initialize(&parser)) {
|
||||
fclose(f);
|
||||
return -1;
|
||||
}
|
||||
yaml_parser_set_input_file(&parser, f);
|
||||
|
||||
parse_state_t st = {0};
|
||||
st.cfg = cfg;
|
||||
st.expect_key = true; // YAML mapping starts with a key
|
||||
|
||||
while (1) {
|
||||
yaml_event_t event;
|
||||
if (!yaml_parser_parse(&parser, &event)) break;
|
||||
|
||||
switch (event.type) {
|
||||
case YAML_STREAM_END_EVENT:
|
||||
yaml_event_delete(&event);
|
||||
goto done;
|
||||
|
||||
case YAML_SCALAR_EVENT: {
|
||||
char *s = (char *)event.data.scalar.value;
|
||||
if (st.in_capital_map) {
|
||||
if (st.expect_key) {
|
||||
strncpy(st.capital_currency, s, CURRENCY_NAME_LEN - 1);
|
||||
st.expect_key = false;
|
||||
} else {
|
||||
if (st.cfg->initial_capital_count < MAX_CAPITAL_ENTRIES) {
|
||||
strncpy(st.cfg->initial_capital[st.cfg->initial_capital_count].currency,
|
||||
st.capital_currency, CURRENCY_NAME_LEN - 1);
|
||||
st.cfg->initial_capital[st.cfg->initial_capital_count].amount = atof(s);
|
||||
st.cfg->initial_capital_count++;
|
||||
}
|
||||
st.expect_key = true;
|
||||
}
|
||||
} else if (st.expect_key) {
|
||||
strncpy(st.current_key, s, sizeof(st.current_key) - 1);
|
||||
st.expect_key = false;
|
||||
} else {
|
||||
handle_value(&st, s);
|
||||
if (!st.in_symbols && !st.in_hold && !st.in_excluded) {
|
||||
st.expect_key = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case YAML_MAPPING_START_EVENT:
|
||||
// Root mapping is depth 0; depth 1 mappings are named sections
|
||||
if (st.mapping_depth == 1 &&
|
||||
(strcmp(st.current_key, "fused_engine") == 0 ||
|
||||
strcmp(st.current_key, "executor") == 0)) {
|
||||
strncpy(st.section, st.current_key, sizeof(st.section) - 1);
|
||||
}
|
||||
// Nested mapping inside fused_engine section (depth 2)
|
||||
if (st.mapping_depth == 2 && strcmp(st.section, "fused_engine") == 0 &&
|
||||
strcmp(st.current_key, "initial_capital") == 0) {
|
||||
st.in_capital_map = true;
|
||||
st.cfg->initial_capital_count = 0;
|
||||
st.expect_key = true;
|
||||
}
|
||||
st.mapping_depth++;
|
||||
st.expect_key = true;
|
||||
break;
|
||||
|
||||
case YAML_MAPPING_END_EVENT:
|
||||
st.mapping_depth--;
|
||||
if (st.in_capital_map && st.mapping_depth <= 2) {
|
||||
st.in_capital_map = false;
|
||||
}
|
||||
if (st.mapping_depth < 2) {
|
||||
st.section[0] = '\0'; // exited a named section
|
||||
}
|
||||
st.current_key[0] = '\0';
|
||||
break;
|
||||
|
||||
case YAML_SEQUENCE_START_EVENT:
|
||||
if (strcmp(st.section, "fused_engine") == 0 && strcmp(st.current_key, "symbols") == 0) {
|
||||
st.in_symbols = true;
|
||||
} else if (strcmp(st.section, "fused_engine") == 0 && strcmp(st.current_key, "hold_currencies") == 0) {
|
||||
st.in_hold = true;
|
||||
st.cfg->hold_currency_count = 0; // override default
|
||||
} else if (strcmp(st.section, "fused_engine") == 0 && strcmp(st.current_key, "excluded_currencies") == 0) {
|
||||
st.in_excluded = true;
|
||||
st.cfg->excluded_currency_count = 0;
|
||||
}
|
||||
st.expect_key = false; // sequence items are values
|
||||
break;
|
||||
|
||||
case YAML_SEQUENCE_END_EVENT:
|
||||
st.in_symbols = false;
|
||||
st.in_hold = false;
|
||||
st.in_excluded = false;
|
||||
st.expect_key = true;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
yaml_event_delete(&event);
|
||||
}
|
||||
|
||||
done:
|
||||
yaml_parser_delete(&parser);
|
||||
fclose(f);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
#ifndef FUSED_CONFIG_H
|
||||
#define FUSED_CONFIG_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "book.h"
|
||||
|
||||
#define MAX_SYMBOLS 2048
|
||||
#define MAX_HOLD_CURRENCIES 16
|
||||
#define MAX_EXCLUDED_CURRENCIES 64
|
||||
#define MAX_CAPITAL_ENTRIES 16
|
||||
|
||||
typedef struct {
|
||||
char currency[CURRENCY_NAME_LEN]; /* currency ticker */
|
||||
double amount; /* max capital allocation */
|
||||
} capital_entry_t;
|
||||
|
||||
/* Top-level application configuration parsed from config.yaml */
|
||||
typedef struct {
|
||||
/* fh_ob section */
|
||||
char symbols[MAX_SYMBOLS][SYMBOL_NAME_LEN]; /* subscribed trading symbol names */
|
||||
uint32_t symbol_count; /* number of symbols in the list */
|
||||
char log_level[8]; /* log verbosity level string */
|
||||
char socket_path[256]; /* unix socket path for inter-process comm */
|
||||
char rest_host[64]; /* KuCoin REST API hostname */
|
||||
int rest_port; /* KuCoin REST API port */
|
||||
char ws_url[256]; /* KuCoin WebSocket base URL */
|
||||
char token_url[256]; /* KuCoin token endpoint URL */
|
||||
double reconnect_base_delay; /* initial WebSocket reconnect delay (seconds) */
|
||||
double reconnect_max_delay; /* max WebSocket reconnect delay (seconds) */
|
||||
double heartbeat_interval; /* WebSocket ping interval (seconds) */
|
||||
|
||||
/* oe_em section */
|
||||
double signal_threshold_bps; /* min predicted bps to fire a signal */
|
||||
char hold_currencies[MAX_HOLD_CURRENCIES][CURRENCY_NAME_LEN]; /* currencies to hold between legs */
|
||||
uint32_t hold_currency_count; /* number of hold currencies */
|
||||
char excluded_currencies[MAX_EXCLUDED_CURRENCIES][CURRENCY_NAME_LEN]; /* currencies to skip */
|
||||
uint32_t excluded_currency_count; /* number of excluded currencies */
|
||||
bool kcs_discount_active; /* whether KCS fee discount applies */
|
||||
char executor_socket_path[256]; /* unix socket path for signal executor */
|
||||
bool send_signals; /* whether to actually emit signals */
|
||||
double cooldown_seconds; /* min seconds between signals for same triangle */
|
||||
double stats_interval_seconds; /* period between stats log dumps */
|
||||
bool live_mode; /* live trading vs paper/simulation */
|
||||
|
||||
/* Capital allocation limits — each entry maps a currency ticker to a max
|
||||
* quote amount the fused engine may deploy for any one triangle signal. */
|
||||
capital_entry_t initial_capital[MAX_CAPITAL_ENTRIES];
|
||||
uint32_t initial_capital_count;
|
||||
|
||||
/* KuCoin API credentials (top-level keys in config.yaml) */
|
||||
char kucoin_api_key[64]; /* KuCoin API key */
|
||||
char kucoin_api_secret[128]; /* KuCoin API secret */
|
||||
char kucoin_api_passphrase[64]; /* KuCoin API passphrase */
|
||||
} config_t;
|
||||
|
||||
/* Load and parse YAML config file into config_t */
|
||||
int config_load(const char *path, config_t *cfg);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,368 @@
|
|||
/*
|
||||
* evaluate.c - Triangle arbitrage opportunity detection
|
||||
*
|
||||
* Given an updated order book, iterates all triangles that reference that symbol,
|
||||
* computes the cumulative arbitrage return (bps), determines max tradeable volume
|
||||
* constrained by liquidity, applies precision rounding per exchange increments,
|
||||
* and pushes profitable signals to the SPSC queue for the executor.
|
||||
*
|
||||
* Core computation: cumulative = product of (rate * fee_factor) for all 3 legs.
|
||||
* - Buy leg: rate = 1/ask_price (quote -> base)
|
||||
* - Sell leg: rate = bid_price (base -> quote)
|
||||
* - Fee factor = 1 - taker_fee_rate
|
||||
*/
|
||||
|
||||
#include "log.h"
|
||||
#include "evaluate.h"
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
|
||||
static inline int64_t now_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
void evaluator_init(evaluator_t *ev, const triangle_set_t *triangles,
|
||||
const order_book_t *books, const config_t *cfg,
|
||||
spsc_queue_t *queue, bool kcs_discount) {
|
||||
ev->triangles = triangles;
|
||||
ev->books = books;
|
||||
ev->cfg = cfg;
|
||||
ev->queue = queue;
|
||||
ev->fee_mult = kcs_discount ? 0.8 : 1.0;
|
||||
memset(&ev->stats, 0, sizeof(ev->stats));
|
||||
ev->stats.best_net_bps = -1e18;
|
||||
ev->stats.worst_net_bps = 1e18;
|
||||
ev->stats.best_triangle_key[0] = '\0';
|
||||
memset(ev->last_signal_ts_ms, 0, sizeof(ev->last_signal_ts_ms));
|
||||
}
|
||||
|
||||
/*
|
||||
* Evaluate all triangles involving symbol_idx after a book update.
|
||||
*
|
||||
* For each triangle:
|
||||
* 1. Fetch the 3 order books (b0, b1, b2)
|
||||
* 2. For each leg, compute rate and fee-adjusted multiplier:
|
||||
* - use_bid = 1: sell base at bid -> rate = bid[0].price
|
||||
* - use_bid = 0: buy base at ask -> rate = 1.0 / ask[0].price
|
||||
* 3. cumulative = prod(rate * fee_factor) for all 3 legs
|
||||
* 4. net_bps = (cumulative - 1) * 10000
|
||||
* 5. If net_bps > threshold, compute max_volume constrained by each leg's liquidity
|
||||
* (converted back to starting quote via inverse cumulative product)
|
||||
* 6. Apply exchange precision rounding:
|
||||
* - floor() for quantities (base size)
|
||||
* - ceil() for quote costs (must cover the order)
|
||||
* - Adjust by 1e-12 epsilon to avoid floating-point boundary errors
|
||||
* 7. Check base_min_size constraints in live mode
|
||||
* 8. Push signal to queue with full leg/order params
|
||||
*
|
||||
* Returns true if at least one signal was fired.
|
||||
*/
|
||||
bool evaluate_symbol(evaluator_t *ev, uint16_t symbol_idx, int64_t t_sock_arrive_ms, int64_t t_arrive_ms) {
|
||||
const triangle_set_t *tris = ev->triangles;
|
||||
const order_book_t *books = ev->books;
|
||||
const config_t *cfg = ev->cfg;
|
||||
bool fired_any = false;
|
||||
|
||||
uint32_t tri_count = tris->triangle_count;
|
||||
if (tri_count == 0) return false;
|
||||
|
||||
// Only evaluate triangles involving the updated symbol
|
||||
uint32_t offset = tris->tri_index[symbol_idx].offset;
|
||||
uint32_t count = tris->tri_index[symbol_idx].count;
|
||||
|
||||
static uint64_t calls_no_tri = 0;
|
||||
static uint64_t calls_with_tri = 0;
|
||||
|
||||
if (count == 0) {
|
||||
calls_no_tri++;
|
||||
return false;
|
||||
}
|
||||
calls_with_tri++;
|
||||
|
||||
uint32_t *tri_flat = tris->tri_flat;
|
||||
if (!tri_flat) return false;
|
||||
|
||||
static int64_t last_status_ms = 0;
|
||||
|
||||
for (uint32_t j = 0; j < count; j++) {
|
||||
uint32_t i = tri_flat[offset + j];
|
||||
const triangle_t *tri = &tris->triangles[i];
|
||||
|
||||
const order_book_t *b0 = &books[tri->symbol_idx[0]];
|
||||
const order_book_t *b1 = &books[tri->symbol_idx[1]];
|
||||
const order_book_t *b2 = &books[tri->symbol_idx[2]];
|
||||
|
||||
if (b0->ts_ms <= 0 || b1->ts_ms <= 0 || b2->ts_ms <= 0) {
|
||||
ev->stats.triangles_evaluated++;
|
||||
ev->stats.books_missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const order_book_t *books_arr[3] = {b0, b1, b2};
|
||||
double cumulative = 1.0;
|
||||
double max_v0_list[3] = {0, 0, 0};
|
||||
double cumulative_mult = 1.0;
|
||||
double rates[3];
|
||||
double fee_factors[3];
|
||||
bool valid = true;
|
||||
|
||||
// Use the most recent book timestamp across all 3 legs
|
||||
int64_t book_ts_ms = b0->ts_ms;
|
||||
if (b1->ts_ms > book_ts_ms) book_ts_ms = b1->ts_ms;
|
||||
if (b2->ts_ms > book_ts_ms) book_ts_ms = b2->ts_ms;
|
||||
|
||||
for (int leg = 0; leg < 3; leg++) {
|
||||
const order_book_t *bk = books_arr[leg];
|
||||
bool use_bid = tri->use_bid[leg];
|
||||
|
||||
double rate;
|
||||
double max_input;
|
||||
if (use_bid) {
|
||||
// Sell: we receive base, sell at bid -> output = bid_price
|
||||
if (bk->bid_count == 0) { valid = false; break; }
|
||||
rate = bk->bids[0][0];
|
||||
max_input = bk->bids[0][1];
|
||||
} else {
|
||||
// Buy: we input quote, buy at ask -> rate = 1/ask_price (quote-to-base conversion)
|
||||
if (bk->ask_count == 0) { valid = false; break; }
|
||||
double ask_price = bk->asks[0][0];
|
||||
if (ask_price <= 0.0) { valid = false; break; }
|
||||
rate = 1.0 / ask_price;
|
||||
max_input = bk->asks[0][1] * ask_price;
|
||||
}
|
||||
|
||||
rates[leg] = rate;
|
||||
double ff = tri->fee_factor[leg];
|
||||
fee_factors[leg] = ff;
|
||||
double leg_mult = rate * ff;
|
||||
cumulative *= leg_mult;
|
||||
|
||||
// max_v0_list[leg]: how much starting quote can pass through this leg
|
||||
// Divide by cumulative_mult (product of prior leg multipliers) to
|
||||
// convert this leg's max_input back to starting-quote-equivalent volume
|
||||
if (cumulative_mult > 0) {
|
||||
max_v0_list[leg] = max_input / cumulative_mult;
|
||||
}
|
||||
cumulative_mult *= leg_mult;
|
||||
}
|
||||
|
||||
if (!valid) {
|
||||
ev->stats.triangles_evaluated++;
|
||||
ev->stats.books_missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ev->stats.triangles_evaluated++;
|
||||
|
||||
double net_bps = (cumulative - 1.0) * 10000.0;
|
||||
|
||||
if (net_bps > ev->stats.best_net_bps) {
|
||||
ev->stats.best_net_bps = net_bps;
|
||||
snprintf(ev->stats.best_triangle_key, sizeof(ev->stats.best_triangle_key),
|
||||
"%s/%s/%s", tri->base, tri->mid, tri->quote);
|
||||
}
|
||||
if (net_bps < ev->stats.worst_net_bps) ev->stats.worst_net_bps = net_bps;
|
||||
|
||||
int64_t now = now_ms();
|
||||
if (now - last_status_ms >= 30000) {
|
||||
last_status_ms = now;
|
||||
log_write("[STATUS] evals=%lu signals=%lu "
|
||||
"best=%.2f bps (%s) | %u triangles\n",
|
||||
(unsigned long)ev->stats.triangles_evaluated,
|
||||
(unsigned long)ev->stats.signals_fired,
|
||||
ev->stats.best_net_bps, ev->stats.best_triangle_key,
|
||||
tris->triangle_count);
|
||||
}
|
||||
|
||||
if (net_bps <= cfg->signal_threshold_bps) {
|
||||
ev->stats.triangles_skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// max_volume is the bottleneck leg: the smallest starting-quote-equivalent volume.
|
||||
// Scale down by 0.5 so we never consume the entire top-of-book in one shot,
|
||||
// leaving room for the subsequent legs and avoiding excessive slippage.
|
||||
double max_volume = max_v0_list[0];
|
||||
for (int leg = 1; leg < 3; leg++) {
|
||||
if (max_v0_list[leg] < max_volume) max_volume = max_v0_list[leg];
|
||||
}
|
||||
max_volume *= 0.5;
|
||||
|
||||
// Clamp by the configured capital allocation for this triangle's base currency.
|
||||
for (uint32_t c = 0; c < cfg->initial_capital_count; c++) {
|
||||
if (strcmp(tri->base, cfg->initial_capital[c].currency) == 0) {
|
||||
double cap = cfg->initial_capital[c].amount;
|
||||
if (cap > 0 && max_volume > cap) max_volume = cap;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int64_t cooldown_ms = (int64_t)(cfg->cooldown_seconds * 1000);
|
||||
if (now - ev->last_signal_ts_ms[i] < cooldown_ms) continue;
|
||||
ev->last_signal_ts_ms[i] = now;
|
||||
|
||||
int64_t t_eval = now_ms();
|
||||
|
||||
signal_entry_t sig;
|
||||
memset(&sig, 0, sizeof(sig));
|
||||
|
||||
/*
|
||||
* Compute per-leg order parameters at max_volume scale with exchange precision.
|
||||
*
|
||||
* Precision rounding strategy:
|
||||
* - Base size: floor(base_increment) — we cannot trade a fraction of a step
|
||||
* - Quote cost: ceil(quote_increment) — must cover the full cost
|
||||
* - 1e-12 epsilon guards against floating-point truncation at increment boundaries
|
||||
* e.g. ceil(value / qi - 1e-12) ensures that 0.10000000000000001 doesn't
|
||||
* round up to 0.10000001 when qi = 0.01
|
||||
*
|
||||
* Buy leg: input = quote, output = base
|
||||
* quote_cost = ceil(leg_input / qi - eps) * qi
|
||||
* net = quote_cost * ff
|
||||
* base = floor(net / price / bi + eps) * bi
|
||||
* final_quote = ceil(base * price / qi - eps) * qi (re-check)
|
||||
*
|
||||
* Sell leg: input = base, output = quote
|
||||
* base = floor(leg_input / bi + eps) * bi
|
||||
* gross = ceil(base * price / qi - eps) * qi
|
||||
* net = gross * ff
|
||||
*/
|
||||
double leg_input = max_volume;
|
||||
double leg_quote_vol[3] = {0};
|
||||
double leg_base_size[3] = {0};
|
||||
for (int leg = 0; leg < 3; leg++) {
|
||||
const order_book_t *bk = books_arr[leg];
|
||||
double bi = tri->base_increment[leg];
|
||||
double qi = tri->quote_increment[leg];
|
||||
double ff = tri->fee_factor[leg];
|
||||
bool is_buy = !tri->use_bid[leg];
|
||||
double price = is_buy ? bk->asks[0][0] : bk->bids[0][0];
|
||||
double leg_output;
|
||||
|
||||
if (is_buy) {
|
||||
double ceiling = (qi > 0) ? ceil(leg_input / qi - 1e-12) * qi : leg_input;
|
||||
double net = ceiling * ff;
|
||||
double base = (bi > 0) ? floor(net / price / bi + 1e-12) * bi : (net / price);
|
||||
double quote_cost = (qi > 0) ? ceil(base * price / qi - 1e-12) * qi : (base * price);
|
||||
leg_quote_vol[leg] = quote_cost;
|
||||
sig.legs.legs[leg].quote_volume = quote_cost;
|
||||
leg_base_size[leg] = base;
|
||||
leg_output = base;
|
||||
} else {
|
||||
double base = (bi > 0) ? floor(leg_input / bi + 1e-12) * bi : leg_input;
|
||||
double gross = (qi > 0) ? ceil(base * price / qi - 1e-12) * qi : (base * price);
|
||||
leg_quote_vol[leg] = gross;
|
||||
sig.legs.legs[leg].quote_volume = gross;
|
||||
leg_base_size[leg] = base;
|
||||
leg_output = gross * ff;
|
||||
}
|
||||
leg_input = leg_output;
|
||||
}
|
||||
sig.starting_volume = leg_quote_vol[0];
|
||||
sig.live = cfg->live_mode;
|
||||
|
||||
if (sig.live) {
|
||||
bool below_min = false;
|
||||
for (int leg = 0; leg < 3; leg++) {
|
||||
if (leg_base_size[leg] <= 0 || leg_base_size[leg] < tri->base_min_size[leg]) {
|
||||
below_min = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (below_min) continue;
|
||||
}
|
||||
|
||||
snprintf(sig.triangle_key, sizeof(sig.triangle_key), "%s/%s/%s",
|
||||
tri->base, tri->mid, tri->quote);
|
||||
strncpy(sig.primary_quote, tri->base, CURRENCY_NAME_LEN);
|
||||
sig.predicted_bps = net_bps;
|
||||
snprintf(sig.max_volume, sizeof(sig.max_volume), "%.8g", max_volume);
|
||||
sig.ts_ms = now;
|
||||
sig.book_ts_ms = book_ts_ms;
|
||||
sig.t_sock_arrive_ms = t_sock_arrive_ms;
|
||||
sig.t_arrive_ms = t_arrive_ms;
|
||||
sig.t_eval_ms = t_eval;
|
||||
sig.book_count = 3;
|
||||
sig.legs.leg_count = 3;
|
||||
|
||||
for (int leg = 0; leg < 3; leg++) {
|
||||
const order_book_t *bk = books_arr[leg];
|
||||
signal_book_t *sb = &sig.books[leg];
|
||||
strncpy(sb->symbol, bk->symbol, SYMBOL_NAME_LEN);
|
||||
sb->ts_ms = bk->ts_ms;
|
||||
sb->bid_count = bk->bid_count;
|
||||
sb->ask_count = bk->ask_count;
|
||||
for (uint8_t l = 0; l < bk->bid_count; l++) {
|
||||
sb->bids[l].price = bk->bids[l][0];
|
||||
sb->bids[l].size = bk->bids[l][1];
|
||||
}
|
||||
for (uint8_t l = 0; l < bk->ask_count; l++) {
|
||||
sb->asks[l].price = bk->asks[l][0];
|
||||
sb->asks[l].size = bk->asks[l][1];
|
||||
}
|
||||
|
||||
signal_leg_t *sl = &sig.legs.legs[leg];
|
||||
strncpy(sl->symbol, tri->symbol_names[leg], SYMBOL_NAME_LEN);
|
||||
char base_cur[CURRENCY_NAME_LEN], quote_cur[CURRENCY_NAME_LEN];
|
||||
const char *dash = strchr(tri->symbol_names[leg], '-');
|
||||
if (dash) {
|
||||
size_t blen = dash - tri->symbol_names[leg];
|
||||
if (blen >= CURRENCY_NAME_LEN) blen = CURRENCY_NAME_LEN - 1;
|
||||
strncpy(base_cur, tri->symbol_names[leg], blen);
|
||||
base_cur[blen] = '\0';
|
||||
strncpy(quote_cur, dash + 1, CURRENCY_NAME_LEN - 1);
|
||||
quote_cur[CURRENCY_NAME_LEN - 1] = '\0';
|
||||
} else {
|
||||
base_cur[0] = quote_cur[0] = '\0';
|
||||
}
|
||||
|
||||
bool use_bid = tri->use_bid[leg];
|
||||
bool is_buy = !use_bid;
|
||||
// order_param: for buys the param is quote volume, for sells it's base size
|
||||
if (is_buy) {
|
||||
snprintf(sl->order_param, sizeof(sl->order_param), "%.8g",
|
||||
sig.legs.legs[leg].quote_volume);
|
||||
} else {
|
||||
snprintf(sl->order_param, sizeof(sl->order_param), "%.8g", leg_base_size[leg]);
|
||||
}
|
||||
sl->base_increment = tri->base_increment[leg];
|
||||
sl->quote_increment = tri->quote_increment[leg];
|
||||
sl->base_min_size = tri->base_min_size[leg];
|
||||
|
||||
if (use_bid) {
|
||||
// Hit the bid: we sell base, receive quote
|
||||
strncpy(sl->input_currency, base_cur, CURRENCY_NAME_LEN);
|
||||
strncpy(sl->output_currency, quote_cur, CURRENCY_NAME_LEN);
|
||||
strncpy(sl->side, "sell", 5);
|
||||
} else {
|
||||
// Hit the ask: we buy base, pay quote
|
||||
strncpy(sl->input_currency, quote_cur, CURRENCY_NAME_LEN);
|
||||
strncpy(sl->output_currency, base_cur, CURRENCY_NAME_LEN);
|
||||
strncpy(sl->side, "buy", 5);
|
||||
}
|
||||
strncpy(sl->fee_currency, tri->fee_currency[leg], CURRENCY_NAME_LEN);
|
||||
sl->fee_rate = 1.0 - fee_factors[leg];
|
||||
sl->exchange_rate = rates[leg];
|
||||
}
|
||||
|
||||
if (spsc_push(ev->queue, &sig)) {
|
||||
ev->stats.signals_fired++;
|
||||
ev->stats.last_eval_ts_ms = now;
|
||||
log_write("[SIGNAL] %.4f bps vol=%s | %s (%s, %s, %s)\n",
|
||||
net_bps, sig.max_volume, sig.triangle_key,
|
||||
sig.legs.legs[0].symbol, sig.legs.legs[1].symbol, sig.legs.legs[2].symbol);
|
||||
fired_any = true;
|
||||
} else {
|
||||
static int drop_count = 0;
|
||||
if (++drop_count <= 3) log_write("[SIGNAL] DROPPED (queue full) %.4f bps vol=%s | %s (%s, %s, %s)\n",
|
||||
net_bps, sig.max_volume, sig.triangle_key,
|
||||
sig.legs.legs[0].symbol, sig.legs.legs[1].symbol, sig.legs.legs[2].symbol);
|
||||
}
|
||||
}
|
||||
|
||||
return fired_any;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
#ifndef FUSED_EVALUATE_H
|
||||
#define FUSED_EVALUATE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "book.h"
|
||||
#include "triangle.h"
|
||||
#include "config.h"
|
||||
#include "queue.h"
|
||||
|
||||
/* Aggregated evaluation statistics for monitoring */
|
||||
typedef struct {
|
||||
uint64_t triangles_evaluated; /* total triangles evaluated since start */
|
||||
uint64_t signals_fired; /* total signals generated since start */
|
||||
uint64_t books_missing; /* count of evaluations skipped due to missing books */
|
||||
uint64_t triangles_skipped; /* count of triangles skipped for other reasons */
|
||||
double best_net_bps; /* best net profit seen (basis points) */
|
||||
double worst_net_bps; /* worst net profit seen (basis points) */
|
||||
int64_t last_eval_ts_ms; /* timestamp of last evaluation (milliseconds) */
|
||||
char best_triangle_key[48]; /* triangle key that produced best_net_bps */
|
||||
} eval_stats_t;
|
||||
|
||||
/* Runtime state for the triangular arbitrage evaluator */
|
||||
typedef struct {
|
||||
const triangle_set_t *triangles; /* pre-enumerated triangle set (read-only) */
|
||||
const order_book_t *books; /* live order books array (read-only) */
|
||||
const config_t *cfg; /* application configuration (read-only) */
|
||||
spsc_queue_t *queue; /* signal queue for firing opportunities */
|
||||
eval_stats_t stats; /* cumulative evaluation statistics */
|
||||
double fee_mult; /* combined fee multiplier (includes KCS discount) */
|
||||
int64_t last_signal_ts_ms[MAX_TRIANGLES]; /* per-triangle cooldown timestamps */
|
||||
} evaluator_t;
|
||||
|
||||
/* Initialise evaluator with triangle set, books, config, and signal queue */
|
||||
void evaluator_init(evaluator_t *ev, const triangle_set_t *triangles,
|
||||
const order_book_t *books, const config_t *cfg,
|
||||
spsc_queue_t *queue, bool kcs_discount);
|
||||
|
||||
/* Evaluate all triangles involving the given symbol; returns true if a signal was fired */
|
||||
bool evaluate_symbol(evaluator_t *ev, uint16_t symbol_idx, int64_t t_sock_arrive_ms, int64_t t_arrive_ms);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
/*
|
||||
* events.c - Epoll-based event loops for WebSocket I/O and signal dispatch
|
||||
*
|
||||
* Two-thread architecture:
|
||||
* HOT thread: epoll_wait on WebSocket fds + timer fd for keep-alive pings
|
||||
* COLD thread: polls SPSC signal queue + Unix domain socket to executor
|
||||
*
|
||||
* Signals flow: evaluate.c -> SPSC queue -> COLD thread -> executor via UDS
|
||||
*/
|
||||
|
||||
#include "log.h"
|
||||
#include "events.h"
|
||||
#include "evaluate.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <time.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/un.h>
|
||||
#include <sys/timerfd.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <sys/socket.h>
|
||||
#include <arpa/inet.h>
|
||||
|
||||
static int set_nonblocking(int fd) {
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
if (flags < 0) return -1;
|
||||
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||
}
|
||||
|
||||
int event_loops_add_fd(epoll_set_t *set, int fd, fd_type_t type,
|
||||
uint32_t ws_idx, void *user_data, uint32_t events) {
|
||||
if (set->fd_count >= MAX_EPOLL_FDS) {
|
||||
log_write("[EVENTS] epoll set full\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// If fd already tracked, modify instead of re-adding
|
||||
for (uint32_t i = 0; i < set->fd_count; i++) {
|
||||
if (set->fds[i].fd == fd) {
|
||||
struct epoll_event ev = {
|
||||
.events = events,
|
||||
.data.ptr = &set->fds[i]
|
||||
};
|
||||
return epoll_ctl(set->epoll_fd, EPOLL_CTL_MOD, fd, &ev);
|
||||
}
|
||||
}
|
||||
|
||||
tracked_fd_t *tf = &set->fds[set->fd_count++];
|
||||
tf->fd = fd;
|
||||
tf->type = type;
|
||||
tf->ws_conn_idx = ws_idx;
|
||||
tf->user_data = user_data;
|
||||
|
||||
struct epoll_event ev = {
|
||||
.events = events,
|
||||
.data.ptr = tf
|
||||
};
|
||||
return epoll_ctl(set->epoll_fd, EPOLL_CTL_ADD, fd, &ev);
|
||||
}
|
||||
|
||||
void event_loops_remove_fd(epoll_set_t *set, int fd) {
|
||||
epoll_ctl(set->epoll_fd, EPOLL_CTL_DEL, fd, NULL);
|
||||
for (uint32_t i = 0; i < set->fd_count; i++) {
|
||||
if (set->fds[i].fd == fd) {
|
||||
set->fds[i].fd = -1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void epoll_set_init(epoll_set_t *set) {
|
||||
memset(set, 0, sizeof(*set));
|
||||
set->epoll_fd = epoll_create1(EPOLL_CLOEXEC);
|
||||
if (set->epoll_fd < 0) {
|
||||
perror("epoll_create1");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
int event_loops_init(event_loops_t *loops, ws_client_t *ws_client,
|
||||
spsc_queue_t *signal_queue, const config_t *cfg, int wakeup_fd) {
|
||||
memset(loops, 0, sizeof(*loops));
|
||||
loops->ws_client = ws_client;
|
||||
loops->signal_queue = signal_queue;
|
||||
loops->running = true;
|
||||
loops->unix_client_fd = -1;
|
||||
loops->wakeup_fd = wakeup_fd;
|
||||
|
||||
epoll_set_init(&loops->hot_epoll);
|
||||
epoll_set_init(&loops->cold_epoll);
|
||||
|
||||
loops->timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
|
||||
if (loops->timer_fd < 0) {
|
||||
perror("timerfd_create");
|
||||
return -1;
|
||||
}
|
||||
|
||||
event_loops_add_fd(&loops->cold_epoll, loops->wakeup_fd, FD_TYPE_EVENT,
|
||||
0, NULL, EPOLLIN);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void event_loops_destroy(event_loops_t *loops) {
|
||||
loops->running = false;
|
||||
if (loops->timer_fd >= 0) close(loops->timer_fd);
|
||||
if (loops->wakeup_fd >= 0) close(loops->wakeup_fd);
|
||||
if (loops->unix_client_fd >= 0) close(loops->unix_client_fd);
|
||||
if (loops->http_server_fd >= 0) close(loops->http_server_fd);
|
||||
if (loops->hot_epoll.epoll_fd >= 0) close(loops->hot_epoll.epoll_fd);
|
||||
if (loops->cold_epoll.epoll_fd >= 0) close(loops->cold_epoll.epoll_fd);
|
||||
}
|
||||
|
||||
int unix_client_connect(const char *socket_path) {
|
||||
int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK, 0);
|
||||
if (fd < 0) return -1;
|
||||
|
||||
struct sockaddr_un addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sun_family = AF_UNIX;
|
||||
strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1);
|
||||
|
||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
if (errno != EINPROGRESS) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
struct pollfd pfd = { .fd = fd, .events = POLLOUT };
|
||||
if (poll(&pfd, 1, 100) <= 0) { // 100 ms timeout
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
int unix_server_create(const char *socket_path) {
|
||||
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (fd < 0) return -1;
|
||||
|
||||
struct sockaddr_un addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sun_family = AF_UNIX;
|
||||
strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1);
|
||||
|
||||
unlink(socket_path);
|
||||
|
||||
if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (listen(fd, 5) < 0) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
set_nonblocking(fd);
|
||||
return fd;
|
||||
}
|
||||
|
||||
/*
|
||||
* Build a JSON signal message and send it to the external executor over a Unix socket.
|
||||
*
|
||||
* JSON structure:
|
||||
* {
|
||||
* "type": "signal",
|
||||
* "correlation_id": "<hex>",
|
||||
* "triangle_key": ["base","mid","quote"],
|
||||
* "primary_quote": "<currency>",
|
||||
* "live": true/false,
|
||||
* "starting_volume": "<volume>",
|
||||
* "legs": [{...}, {...}, {...}],
|
||||
* "predicted_bps": <float>,
|
||||
* "ts_ms", "book_ts_ms", "t_sock_arrive_ms", "t_arrive_ms", "t_eval_ms": <timestamp>,
|
||||
* "books": [...] (snapshot, only when !live)
|
||||
* }
|
||||
*
|
||||
* correlation_id is a mix of address/ts/bps values for best-effort uniqueness.
|
||||
* Connects lazily on first signal; reconnects on write failure.
|
||||
*/
|
||||
static void send_signal_to_executor(event_loops_t *loops, signal_entry_t *sig) {
|
||||
if (loops->unix_client_fd < 0) {
|
||||
loops->unix_client_fd = unix_client_connect(loops->ws_client->cfg->executor_socket_path);
|
||||
if (loops->unix_client_fd < 0) {
|
||||
log_write("[EVENTS] Cannot connect to executor at %s\n",
|
||||
loops->ws_client->cfg->executor_socket_path);
|
||||
return;
|
||||
}
|
||||
event_loops_add_fd(&loops->cold_epoll, loops->unix_client_fd,
|
||||
FD_TYPE_UNIX_CLIENT, 0, NULL, EPOLLIN);
|
||||
}
|
||||
|
||||
char json_buf[4096];
|
||||
char corr_id[37];
|
||||
snprintf(corr_id, sizeof(corr_id),
|
||||
"%08x%08x%08x%08x",
|
||||
(unsigned)(uintptr_t)&sig->legs.legs[0] ^ (unsigned)sig->ts_ms,
|
||||
(unsigned)sig->ts_ms ^ (unsigned)sig->book_ts_ms,
|
||||
(unsigned)sig->predicted_bps,
|
||||
(unsigned)sig->t_arrive_ms);
|
||||
|
||||
char legs_json[1024];
|
||||
legs_json[0] = '\0';
|
||||
for (uint8_t l = 0; l < 3; l++) {
|
||||
const signal_leg_t *sl = &sig->legs.legs[l];
|
||||
char tmp[384];
|
||||
snprintf(tmp, sizeof(tmp),
|
||||
"%s{\"pair\":\"%s\",\"side\":\"%s\","
|
||||
"\"order_param\":\"%s\","
|
||||
"\"fee_rate\":%.6f,\"fee_currency\":\"%s\","
|
||||
"\"base_increment\":\"%.10g\",\"quote_increment\":\"%.10g\",\"base_min_size\":\"%.10g\"}",
|
||||
l ? "," : "", sl->symbol, sl->side,
|
||||
sl->order_param,
|
||||
sl->fee_rate, sl->fee_currency,
|
||||
sl->base_increment, sl->quote_increment, sl->base_min_size);
|
||||
strncat(legs_json, tmp, sizeof(legs_json) - 1);
|
||||
}
|
||||
|
||||
// triangle_key as JSON array ["base","mid","quote"]
|
||||
char triangle_key_json[96];
|
||||
{
|
||||
char parts[3][16] = {{0}};
|
||||
const char *tk = sig->triangle_key;
|
||||
const char *s1 = strchr(tk, '/');
|
||||
const char *s2 = s1 ? strchr(s1 + 1, '/') : NULL;
|
||||
if (s1 && s2) {
|
||||
uint32_t l1 = s1 - tk;
|
||||
if (l1 > 15) l1 = 15;
|
||||
memcpy(parts[0], tk, l1);
|
||||
uint32_t l2 = s2 - s1 - 1;
|
||||
if (l2 > 15) l2 = 15;
|
||||
memcpy(parts[1], s1 + 1, l2);
|
||||
strncpy(parts[2], s2 + 1, 15);
|
||||
snprintf(triangle_key_json, sizeof(triangle_key_json),
|
||||
"[\"%s\",\"%s\",\"%s\"]", parts[0], parts[1], parts[2]);
|
||||
} else {
|
||||
snprintf(triangle_key_json, sizeof(triangle_key_json), "[\"%s\"]", tk);
|
||||
}
|
||||
}
|
||||
|
||||
// Full book snapshot included when !live (paper trading mode)
|
||||
char books_json_str[2048] = "";
|
||||
if (!sig->live && sig->book_count > 0) {
|
||||
char *bp = books_json_str;
|
||||
size_t rem = sizeof(books_json_str);
|
||||
for (uint8_t b = 0; b < sig->book_count; b++) {
|
||||
const signal_book_t *sb = &sig->books[b];
|
||||
char bid_arr[256] = {0}, ask_arr[256] = {0};
|
||||
for (uint8_t lev = 0; lev < sb->bid_count; lev++) {
|
||||
char tmp[64];
|
||||
snprintf(tmp, sizeof(tmp), "%s{\"price\":\"%.6g\",\"size\":\"%.8g\"}",
|
||||
lev ? "," : "", sb->bids[lev].price, sb->bids[lev].size);
|
||||
strncat(bid_arr, tmp, sizeof(bid_arr) - 1);
|
||||
}
|
||||
for (uint8_t lev = 0; lev < sb->ask_count; lev++) {
|
||||
char tmp[64];
|
||||
snprintf(tmp, sizeof(tmp), "%s{\"price\":\"%.6g\",\"size\":\"%.8g\"}",
|
||||
lev ? "," : "", sb->asks[lev].price, sb->asks[lev].size);
|
||||
strncat(ask_arr, tmp, sizeof(ask_arr) - 1);
|
||||
}
|
||||
int n = snprintf(bp, rem,
|
||||
"%s{\"symbol\":\"%s\",\"bids\":[%s],\"asks\":[%s],\"ts_ms\":%lld}",
|
||||
b ? "," : "", sb->symbol, bid_arr, ask_arr, (long long)sb->ts_ms);
|
||||
if (n > 0 && (size_t)n < rem) { bp += n; rem -= (size_t)n; }
|
||||
}
|
||||
}
|
||||
|
||||
snprintf(json_buf, sizeof(json_buf),
|
||||
"{\"type\":\"signal\",\"correlation_id\":\"%s\","
|
||||
"\"triangle_key\":%s,\"primary_quote\":\"%s\","
|
||||
"\"live\":%s,\"starting_volume\":\"%.8g\","
|
||||
"\"legs\":[%s],\"predicted_bps\":%.4f,"
|
||||
"\"ts_ms\":%lld,\"book_ts_ms\":%lld,\"t_sock_arrive_ms\":%lld,\"t_arrive_ms\":%lld,\"t_eval_ms\":%lld"
|
||||
"%s%s%s"
|
||||
"}\n",
|
||||
corr_id, triangle_key_json, sig->primary_quote,
|
||||
sig->live ? "true" : "false", sig->starting_volume,
|
||||
legs_json, sig->predicted_bps,
|
||||
(long long)sig->ts_ms, (long long)sig->book_ts_ms,
|
||||
(long long)sig->t_sock_arrive_ms,
|
||||
(long long)sig->t_arrive_ms, (long long)sig->t_eval_ms,
|
||||
(sig->live || sig->book_count == 0) ? "" : ",\"books\":[",
|
||||
books_json_str[0] ? books_json_str : "",
|
||||
(sig->live || sig->book_count == 0) ? "" : "]");
|
||||
|
||||
size_t to_send = strlen(json_buf);
|
||||
size_t sent = 0;
|
||||
while (sent < to_send) {
|
||||
int r = (int)write(loops->unix_client_fd, json_buf + sent, to_send - sent);
|
||||
if (r > 0) {
|
||||
sent += (size_t)r;
|
||||
continue;
|
||||
}
|
||||
if (r == 0 || (errno != EAGAIN && errno != EWOULDBLOCK)) {
|
||||
log_write("[EVENTS] Write to executor failed, reconnecting\n");
|
||||
int old_fd = loops->unix_client_fd;
|
||||
loops->unix_client_fd = -1;
|
||||
close(old_fd);
|
||||
event_loops_remove_fd(&loops->cold_epoll, old_fd);
|
||||
break;
|
||||
}
|
||||
/* EAGAIN: executor buffer full, drop this signal and move on */
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void arm_ping_timer(event_loops_t *loops, uint64_t interval_ms) {
|
||||
if (interval_ms == 0) return;
|
||||
struct itimerspec its = {0};
|
||||
its.it_value.tv_sec = interval_ms / 1000;
|
||||
its.it_value.tv_nsec = (interval_ms % 1000) * 1000000;
|
||||
timerfd_settime(loops->timer_fd, 0, &its, NULL);
|
||||
}
|
||||
|
||||
/*
|
||||
* HOT thread: epoll-driven WebSocket I/O.
|
||||
* Monitors WS connection fds for incoming data and ping timer for keep-alive.
|
||||
* Sends ping frames to all connected WS connections on timer expiry.
|
||||
*/
|
||||
void *event_hot_thread(void *arg) {
|
||||
event_loops_t *loops = (event_loops_t *)arg;
|
||||
ws_client_t *ws = loops->ws_client;
|
||||
|
||||
log_write("[HOT] Thread started\n");
|
||||
|
||||
for (uint32_t i = 0; i < ws->connection_count; i++) {
|
||||
ws_connection_t *conn = &ws->connections[i];
|
||||
if (conn->fd >= 0) {
|
||||
set_nonblocking(conn->fd);
|
||||
event_loops_add_fd(&loops->hot_epoll, conn->fd, FD_TYPE_WS, i, NULL, EPOLLIN);
|
||||
}
|
||||
}
|
||||
|
||||
if (ws->connections[0].ping_interval_ms > 0) {
|
||||
event_loops_add_fd(&loops->hot_epoll, loops->timer_fd, FD_TYPE_TIMER,
|
||||
0, NULL, EPOLLIN);
|
||||
arm_ping_timer(loops, ws->connections[0].ping_interval_ms);
|
||||
}
|
||||
|
||||
while (loops->running) {
|
||||
int nfds = epoll_wait(loops->hot_epoll.epoll_fd,
|
||||
loops->hot_epoll.events, MAX_EPOLL_FDS, 100);
|
||||
if (nfds < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
perror("epoll_wait hot");
|
||||
break;
|
||||
}
|
||||
|
||||
for (int i = 0; i < nfds; i++) {
|
||||
tracked_fd_t *tf = (tracked_fd_t *)loops->hot_epoll.events[i].data.ptr;
|
||||
if (!tf || tf->fd < 0) continue;
|
||||
|
||||
if (tf->type == FD_TYPE_WS) {
|
||||
ws_client_read(ws, tf->ws_conn_idx);
|
||||
} else if (tf->type == FD_TYPE_TIMER) {
|
||||
uint64_t expirations = 0;
|
||||
read(loops->timer_fd, &expirations, sizeof(expirations));
|
||||
|
||||
for (uint32_t c = 0; c < ws->connection_count; c++) {
|
||||
ws_connection_t *conn = &ws->connections[c];
|
||||
if (conn->state == WS_STATE_CONNECTED) {
|
||||
ws_client_send_ping(conn);
|
||||
}
|
||||
}
|
||||
|
||||
if (ws->connections[0].ping_interval_ms > 0) {
|
||||
arm_ping_timer(loops, ws->connections[0].ping_interval_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log_write("[HOT] Thread exited\n");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/*
|
||||
* COLD thread: drain SPSC signal queue and forward to executor.
|
||||
* Uses epoll_wait on the Unix client fd to detect disconnection.
|
||||
* Priority: drains queue before and after epoll to minimize latency.
|
||||
*/
|
||||
void *event_cold_thread(void *arg) {
|
||||
event_loops_t *loops = (event_loops_t *)arg;
|
||||
|
||||
log_write("[COLD] Thread started\n");
|
||||
|
||||
while (loops->running) {
|
||||
while (!spsc_empty(loops->signal_queue)) {
|
||||
signal_entry_t sig;
|
||||
if (spsc_pop(loops->signal_queue, &sig)) {
|
||||
send_signal_to_executor(loops, &sig);
|
||||
}
|
||||
}
|
||||
|
||||
int nfds = epoll_wait(loops->cold_epoll.epoll_fd,
|
||||
loops->cold_epoll.events, MAX_EPOLL_FDS, 200);
|
||||
if (nfds < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
perror("epoll_wait cold");
|
||||
break;
|
||||
}
|
||||
|
||||
for (int i = 0; i < nfds; i++) {
|
||||
tracked_fd_t *tf = (tracked_fd_t *)loops->cold_epoll.events[i].data.ptr;
|
||||
if (!tf || tf->fd < 0) continue;
|
||||
|
||||
uint32_t ev = loops->cold_epoll.events[i].events;
|
||||
|
||||
if (tf->type == FD_TYPE_EVENT) {
|
||||
uint64_t val = 0;
|
||||
read(loops->wakeup_fd, &val, sizeof(val));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tf->type == FD_TYPE_UNIX_CLIENT) {
|
||||
if (ev & (EPOLLERR | EPOLLHUP)) {
|
||||
log_write("[COLD] Executor disconnected\n");
|
||||
close(loops->unix_client_fd);
|
||||
loops->unix_client_fd = -1;
|
||||
event_loops_remove_fd(&loops->cold_epoll, tf->fd);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain again after epoll to catch any signals queued during processing
|
||||
while (!spsc_empty(loops->signal_queue)) {
|
||||
signal_entry_t sig;
|
||||
if (spsc_pop(loops->signal_queue, &sig)) {
|
||||
send_signal_to_executor(loops, &sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log_write("[COLD] Thread exited\n");
|
||||
return NULL;
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
#ifndef FUSED_EVENTS_H
|
||||
#define FUSED_EVENTS_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <sys/epoll.h>
|
||||
#include "ws_client.h"
|
||||
#include "queue.h"
|
||||
|
||||
#define MAX_EPOLL_FDS 64
|
||||
|
||||
/* Identifies the type of a tracked file descriptor */
|
||||
typedef enum {
|
||||
FD_TYPE_WS, /* WebSocket connection */
|
||||
FD_TYPE_TIMER, /* timerfd */
|
||||
FD_TYPE_EVENT, /* eventfd for queue wakeup */
|
||||
FD_TYPE_UNIX_SERVER, /* unix domain socket server */
|
||||
FD_TYPE_HTTP_SERVER, /* HTTP server socket */
|
||||
FD_TYPE_UNIX_CLIENT /* unix domain socket client */
|
||||
} fd_type_t;
|
||||
|
||||
/* A file descriptor tracked by the epoll event loop */
|
||||
typedef struct {
|
||||
int fd; /* the file descriptor */
|
||||
fd_type_t type; /* type identifier for dispatch */
|
||||
uint32_t ws_conn_idx; /* WebSocket connection index (if type is FD_TYPE_WS) */
|
||||
void *user_data; /* optional user data pointer */
|
||||
} tracked_fd_t;
|
||||
|
||||
/* Epoll-based event set for a group of file descriptors */
|
||||
typedef struct {
|
||||
int epoll_fd; /* epoll instance fd */
|
||||
struct epoll_event events[MAX_EPOLL_FDS]; /* re-usable event array */
|
||||
tracked_fd_t fds[MAX_EPOLL_FDS]; /* tracked fd descriptors */
|
||||
uint32_t fd_count; /* number of tracked fds */
|
||||
} epoll_set_t;
|
||||
|
||||
/* Top-level event loop state, split into hot (ws) and cold (timer/http) paths */
|
||||
typedef struct {
|
||||
epoll_set_t hot_epoll; /* hot epoll set for latency-sensitive ws events */
|
||||
epoll_set_t cold_epoll; /* cold epoll set for timer/http events */
|
||||
ws_client_t *ws_client; /* WebSocket client instance */
|
||||
spsc_queue_t *signal_queue; /* signal queue for emitting opportunities */
|
||||
int unix_server_fd; /* unix domain server socket */
|
||||
int unix_client_fd; /* unix domain client socket */
|
||||
int http_server_fd; /* HTTP server socket */
|
||||
int timer_fd; /* timerfd for periodic tasks */
|
||||
int wakeup_fd; /* eventfd for waking the cold loop */
|
||||
uint64_t next_ping_ms; /* next scheduled WebSocket ping timestamp */
|
||||
bool running; /* false signals event loops to exit */
|
||||
} event_loops_t;
|
||||
|
||||
/* Initialise both epoll sets, create sockets, and start event loops */
|
||||
int event_loops_init(event_loops_t *loops, ws_client_t *ws_client,
|
||||
spsc_queue_t *signal_queue, const config_t *cfg, int wakeup_fd);
|
||||
/* Tear down event loops, close all sockets */
|
||||
void event_loops_destroy(event_loops_t *loops);
|
||||
/* Register a file descriptor with an epoll set */
|
||||
int event_loops_add_fd(epoll_set_t *set, int fd, fd_type_t type,
|
||||
uint32_t ws_idx, void *user_data, uint32_t events);
|
||||
/* Remove a file descriptor from an epoll set */
|
||||
void event_loops_remove_fd(epoll_set_t *set, int fd);
|
||||
/* Hot event loop thread: handles WebSocket I/O */
|
||||
void *event_hot_thread(void *arg);
|
||||
/* Cold event loop thread: handles timers, HTTP, and signal dispatch */
|
||||
void *event_cold_thread(void *arg);
|
||||
/* Connect to a unix domain socket (non-blocking) */
|
||||
int unix_client_connect(const char *socket_path);
|
||||
/* Create and listen on a unix domain socket */
|
||||
int unix_server_create(const char *socket_path);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* hash.c - FNV-1a hash function and symbol table (sorted array with bsearch)
|
||||
*
|
||||
* The symbol table maps KuCoin trading pair names (e.g. "BTC-USDT") to
|
||||
* dense 16-bit indices. Entries are sorted alphabetically for O(log n)
|
||||
* lookup via bsearch(3). Used by ws_client to resolve symbol names in
|
||||
* book update messages.
|
||||
*/
|
||||
|
||||
#include "hash.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static const uint32_t FNV_OFFSET = 2166136261u;
|
||||
static const uint32_t FNV_PRIME = 16777619u;
|
||||
|
||||
uint32_t fnv1a_hash(const char *str, uint32_t len) {
|
||||
uint32_t hash = FNV_OFFSET;
|
||||
for (uint32_t i = 0; i < len; i++) {
|
||||
hash ^= (uint8_t)str[i];
|
||||
hash *= FNV_PRIME;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
void symbol_table_init(symbol_table_t *table) {
|
||||
table->capacity = SYMBOL_TABLE_INITIAL;
|
||||
table->count = 0;
|
||||
table->entries = calloc(table->capacity, sizeof(symbol_entry_t));
|
||||
}
|
||||
|
||||
static int entry_cmp(const void *a, const void *b) {
|
||||
const symbol_entry_t *ea = (const symbol_entry_t *)a;
|
||||
const symbol_entry_t *eb = (const symbol_entry_t *)b;
|
||||
return strcmp(ea->name, eb->name);
|
||||
}
|
||||
|
||||
static int entry_cmp_qsort(const void *a, const void *b) {
|
||||
return entry_cmp(a, b);
|
||||
}
|
||||
|
||||
void symbol_table_sort(symbol_table_t *table) {
|
||||
qsort(table->entries, table->count, sizeof(symbol_entry_t), entry_cmp_qsort);
|
||||
for (uint32_t i = 0; i < table->count; i++) {
|
||||
table->entries[i].index = (uint16_t)i;
|
||||
}
|
||||
}
|
||||
|
||||
int symbol_table_add(symbol_table_t *table, const char *name) {
|
||||
if (table->count >= table->capacity) {
|
||||
uint32_t new_cap = table->capacity * 2;
|
||||
symbol_entry_t *new_entries = realloc(table->entries,
|
||||
new_cap * sizeof(symbol_entry_t));
|
||||
if (!new_entries) return -1;
|
||||
table->entries = new_entries;
|
||||
table->capacity = new_cap;
|
||||
}
|
||||
|
||||
symbol_entry_t *entry = &table->entries[table->count];
|
||||
strncpy(entry->name, name, SYMBOL_NAME_LEN - 1);
|
||||
entry->name[SYMBOL_NAME_LEN - 1] = '\0';
|
||||
entry->index = (uint16_t)table->count;
|
||||
table->count++;
|
||||
|
||||
return (int)(table->count - 1);
|
||||
}
|
||||
|
||||
int16_t symbol_table_lookup(const symbol_table_t *table, const char *name) {
|
||||
symbol_entry_t key;
|
||||
strncpy(key.name, name, SYMBOL_NAME_LEN - 1);
|
||||
key.name[SYMBOL_NAME_LEN - 1] = '\0';
|
||||
|
||||
symbol_entry_t *found = bsearch(&key, table->entries, table->count,
|
||||
sizeof(symbol_entry_t), entry_cmp);
|
||||
if (!found) return -1;
|
||||
return (int16_t)found->index;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
#ifndef FUSED_HASH_H
|
||||
#define FUSED_HASH_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include "book.h"
|
||||
|
||||
#define SYMBOL_TABLE_INITIAL 1024
|
||||
|
||||
/* One entry in the symbol table mapping name -> numeric index */
|
||||
typedef struct {
|
||||
char name[SYMBOL_NAME_LEN]; /* symbol name e.g. "BTC-USDT" */
|
||||
uint16_t index; /* assigned numeric index */
|
||||
} symbol_entry_t;
|
||||
|
||||
/* Growable symbol table for mapping symbol names to dense indices */
|
||||
typedef struct {
|
||||
symbol_entry_t *entries; /* dynamic array of entries */
|
||||
uint32_t count; /* number of entries currently stored */
|
||||
uint32_t capacity; /* allocated capacity of the array */
|
||||
} symbol_table_t;
|
||||
|
||||
/* Initialise an empty symbol table */
|
||||
void symbol_table_init(symbol_table_t *table);
|
||||
/* Add a symbol to the table; returns its index, or -1 on failure */
|
||||
int symbol_table_add(symbol_table_t *table, const char *name);
|
||||
/* Look up a symbol by name; returns its index or -1 if not found */
|
||||
int16_t symbol_table_lookup(const symbol_table_t *table, const char *name);
|
||||
/* Sort symbol table entries alphabetically by name */
|
||||
void symbol_table_sort(symbol_table_t *table);
|
||||
|
||||
/* FNV-1a non-cryptographic hash for a byte string */
|
||||
uint32_t fnv1a_hash(const char *str, uint32_t len);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
/*
|
||||
* http_client.c - Synchronous HTTP/HTTPS client with KuCoin API auth support
|
||||
*
|
||||
* Provides: TCP socket connection (IPv4), TLS via OpenSSL, HTTP request building,
|
||||
* chunked transfer-encoding dechunking, and KuCoin HMAC-SHA256 signing.
|
||||
*
|
||||
* All calls are blocking with 10s socket timeouts.
|
||||
* Response buffer is 1MB (KuCoin /api/v2/symbols response ~700KB).
|
||||
*/
|
||||
|
||||
#include "log.h"
|
||||
#include "http_client.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <time.h>
|
||||
#include <stdbool.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/types.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#include <openssl/ssl.h>
|
||||
#include <openssl/err.h>
|
||||
#include <openssl/hmac.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/bio.h>
|
||||
|
||||
#define HTTP_BUFFER_SIZE 1048576 // 1MB for large responses (KuCoin symbols ~700KB)
|
||||
|
||||
static int resolve_host(const char *host, struct sockaddr_in *addr) {
|
||||
struct addrinfo hints = {0}, *res = NULL;
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
|
||||
int ret = getaddrinfo(host, "443", &hints, &res);
|
||||
if (ret != 0 || !res) return -1;
|
||||
|
||||
memcpy(addr, res->ai_addr, sizeof(struct sockaddr_in));
|
||||
freeaddrinfo(res);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int create_tcp_socket(const char *host, int port) {
|
||||
struct sockaddr_in addr = {0};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
|
||||
if (inet_pton(AF_INET, host, &addr.sin_addr) == 1) {
|
||||
// IP address
|
||||
} else {
|
||||
if (resolve_host(host, &addr) != 0) return -1;
|
||||
}
|
||||
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) return -1;
|
||||
|
||||
// Set connect/read/write timeouts to 10s
|
||||
struct timeval tv = { .tv_sec = 10, .tv_usec = 0 };
|
||||
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
|
||||
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
static void build_request(char *buf, size_t buf_len, const char *method,
|
||||
const char *host, const char *path,
|
||||
const char *body, int body_len) {
|
||||
snprintf(buf, buf_len,
|
||||
"%s %s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
"Accept: */*\r\n"
|
||||
"User-Agent: fused-engine/1.0\r\n"
|
||||
"Connection: close\r\n",
|
||||
method, path, host);
|
||||
|
||||
if (body && body_len > 0) {
|
||||
snprintf(buf + strlen(buf), buf_len - strlen(buf),
|
||||
"Content-Type: application/json\r\n"
|
||||
"Content-Length: %d\r\n"
|
||||
"\r\n%s",
|
||||
body_len, body);
|
||||
} else {
|
||||
strcat(buf, "\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
char *http_get(const char *host, int port, const char *path, int *out_len) {
|
||||
return http_post(host, port, path, NULL, 0, out_len);
|
||||
}
|
||||
|
||||
char *http_post(const char *host, int port, const char *path,
|
||||
const char *body, int body_len, int *out_len) {
|
||||
int fd = create_tcp_socket(host, port);
|
||||
if (fd < 0) return NULL;
|
||||
|
||||
char req[4096];
|
||||
build_request(req, sizeof(req), body ? "POST" : "GET", host, path, body, body_len);
|
||||
|
||||
if (send(fd, req, strlen(req), 0) < 0) {
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char *resp = malloc(HTTP_BUFFER_SIZE);
|
||||
if (!resp) {
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int total = 0;
|
||||
while (total < HTTP_BUFFER_SIZE - 1) {
|
||||
int n = recv(fd, resp + total, HTTP_BUFFER_SIZE - 1 - total, 0);
|
||||
if (n <= 0) break;
|
||||
total += n;
|
||||
}
|
||||
resp[total] = '\0';
|
||||
|
||||
close(fd);
|
||||
|
||||
// Extract body after headers
|
||||
char *headers_end = strstr(resp, "\r\n\r\n");
|
||||
if (headers_end) {
|
||||
memmove(resp, headers_end + 4, total - (headers_end - resp) - 4);
|
||||
total = total - (headers_end - resp) - 4;
|
||||
resp[total] = '\0';
|
||||
}
|
||||
|
||||
if (out_len) *out_len = total;
|
||||
return resp;
|
||||
}
|
||||
|
||||
char *https_get(const char *host, int port, const char *path, int *out_len) {
|
||||
return https_post(host, port, path, NULL, 0, out_len);
|
||||
}
|
||||
|
||||
char *https_post(const char *host, int port, const char *path,
|
||||
const char *body, int body_len, int *out_len) {
|
||||
int fd = create_tcp_socket(host, port);
|
||||
if (fd < 0) return NULL;
|
||||
|
||||
SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
|
||||
if (!ctx) {
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
SSL *ssl = SSL_new(ctx);
|
||||
if (!ssl) {
|
||||
SSL_CTX_free(ctx);
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
SSL_set_fd(ssl, fd);
|
||||
SSL_set_tlsext_host_name(ssl, host);
|
||||
|
||||
if (SSL_connect(ssl) <= 0) {
|
||||
SSL_free(ssl);
|
||||
SSL_CTX_free(ctx);
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char req[4096];
|
||||
build_request(req, sizeof(req), body ? "POST" : "GET", host, path, body, body_len);
|
||||
|
||||
if (SSL_write(ssl, req, strlen(req)) <= 0) {
|
||||
SSL_free(ssl);
|
||||
SSL_CTX_free(ctx);
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char *resp = malloc(HTTP_BUFFER_SIZE);
|
||||
if (!resp) {
|
||||
SSL_free(ssl);
|
||||
SSL_CTX_free(ctx);
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int total = 0;
|
||||
while (total < HTTP_BUFFER_SIZE - 1) {
|
||||
int n = SSL_read(ssl, resp + total, HTTP_BUFFER_SIZE - 1 - total);
|
||||
if (n <= 0) {
|
||||
int err = SSL_get_error(ssl, n);
|
||||
log_write("[HTTPS] SSL_read returned %d, SSL_error=%d\n", n, err);
|
||||
break;
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
resp[total] = '\0';
|
||||
log_write("[HTTPS] read %d bytes total, first 200: %.200s\n", total, resp);
|
||||
log_write("[HTTPS] read %d bytes total\n", total);
|
||||
|
||||
SSL_shutdown(ssl);
|
||||
SSL_free(ssl);
|
||||
SSL_CTX_free(ctx);
|
||||
close(fd);
|
||||
|
||||
// Strip HTTP headers
|
||||
char *headers_end = strstr(resp, "\r\n\r\n");
|
||||
if (!headers_end) {
|
||||
if (out_len) *out_len = 0;
|
||||
return resp;
|
||||
}
|
||||
int header_len = (headers_end - resp) + 4;
|
||||
bool is_chunked = (strcasestr(resp, "transfer-encoding") != NULL);
|
||||
memmove(resp, headers_end + 4, total - header_len);
|
||||
total -= header_len;
|
||||
resp[total] = '\0';
|
||||
|
||||
// Dechunk if needed (parse hex chunk sizes, copy chunk data)
|
||||
if (is_chunked) {
|
||||
log_write("[HTTPS] dechunking %d bytes\n", total);
|
||||
char *out = malloc(HTTP_BUFFER_SIZE);
|
||||
if (out) {
|
||||
int out_pos = 0;
|
||||
char *p = resp;
|
||||
int chunk_num = 0;
|
||||
while (*p && !(*p == '0' && (p[1] == '\r' || p[1] == '\n'))) {
|
||||
int chunk_len = 0;
|
||||
while (*p && *p != '\r' && *p != '\n') {
|
||||
char hex = *p;
|
||||
chunk_len <<= 4;
|
||||
chunk_len += (hex >= '0' && hex <= '9') ? (hex - '0') : ((hex & 0x1f) + 9);
|
||||
p++;
|
||||
}
|
||||
if (*p == '\r') p++;
|
||||
if (*p == '\n') p++;
|
||||
if (chunk_len > 0 && out_pos + chunk_len < HTTP_BUFFER_SIZE - 1) {
|
||||
memcpy(out + out_pos, p, chunk_len);
|
||||
out_pos += chunk_len;
|
||||
p += chunk_len;
|
||||
}
|
||||
if (*p == '\r') p++;
|
||||
if (*p == '\n') p++;
|
||||
chunk_num++;
|
||||
if (chunk_num == 1) {
|
||||
log_write("[HTTPS] first chunk: len=%d, data='%.100s'\n", chunk_len, p - chunk_len);
|
||||
}
|
||||
}
|
||||
out[out_pos] = '\0';
|
||||
log_write("[HTTPS] dechunked: %d chunks, %d bytes, first 200: '%.200s'\n", chunk_num, out_pos, out);
|
||||
free(resp);
|
||||
resp = out;
|
||||
total = out_pos;
|
||||
}
|
||||
}
|
||||
|
||||
if (out_len) *out_len = total;
|
||||
return resp;
|
||||
}
|
||||
|
||||
/*
|
||||
* Compute HMAC-SHA256 digest of data using key, then base64-encode the result.
|
||||
* Uses OpenSSL HMAC() + BIO_f_base64 filter chain.
|
||||
*/
|
||||
static int hmac_sha256_base64(const char *key, const char *data, char *out, size_t out_len) {
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int digest_len = 0;
|
||||
|
||||
if (HMAC(EVP_sha256(), key, (int)strlen(key),
|
||||
(const unsigned char *)data, (int)strlen(data),
|
||||
digest, &digest_len) == NULL) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Use BIO_s_mem as backend, BIO_f_base64 as filter
|
||||
BIO *bmem = BIO_new(BIO_s_mem());
|
||||
BIO *b64 = BIO_new(BIO_f_base64());
|
||||
BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL);
|
||||
BIO *chain = BIO_push(b64, bmem);
|
||||
|
||||
BIO_write(chain, digest, digest_len);
|
||||
BIO_flush(chain);
|
||||
|
||||
// Get data from mem BIO before freeing
|
||||
char *buf = NULL;
|
||||
long len = BIO_get_mem_data(bmem, &buf);
|
||||
if (len < 0 || (size_t)len >= out_len) {
|
||||
BIO_free_all(chain);
|
||||
return -1;
|
||||
}
|
||||
memcpy(out, buf, (size_t)len);
|
||||
out[len] = '\0';
|
||||
|
||||
BIO_free_all(chain);
|
||||
return (int)len;
|
||||
}
|
||||
|
||||
/*
|
||||
* Authenticated GET request to KuCoin REST API.
|
||||
* Builds signature from timestamp + "GET" + path using HMAC-SHA256.
|
||||
* Passphrase is also HMAC'd. Sends as KC-API-* headers.
|
||||
*/
|
||||
char *https_get_auth(const char *host, int port, const char *path,
|
||||
const char *api_key, const char *api_secret,
|
||||
const char *api_passphrase, int *out_len) {
|
||||
if (!api_key || !api_secret || !api_passphrase || !*api_key || !*api_secret || !*api_passphrase) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
char timestamp[32];
|
||||
snprintf(timestamp, sizeof(timestamp), "%lld", (long long)(ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL));
|
||||
|
||||
char sign_input[512];
|
||||
snprintf(sign_input, sizeof(sign_input), "%sGET%s", timestamp, path);
|
||||
log_write("[HTTP_AUTH] sign_input: '%s'\n", sign_input);
|
||||
char sign_b64[256] = {0};
|
||||
if (hmac_sha256_base64(api_secret, sign_input, sign_b64, sizeof(sign_b64)) < 0) {
|
||||
return NULL;
|
||||
}
|
||||
log_write("[HTTP_AUTH] sign_b64: '%s'\n", sign_b64);
|
||||
|
||||
char passphrase_b64[256] = {0};
|
||||
if (hmac_sha256_base64(api_secret, api_passphrase, passphrase_b64, sizeof(passphrase_b64)) < 0) {
|
||||
return NULL;
|
||||
}
|
||||
int fd = create_tcp_socket(host, port);
|
||||
if (fd < 0) return NULL;
|
||||
|
||||
SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
|
||||
if (!ctx) { close(fd); return NULL; }
|
||||
|
||||
SSL *ssl = SSL_new(ctx);
|
||||
if (!ssl) { SSL_CTX_free(ctx); close(fd); return NULL; }
|
||||
|
||||
SSL_set_fd(ssl, fd);
|
||||
SSL_set_tlsext_host_name(ssl, host);
|
||||
|
||||
if (SSL_connect(ssl) <= 0) {
|
||||
SSL_free(ssl); SSL_CTX_free(ctx); close(fd); return NULL;
|
||||
}
|
||||
|
||||
char req[4096];
|
||||
snprintf(req, sizeof(req),
|
||||
"GET %s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
"Accept: */*\r\n"
|
||||
"User-Agent: fused-engine/1.0\r\n"
|
||||
"Connection: close\r\n"
|
||||
"KC-API-KEY: %s\r\n"
|
||||
"KC-API-SIGN: %s\r\n"
|
||||
"KC-API-TIMESTAMP: %s\r\n"
|
||||
"KC-API-PASSPHRASE: %s\r\n"
|
||||
"KC-API-SIGN-TYPE: 2\r\n"
|
||||
"KC-API-KEY-VERSION: 3\r\n"
|
||||
"\r\n",
|
||||
path, host, api_key, sign_b64, timestamp, passphrase_b64);
|
||||
|
||||
if (SSL_write(ssl, req, (int)strlen(req)) <= 0) {
|
||||
SSL_free(ssl); SSL_CTX_free(ctx); close(fd); return NULL;
|
||||
}
|
||||
|
||||
char *resp = malloc(HTTP_BUFFER_SIZE);
|
||||
if (!resp) { SSL_free(ssl); SSL_CTX_free(ctx); close(fd); return NULL; }
|
||||
|
||||
int total = 0;
|
||||
while (total < HTTP_BUFFER_SIZE - 1) {
|
||||
int n = SSL_read(ssl, resp + total, HTTP_BUFFER_SIZE - 1 - total);
|
||||
if (n <= 0) {
|
||||
int err = SSL_get_error(ssl, n);
|
||||
log_write("[HTTPS_AUTH] SSL_read returned %d, SSL_error=%d\n", n, err);
|
||||
break;
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
resp[total] = '\0';
|
||||
log_write("[HTTPS_AUTH] read %d bytes total, first 200: %.200s\n", total, resp);
|
||||
log_write("[HTTPS_AUTH] read %d bytes total\n", total);
|
||||
|
||||
SSL_shutdown(ssl);
|
||||
SSL_free(ssl);
|
||||
SSL_CTX_free(ctx);
|
||||
close(fd);
|
||||
|
||||
// Strip headers and dechunk
|
||||
char *headers_end = strstr(resp, "\r\n\r\n");
|
||||
if (headers_end) {
|
||||
int hl = (headers_end - resp) + 4;
|
||||
bool chunked = (strcasestr(resp, "transfer-encoding") != NULL);
|
||||
memmove(resp, headers_end + 4, total - hl);
|
||||
total -= hl;
|
||||
resp[total] = '\0';
|
||||
if (chunked) {
|
||||
char *out = malloc(HTTP_BUFFER_SIZE);
|
||||
if (out) {
|
||||
int op = 0;
|
||||
char *p = resp;
|
||||
while (*p && !(*p == '0' && (p[1] == '\r' || p[1] == '\n'))) {
|
||||
int cl = 0;
|
||||
while (*p && *p != '\r' && *p != '\n') {
|
||||
char h = *p;
|
||||
cl <<= 4;
|
||||
cl += (h >= '0' && h <= '9') ? (h - '0') : ((h & 0x1f) + 9);
|
||||
p++;
|
||||
}
|
||||
if (*p == '\r') p++;
|
||||
if (*p == '\n') p++;
|
||||
if (cl > 0 && op + cl < HTTP_BUFFER_SIZE - 1) {
|
||||
memcpy(out + op, p, cl);
|
||||
op += cl;
|
||||
p += cl;
|
||||
}
|
||||
if (*p == '\r') p++;
|
||||
if (*p == '\n') p++;
|
||||
}
|
||||
out[op] = '\0';
|
||||
free(resp);
|
||||
resp = out;
|
||||
total = op;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log_write("[HTTP_AUTH] body (%d bytes): %.200s\n", total, resp);
|
||||
|
||||
if (out_len) *out_len = total;
|
||||
return resp;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#ifndef FUSED_HTTP_CLIENT_H
|
||||
#define FUSED_HTTP_CLIENT_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* Plain TCP HTTP GET request; returns malloc'd body (caller frees) or NULL */
|
||||
char *http_get(const char *host, int port, const char *path, int *out_len);
|
||||
/* Plain TCP HTTP POST request; returns malloc'd body (caller frees) or NULL */
|
||||
char *http_post(const char *host, int port, const char *path,
|
||||
const char *body, int body_len, int *out_len);
|
||||
|
||||
/* TLS HTTPS GET request via OpenSSL; returns malloc'd body (caller frees) or NULL */
|
||||
char *https_get(const char *host, int port, const char *path, int *out_len);
|
||||
/* TLS HTTPS POST request via OpenSSL; returns malloc'd body (caller frees) or NULL */
|
||||
char *https_post(const char *host, int port, const char *path,
|
||||
const char *body, int body_len, int *out_len);
|
||||
|
||||
/* Authenticated HTTPS GET signed with KuCoin API HMAC-SHA256; returns malloc'd body */
|
||||
char *https_get_auth(const char *host, int port, const char *path,
|
||||
const char *api_key, const char *api_secret,
|
||||
const char *api_passphrase, int *out_len);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
/*
|
||||
* http_server.c - Simple single-threaded HTTP server for health & book queries
|
||||
*
|
||||
* Provides REST endpoints:
|
||||
* GET /health - connection status
|
||||
* GET /book/{symbol} - single order book snapshot
|
||||
* GET /books - all order books
|
||||
* GET /symbols - list tracked symbols
|
||||
* POST /symbols - dynamically add symbols (subscribe via WS)
|
||||
* DELETE /symbols/{name} - remove symbol
|
||||
*/
|
||||
|
||||
#include "log.h"
|
||||
#include "http_server.h"
|
||||
#include "cJSON.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
|
||||
static int set_nonblocking(int fd) {
|
||||
int flags = fcntl(fd, F_GETFL, 0);
|
||||
if (flags < 0) return -1;
|
||||
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
|
||||
}
|
||||
|
||||
int http_server_init(http_server_t *srv, const char *host, int port,
|
||||
order_book_t *books, symbol_table_t *symbols,
|
||||
ws_client_t *ws_client, evaluator_t *evaluator,
|
||||
config_t *cfg) {
|
||||
memset(srv, 0, sizeof(*srv));
|
||||
srv->books = books;
|
||||
srv->symbols = symbols;
|
||||
srv->ws_client = ws_client;
|
||||
srv->evaluator = evaluator;
|
||||
srv->cfg = cfg;
|
||||
srv->client_fd = -1;
|
||||
srv->running = true;
|
||||
|
||||
srv->listen_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (srv->listen_fd < 0) {
|
||||
perror("socket");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int opt = 1;
|
||||
setsockopt(srv->listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
|
||||
|
||||
struct sockaddr_in addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(port);
|
||||
if (strcmp(host, "0.0.0.0") == 0) {
|
||||
addr.sin_addr.s_addr = INADDR_ANY;
|
||||
} else {
|
||||
inet_pton(AF_INET, host, &addr.sin_addr);
|
||||
}
|
||||
|
||||
if (bind(srv->listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
perror("bind");
|
||||
close(srv->listen_fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (listen(srv->listen_fd, 16) < 0) {
|
||||
perror("listen");
|
||||
close(srv->listen_fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
set_nonblocking(srv->listen_fd);
|
||||
log_write("[HTTP] Server listening on %s:%d\n", host, port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void http_server_destroy(http_server_t *srv) {
|
||||
srv->running = false;
|
||||
if (srv->client_fd >= 0) close(srv->client_fd);
|
||||
if (srv->listen_fd >= 0) close(srv->listen_fd);
|
||||
}
|
||||
|
||||
int http_server_accept(http_server_t *srv) {
|
||||
if (srv->client_fd >= 0) {
|
||||
close(srv->client_fd);
|
||||
}
|
||||
struct sockaddr_in client_addr;
|
||||
socklen_t addr_len = sizeof(client_addr);
|
||||
srv->client_fd = accept(srv->listen_fd, (struct sockaddr *)&client_addr, &addr_len);
|
||||
if (srv->client_fd < 0) {
|
||||
if (errno != EAGAIN && errno != EWOULDBLOCK) {
|
||||
perror("accept");
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
set_nonblocking(srv->client_fd);
|
||||
srv->recv_len = 0;
|
||||
return srv->client_fd;
|
||||
}
|
||||
|
||||
static void http_send(http_server_t *srv, const char *status,
|
||||
const char *content_type, const char *body) {
|
||||
char header[512];
|
||||
int body_len = body ? (int)strlen(body) : 0;
|
||||
int hdr_len = snprintf(header, sizeof(header),
|
||||
"HTTP/1.1 %s\r\n"
|
||||
"Content-Type: %s\r\n"
|
||||
"Content-Length: %d\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n",
|
||||
status, content_type, body_len);
|
||||
write(srv->client_fd, header, (size_t)hdr_len);
|
||||
if (body && body_len > 0) {
|
||||
write(srv->client_fd, body, (size_t)body_len);
|
||||
}
|
||||
}
|
||||
|
||||
static void http_send_json(http_server_t *srv, const char *body) {
|
||||
http_send(srv, "200 OK", "application/json", body);
|
||||
}
|
||||
|
||||
static void http_send_error(http_server_t *srv, const char *status, const char *msg) {
|
||||
http_send(srv, status, "text/plain", msg);
|
||||
}
|
||||
|
||||
static void handle_health(http_server_t *srv) {
|
||||
char body[256];
|
||||
int connected = 0;
|
||||
if (srv->ws_client) {
|
||||
for (uint32_t i = 0; i < srv->ws_client->connection_count; i++) {
|
||||
if (srv->ws_client->connections[i].state == WS_STATE_CONNECTED) connected++;
|
||||
}
|
||||
}
|
||||
snprintf(body, sizeof(body),
|
||||
"{\"status\":\"ok\",\"ws_connections\":%d,\"symbols\":%u}",
|
||||
connected, srv->symbols ? srv->symbols->count : 0);
|
||||
http_send_json(srv, body);
|
||||
}
|
||||
|
||||
static void handle_book(http_server_t *srv, const char *symbol) {
|
||||
if (!srv->symbols || !srv->books) {
|
||||
http_send_error(srv, "500 Internal Server Error", "not initialized\n");
|
||||
return;
|
||||
}
|
||||
|
||||
int16_t idx = symbol_table_lookup(srv->symbols, symbol);
|
||||
if (idx < 0) {
|
||||
char body[128];
|
||||
snprintf(body, sizeof(body), "{\"error\":\"symbol not found\",\"symbol\":\"%s\"}", symbol);
|
||||
http_send(srv, "404 Not Found", "application/json", body);
|
||||
return;
|
||||
}
|
||||
|
||||
order_book_t *book = &srv->books[idx];
|
||||
char body[2048];
|
||||
int off = 0;
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off,
|
||||
"{\"symbol\":\"%s\",\"ts_ms\":%lld,\"sequence\":%lld,\"bids\":[",
|
||||
book->symbol, (long long)book->ts_ms, (long long)book->sequence);
|
||||
for (uint8_t i = 0; i < book->bid_count; i++) {
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off,
|
||||
"%s[%.6g,%.8g]", i ? "," : "", book->bids[i][0], book->bids[i][1]);
|
||||
}
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off, "],\"asks\":[");
|
||||
for (uint8_t i = 0; i < book->ask_count; i++) {
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off,
|
||||
"%s[%.6g,%.8g]", i ? "," : "", book->asks[i][0], book->asks[i][1]);
|
||||
}
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off, "]}");
|
||||
http_send_json(srv, body);
|
||||
}
|
||||
|
||||
static void handle_books(http_server_t *srv) {
|
||||
if (!srv->symbols || !srv->books) {
|
||||
http_send_error(srv, "500 Internal Server Error", "not initialized\n");
|
||||
return;
|
||||
}
|
||||
|
||||
char body[65536];
|
||||
int off = snprintf(body, sizeof(body), "[");
|
||||
for (uint32_t i = 0; i < srv->symbols->count && (size_t)off < sizeof(body) - 512; i++) {
|
||||
order_book_t *book = &srv->books[i];
|
||||
if (book->ts_ms <= 0) continue;
|
||||
if (off > 1) off += snprintf(body + off, sizeof(body) - (size_t)off, ",");
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off,
|
||||
"{\"symbol\":\"%s\",\"ts\":%lld,\"bids\":[",
|
||||
book->symbol, (long long)book->ts_ms);
|
||||
for (uint8_t j = 0; j < book->bid_count; j++) {
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off,
|
||||
"%s[%.6g,%.8g]", j ? "," : "", book->bids[j][0], book->bids[j][1]);
|
||||
}
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off, "],\"asks\":[");
|
||||
for (uint8_t j = 0; j < book->ask_count; j++) {
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off,
|
||||
"%s[%.6g,%.8g]", j ? "," : "", book->asks[j][0], book->asks[j][1]);
|
||||
}
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off, "]}");
|
||||
}
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off, "]");
|
||||
http_send_json(srv, body);
|
||||
}
|
||||
|
||||
static void handle_symbols_list(http_server_t *srv) {
|
||||
if (!srv->symbols) {
|
||||
http_send_error(srv, "500 Internal Server Error", "not initialized\n");
|
||||
return;
|
||||
}
|
||||
|
||||
char body[65536];
|
||||
int off = snprintf(body, sizeof(body), "[");
|
||||
for (uint32_t i = 0; i < srv->symbols->count && (size_t)off < sizeof(body) - 64; i++) {
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off,
|
||||
"%s\"%s\"", i ? "," : "", srv->symbols->entries[i].name);
|
||||
}
|
||||
off += snprintf(body + off, sizeof(body) - (size_t)off, "]");
|
||||
http_send_json(srv, body);
|
||||
}
|
||||
|
||||
static void handle_symbols_add(http_server_t *srv, const char *body) {
|
||||
if (!srv->symbols || !srv->ws_client) {
|
||||
http_send_error(srv, "500 Internal Server Error", "not initialized\n");
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_Parse(body);
|
||||
if (!root) {
|
||||
http_send_error(srv, "400 Bad Request", "invalid JSON\n");
|
||||
return;
|
||||
}
|
||||
|
||||
char resp[1024] = "{\"added\":[";
|
||||
int resp_len = (int)strlen(resp);
|
||||
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, root) {
|
||||
if (!cJSON_IsString(item)) continue;
|
||||
const char *sym = item->valuestring;
|
||||
if (!sym) continue;
|
||||
|
||||
int16_t existing = symbol_table_lookup(srv->symbols, sym);
|
||||
if (existing >= 0) continue;
|
||||
|
||||
if (symbol_table_add(srv->symbols, sym) == 0) {
|
||||
int16_t idx = symbol_table_lookup(srv->symbols, sym);
|
||||
if (idx >= 0) {
|
||||
if (resp_len > 11) resp_len += snprintf(resp + resp_len,
|
||||
sizeof(resp) - (size_t)resp_len, ",");
|
||||
resp_len += snprintf(resp + resp_len,
|
||||
sizeof(resp) - (size_t)resp_len, "\"%s\"", sym);
|
||||
|
||||
// Auto-subscribe to WS stream for the new symbol
|
||||
uint16_t uidx = (uint16_t)idx;
|
||||
if (srv->ws_client->connection_count > 0) {
|
||||
ws_client_subscribe(srv->ws_client, 0, &uidx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resp_len += snprintf(resp + resp_len, sizeof(resp) - (size_t)resp_len, "]}");
|
||||
cJSON_Delete(root);
|
||||
http_send_json(srv, resp);
|
||||
}
|
||||
|
||||
static void handle_symbols_remove(http_server_t *srv, const char *symbol) {
|
||||
if (!srv->symbols || !srv->ws_client) {
|
||||
http_send_error(srv, "500 Internal Server Error", "not initialized\n");
|
||||
return;
|
||||
}
|
||||
|
||||
int16_t idx = symbol_table_lookup(srv->symbols, symbol);
|
||||
if (idx < 0) {
|
||||
char body[128];
|
||||
snprintf(body, sizeof(body), "{\"error\":\"symbol not found\",\"symbol\":\"%s\"}", symbol);
|
||||
http_send(srv, "404 Not Found", "application/json", body);
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t uidx = (uint16_t)idx;
|
||||
if (srv->ws_client->connection_count > 0) {
|
||||
ws_client_unsubscribe(srv->ws_client, 0, &uidx, 1);
|
||||
}
|
||||
|
||||
// Compact the symbol table by shifting entries
|
||||
for (uint32_t i = 0; i < srv->symbols->count; i++) {
|
||||
if (strcmp(srv->symbols->entries[i].name, symbol) == 0) {
|
||||
memmove(&srv->symbols->entries[i], &srv->symbols->entries[i + 1],
|
||||
(srv->symbols->count - i - 1) * sizeof(symbol_entry_t));
|
||||
srv->symbols->count--;
|
||||
for (uint32_t j = i; j < srv->symbols->count; j++) {
|
||||
srv->symbols->entries[j].index = (uint16_t)j;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
char resp[128];
|
||||
snprintf(resp, sizeof(resp), "{\"removed\":\"%s\"}", symbol);
|
||||
http_send_json(srv, resp);
|
||||
}
|
||||
|
||||
int http_server_handle_request(http_server_t *srv) {
|
||||
ssize_t n = read(srv->client_fd, srv->recv_buf + srv->recv_len,
|
||||
sizeof(srv->recv_buf) - srv->recv_len - 1);
|
||||
if (n <= 0) {
|
||||
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
|
||||
perror("read");
|
||||
}
|
||||
close(srv->client_fd);
|
||||
srv->client_fd = -1;
|
||||
srv->recv_len = 0;
|
||||
return -1;
|
||||
}
|
||||
srv->recv_len += (size_t)n;
|
||||
srv->recv_buf[srv->recv_len] = '\0';
|
||||
|
||||
char *headers_end = strstr((char *)srv->recv_buf, "\r\n\r\n");
|
||||
if (!headers_end) return 0;
|
||||
|
||||
char *request_line = (char *)srv->recv_buf;
|
||||
char *rl_end = strchr(request_line, '\r');
|
||||
if (!rl_end) return 0;
|
||||
*rl_end = '\0';
|
||||
|
||||
char method[16] = {0}, path[512] = {0};
|
||||
sscanf(request_line, "%15s %511s", method, path);
|
||||
|
||||
char *body_start = headers_end + 4;
|
||||
size_t body_len = srv->recv_len - (size_t)(body_start - (char *)srv->recv_buf);
|
||||
|
||||
if (strcmp(path, "/health") == 0) {
|
||||
handle_health(srv);
|
||||
} else if (strncmp(path, "/book/", 6) == 0) {
|
||||
handle_book(srv, path + 6);
|
||||
} else if (strcmp(path, "/books") == 0) {
|
||||
handle_books(srv);
|
||||
} else if (strcmp(path, "/symbols") == 0) {
|
||||
if (strcmp(method, "POST") == 0) {
|
||||
handle_symbols_add(srv, body_start);
|
||||
} else {
|
||||
handle_symbols_list(srv);
|
||||
}
|
||||
} else if (strncmp(path, "/symbols/", 9) == 0) {
|
||||
if (strcmp(method, "DELETE") == 0) {
|
||||
handle_symbols_remove(srv, path + 9);
|
||||
} else {
|
||||
http_send_error(srv, "405 Method Not Allowed", "use DELETE\n");
|
||||
}
|
||||
} else {
|
||||
http_send_error(srv, "404 Not Found", "not found\n");
|
||||
}
|
||||
|
||||
close(srv->client_fd);
|
||||
srv->client_fd = -1;
|
||||
srv->recv_len = 0;
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
#ifndef FUSED_HTTP_SERVER_H
|
||||
#define FUSED_HTTP_SERVER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "book.h"
|
||||
#include "hash.h"
|
||||
#include "config.h"
|
||||
#include "ws_client.h"
|
||||
#include "evaluate.h"
|
||||
|
||||
/* Embedded HTTP server for health/status endpoints */
|
||||
typedef struct {
|
||||
int listen_fd; /* server listening socket */
|
||||
int client_fd; /* currently connected client socket */
|
||||
uint8_t recv_buf[8192]; /* request receive buffer */
|
||||
size_t recv_len; /* bytes received so far */
|
||||
order_book_t *books; /* pointer to shared order books */
|
||||
symbol_table_t *symbols; /* pointer to shared symbol table */
|
||||
ws_client_t *ws_client; /* pointer to WebSocket client state */
|
||||
evaluator_t *evaluator; /* pointer to evaluator state */
|
||||
config_t *cfg; /* pointer to configuration */
|
||||
bool running; /* false signals server to stop */
|
||||
} http_server_t;
|
||||
|
||||
/* Initialise and bind the HTTP server */
|
||||
int http_server_init(http_server_t *srv, const char *host, int port,
|
||||
order_book_t *books, symbol_table_t *symbols,
|
||||
ws_client_t *ws_client, evaluator_t *evaluator,
|
||||
config_t *cfg);
|
||||
/* Destroy the HTTP server and close sockets */
|
||||
void http_server_destroy(http_server_t *srv);
|
||||
/* Accept a new client connection (non-blocking) */
|
||||
int http_server_accept(http_server_t *srv);
|
||||
/* Read, parse, and respond to the current client request */
|
||||
int http_server_handle_request(http_server_t *srv);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,471 @@
|
|||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2010 Serge Zaitsev
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
#ifndef JSMN_H
|
||||
#define JSMN_H
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#ifdef JSMN_STATIC
|
||||
#define JSMN_API static
|
||||
#else
|
||||
#define JSMN_API extern
|
||||
#endif
|
||||
|
||||
/**
|
||||
* JSON type identifier. Basic types are:
|
||||
* o Object
|
||||
* o Array
|
||||
* o String
|
||||
* o Other primitive: number, boolean (true/false) or null
|
||||
*/
|
||||
typedef enum {
|
||||
JSMN_UNDEFINED = 0,
|
||||
JSMN_OBJECT = 1 << 0,
|
||||
JSMN_ARRAY = 1 << 1,
|
||||
JSMN_STRING = 1 << 2,
|
||||
JSMN_PRIMITIVE = 1 << 3
|
||||
} jsmntype_t;
|
||||
|
||||
enum jsmnerr {
|
||||
/* Not enough tokens were provided */
|
||||
JSMN_ERROR_NOMEM = -1,
|
||||
/* Invalid character inside JSON string */
|
||||
JSMN_ERROR_INVAL = -2,
|
||||
/* The string is not a full JSON packet, more bytes expected */
|
||||
JSMN_ERROR_PART = -3
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON token description.
|
||||
* type type (object, array, string etc.)
|
||||
* start start position in JSON data string
|
||||
* end end position in JSON data string
|
||||
*/
|
||||
typedef struct jsmntok {
|
||||
jsmntype_t type;
|
||||
int start;
|
||||
int end;
|
||||
int size;
|
||||
#ifdef JSMN_PARENT_LINKS
|
||||
int parent;
|
||||
#endif
|
||||
} jsmntok_t;
|
||||
|
||||
/**
|
||||
* JSON parser. Contains an array of token blocks available. Also stores
|
||||
* the string being parsed now and current position in that string.
|
||||
*/
|
||||
typedef struct jsmn_parser {
|
||||
unsigned int pos; /* offset in the JSON string */
|
||||
unsigned int toknext; /* next token to allocate */
|
||||
int toksuper; /* superior token node, e.g. parent object or array */
|
||||
} jsmn_parser;
|
||||
|
||||
/**
|
||||
* Create JSON parser over an array of tokens
|
||||
*/
|
||||
JSMN_API void jsmn_init(jsmn_parser *parser);
|
||||
|
||||
/**
|
||||
* Run JSON parser. It parses a JSON data string into and array of tokens, each
|
||||
* describing
|
||||
* a single JSON object.
|
||||
*/
|
||||
JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len,
|
||||
jsmntok_t *tokens, const unsigned int num_tokens);
|
||||
|
||||
#ifndef JSMN_HEADER
|
||||
/**
|
||||
* Allocates a fresh unused token from the token pool.
|
||||
*/
|
||||
static jsmntok_t *jsmn_alloc_token(jsmn_parser *parser, jsmntok_t *tokens,
|
||||
const size_t num_tokens) {
|
||||
jsmntok_t *tok;
|
||||
if (parser->toknext >= num_tokens) {
|
||||
return NULL;
|
||||
}
|
||||
tok = &tokens[parser->toknext++];
|
||||
tok->start = tok->end = -1;
|
||||
tok->size = 0;
|
||||
#ifdef JSMN_PARENT_LINKS
|
||||
tok->parent = -1;
|
||||
#endif
|
||||
return tok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills token type and boundaries.
|
||||
*/
|
||||
static void jsmn_fill_token(jsmntok_t *token, const jsmntype_t type,
|
||||
const int start, const int end) {
|
||||
token->type = type;
|
||||
token->start = start;
|
||||
token->end = end;
|
||||
token->size = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills next available token with JSON primitive.
|
||||
*/
|
||||
static int jsmn_parse_primitive(jsmn_parser *parser, const char *js,
|
||||
const size_t len, jsmntok_t *tokens,
|
||||
const size_t num_tokens) {
|
||||
jsmntok_t *token;
|
||||
int start;
|
||||
|
||||
start = parser->pos;
|
||||
|
||||
for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
|
||||
switch (js[parser->pos]) {
|
||||
#ifndef JSMN_STRICT
|
||||
/* In strict mode primitive must be followed by "," or "}" or "]" */
|
||||
case ':':
|
||||
#endif
|
||||
case '\t':
|
||||
case '\r':
|
||||
case '\n':
|
||||
case ' ':
|
||||
case ',':
|
||||
case ']':
|
||||
case '}':
|
||||
goto found;
|
||||
default:
|
||||
/* to quiet a warning from gcc*/
|
||||
break;
|
||||
}
|
||||
if (js[parser->pos] < 32 || js[parser->pos] >= 127) {
|
||||
parser->pos = start;
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
}
|
||||
#ifdef JSMN_STRICT
|
||||
/* In strict mode primitive must be followed by a comma/object/array */
|
||||
parser->pos = start;
|
||||
return JSMN_ERROR_PART;
|
||||
#endif
|
||||
|
||||
found:
|
||||
if (tokens == NULL) {
|
||||
parser->pos--;
|
||||
return 0;
|
||||
}
|
||||
token = jsmn_alloc_token(parser, tokens, num_tokens);
|
||||
if (token == NULL) {
|
||||
parser->pos = start;
|
||||
return JSMN_ERROR_NOMEM;
|
||||
}
|
||||
jsmn_fill_token(token, JSMN_PRIMITIVE, start, parser->pos);
|
||||
#ifdef JSMN_PARENT_LINKS
|
||||
token->parent = parser->toksuper;
|
||||
#endif
|
||||
parser->pos--;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills next token with JSON string.
|
||||
*/
|
||||
static int jsmn_parse_string(jsmn_parser *parser, const char *js,
|
||||
const size_t len, jsmntok_t *tokens,
|
||||
const size_t num_tokens) {
|
||||
jsmntok_t *token;
|
||||
|
||||
int start = parser->pos;
|
||||
|
||||
/* Skip starting quote */
|
||||
parser->pos++;
|
||||
|
||||
for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
|
||||
char c = js[parser->pos];
|
||||
|
||||
/* Quote: end of string */
|
||||
if (c == '\"') {
|
||||
if (tokens == NULL) {
|
||||
return 0;
|
||||
}
|
||||
token = jsmn_alloc_token(parser, tokens, num_tokens);
|
||||
if (token == NULL) {
|
||||
parser->pos = start;
|
||||
return JSMN_ERROR_NOMEM;
|
||||
}
|
||||
jsmn_fill_token(token, JSMN_STRING, start + 1, parser->pos);
|
||||
#ifdef JSMN_PARENT_LINKS
|
||||
token->parent = parser->toksuper;
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Backslash: Quoted symbol expected */
|
||||
if (c == '\\' && parser->pos + 1 < len) {
|
||||
int i;
|
||||
parser->pos++;
|
||||
switch (js[parser->pos]) {
|
||||
/* Allowed escaped symbols */
|
||||
case '\"':
|
||||
case '/':
|
||||
case '\\':
|
||||
case 'b':
|
||||
case 'f':
|
||||
case 'r':
|
||||
case 'n':
|
||||
case 't':
|
||||
break;
|
||||
/* Allows escaped symbol \uXXXX */
|
||||
case 'u':
|
||||
parser->pos++;
|
||||
for (i = 0; i < 4 && parser->pos < len && js[parser->pos] != '\0';
|
||||
i++) {
|
||||
/* If it isn't a hex character we have an error */
|
||||
if (!((js[parser->pos] >= 48 && js[parser->pos] <= 57) || /* 0-9 */
|
||||
(js[parser->pos] >= 65 && js[parser->pos] <= 70) || /* A-F */
|
||||
(js[parser->pos] >= 97 && js[parser->pos] <= 102))) { /* a-f */
|
||||
parser->pos = start;
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
parser->pos++;
|
||||
}
|
||||
parser->pos--;
|
||||
break;
|
||||
/* Unexpected symbol */
|
||||
default:
|
||||
parser->pos = start;
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
parser->pos = start;
|
||||
return JSMN_ERROR_PART;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON string and fill tokens.
|
||||
*/
|
||||
JSMN_API int jsmn_parse(jsmn_parser *parser, const char *js, const size_t len,
|
||||
jsmntok_t *tokens, const unsigned int num_tokens) {
|
||||
int r;
|
||||
int i;
|
||||
jsmntok_t *token;
|
||||
int count = parser->toknext;
|
||||
|
||||
for (; parser->pos < len && js[parser->pos] != '\0'; parser->pos++) {
|
||||
char c;
|
||||
jsmntype_t type;
|
||||
|
||||
c = js[parser->pos];
|
||||
switch (c) {
|
||||
case '{':
|
||||
case '[':
|
||||
count++;
|
||||
if (tokens == NULL) {
|
||||
break;
|
||||
}
|
||||
token = jsmn_alloc_token(parser, tokens, num_tokens);
|
||||
if (token == NULL) {
|
||||
return JSMN_ERROR_NOMEM;
|
||||
}
|
||||
if (parser->toksuper != -1) {
|
||||
jsmntok_t *t = &tokens[parser->toksuper];
|
||||
#ifdef JSMN_STRICT
|
||||
/* In strict mode an object or array can't become a key */
|
||||
if (t->type == JSMN_OBJECT) {
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
#endif
|
||||
t->size++;
|
||||
#ifdef JSMN_PARENT_LINKS
|
||||
token->parent = parser->toksuper;
|
||||
#endif
|
||||
}
|
||||
token->type = (c == '{' ? JSMN_OBJECT : JSMN_ARRAY);
|
||||
token->start = parser->pos;
|
||||
parser->toksuper = parser->toknext - 1;
|
||||
break;
|
||||
case '}':
|
||||
case ']':
|
||||
if (tokens == NULL) {
|
||||
break;
|
||||
}
|
||||
type = (c == '}' ? JSMN_OBJECT : JSMN_ARRAY);
|
||||
#ifdef JSMN_PARENT_LINKS
|
||||
if (parser->toknext < 1) {
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
token = &tokens[parser->toknext - 1];
|
||||
for (;;) {
|
||||
if (token->start != -1 && token->end == -1) {
|
||||
if (token->type != type) {
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
token->end = parser->pos + 1;
|
||||
parser->toksuper = token->parent;
|
||||
break;
|
||||
}
|
||||
if (token->parent == -1) {
|
||||
if (token->type != type || parser->toksuper == -1) {
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
break;
|
||||
}
|
||||
token = &tokens[token->parent];
|
||||
}
|
||||
#else
|
||||
for (i = parser->toknext - 1; i >= 0; i--) {
|
||||
token = &tokens[i];
|
||||
if (token->start != -1 && token->end == -1) {
|
||||
if (token->type != type) {
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
parser->toksuper = -1;
|
||||
token->end = parser->pos + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
/* Error if unmatched closing bracket */
|
||||
if (i == -1) {
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
for (; i >= 0; i--) {
|
||||
token = &tokens[i];
|
||||
if (token->start != -1 && token->end == -1) {
|
||||
parser->toksuper = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
break;
|
||||
case '\"':
|
||||
r = jsmn_parse_string(parser, js, len, tokens, num_tokens);
|
||||
if (r < 0) {
|
||||
return r;
|
||||
}
|
||||
count++;
|
||||
if (parser->toksuper != -1 && tokens != NULL) {
|
||||
tokens[parser->toksuper].size++;
|
||||
}
|
||||
break;
|
||||
case '\t':
|
||||
case '\r':
|
||||
case '\n':
|
||||
case ' ':
|
||||
break;
|
||||
case ':':
|
||||
parser->toksuper = parser->toknext - 1;
|
||||
break;
|
||||
case ',':
|
||||
if (tokens != NULL && parser->toksuper != -1 &&
|
||||
tokens[parser->toksuper].type != JSMN_ARRAY &&
|
||||
tokens[parser->toksuper].type != JSMN_OBJECT) {
|
||||
#ifdef JSMN_PARENT_LINKS
|
||||
parser->toksuper = tokens[parser->toksuper].parent;
|
||||
#else
|
||||
for (i = parser->toknext - 1; i >= 0; i--) {
|
||||
if (tokens[i].type == JSMN_ARRAY || tokens[i].type == JSMN_OBJECT) {
|
||||
if (tokens[i].start != -1 && tokens[i].end == -1) {
|
||||
parser->toksuper = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
break;
|
||||
#ifdef JSMN_STRICT
|
||||
/* In strict mode primitives are: numbers and booleans */
|
||||
case '-':
|
||||
case '0':
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
case '4':
|
||||
case '5':
|
||||
case '6':
|
||||
case '7':
|
||||
case '8':
|
||||
case '9':
|
||||
case 't':
|
||||
case 'f':
|
||||
case 'n':
|
||||
/* And they must not be keys of the object */
|
||||
if (tokens != NULL && parser->toksuper != -1) {
|
||||
const jsmntok_t *t = &tokens[parser->toksuper];
|
||||
if (t->type == JSMN_OBJECT ||
|
||||
(t->type == JSMN_STRING && t->size != 0)) {
|
||||
return JSMN_ERROR_INVAL;
|
||||
}
|
||||
}
|
||||
#else
|
||||
/* In non-strict mode every unquoted value is a primitive */
|
||||
default:
|
||||
#endif
|
||||
r = jsmn_parse_primitive(parser, js, len, tokens, num_tokens);
|
||||
if (r < 0) {
|
||||
return r;
|
||||
}
|
||||
count++;
|
||||
if (parser->toksuper != -1 && tokens != NULL) {
|
||||
tokens[parser->toksuper].size++;
|
||||
}
|
||||
break;
|
||||
|
||||
#ifdef JSMN_STRICT
|
||||
/* Unexpected char in strict mode */
|
||||
default:
|
||||
return JSMN_ERROR_INVAL;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens != NULL) {
|
||||
for (i = parser->toknext - 1; i >= 0; i--) {
|
||||
/* Unmatched opened object or array */
|
||||
if (tokens[i].start != -1 && tokens[i].end == -1) {
|
||||
return JSMN_ERROR_PART;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new parser based over a given buffer with an array of tokens
|
||||
* available.
|
||||
*/
|
||||
JSMN_API void jsmn_init(jsmn_parser *parser) {
|
||||
parser->pos = 0;
|
||||
parser->toknext = 0;
|
||||
parser->toksuper = -1;
|
||||
}
|
||||
|
||||
#endif /* JSMN_HEADER */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* JSMN_H */
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* log.c - Non-blocking timestamped stderr logger
|
||||
*
|
||||
* Formats messages into a pipe with O_NONBLOCK so the hot path never
|
||||
* blocks on I/O. A background thread drains the pipe and writes to
|
||||
* stderr. When the pipe buffer is full messages are silently dropped.
|
||||
*/
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include "log.h"
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <pthread.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static int log_pipe[2] = {-1, -1};
|
||||
static pthread_t log_thread;
|
||||
static atomic_bool log_running = false;
|
||||
|
||||
static void *log_worker(void *arg) {
|
||||
(void)arg;
|
||||
char buf[4096];
|
||||
while (atomic_load(&log_running)) {
|
||||
ssize_t n = read(log_pipe[0], buf, sizeof(buf));
|
||||
if (n > 0) {
|
||||
write(STDERR_FILENO, buf, (size_t)n);
|
||||
} else if (n < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
usleep(100);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void log_init(void) {
|
||||
if (pipe2(log_pipe, O_NONBLOCK) != 0) {
|
||||
log_pipe[0] = log_pipe[1] = -1;
|
||||
return;
|
||||
}
|
||||
atomic_store(&log_running, true);
|
||||
pthread_create(&log_thread, NULL, log_worker, NULL);
|
||||
}
|
||||
|
||||
void log_shutdown(void) {
|
||||
atomic_store(&log_running, false);
|
||||
if (log_thread) {
|
||||
pthread_join(log_thread, NULL);
|
||||
}
|
||||
if (log_pipe[0] >= 0) { close(log_pipe[0]); log_pipe[0] = -1; }
|
||||
if (log_pipe[1] >= 0) { close(log_pipe[1]); log_pipe[1] = -1; }
|
||||
}
|
||||
|
||||
void log_write(const char *fmt, ...) {
|
||||
if (log_pipe[1] < 0) {
|
||||
/* fallback: sync write to stderr */
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
vfprintf(stderr, fmt, ap);
|
||||
va_end(ap);
|
||||
return;
|
||||
}
|
||||
|
||||
char ts[32];
|
||||
time_t t = time(NULL);
|
||||
struct tm tm;
|
||||
localtime_r(&t, &tm);
|
||||
strftime(ts, sizeof(ts), "[%Y/%m/%d %H:%M:%S] ", &tm);
|
||||
|
||||
char buf[1536];
|
||||
int ts_len = (int)strlen(ts);
|
||||
memcpy(buf, ts, (size_t)ts_len);
|
||||
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
int msg_len = vsnprintf(buf + ts_len, sizeof(buf) - (size_t)ts_len, fmt, ap);
|
||||
va_end(ap);
|
||||
|
||||
int total = ts_len + (msg_len > 0 ? msg_len : 0);
|
||||
if (total > 0) {
|
||||
write(log_pipe[1], buf, (size_t)total);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
#ifndef FUSED_LOG_H
|
||||
#define FUSED_LOG_H
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
/* Initialise the non-blocking log subsystem (pipe + writer thread). */
|
||||
void log_init(void);
|
||||
|
||||
/* Shut down the writer thread and close the pipe. */
|
||||
void log_shutdown(void);
|
||||
|
||||
/* Write a formatted log line to stderr with timestamp and newline.
|
||||
* Non-blocking after log_init(); falls back to synchronous fprintf. */
|
||||
void log_write(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
/*
|
||||
* main.c - Fused triangular arbitrage engine entry point
|
||||
*
|
||||
* Orchestrates: config loading -> fee table fetch -> symbol discovery ->
|
||||
* WebSocket connections -> epoll event loops (hot/cold threads) -> HTTP status server.
|
||||
* Signal delivery to external executor via Unix domain socket.
|
||||
*/
|
||||
|
||||
#include "log.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <openssl/ssl.h>
|
||||
#include <openssl/err.h>
|
||||
#include "config.h"
|
||||
#include "hash.h"
|
||||
#include "book.h"
|
||||
#include "triangle.h"
|
||||
#include "http_client.h"
|
||||
#include "symbols_api.h"
|
||||
#include "ws_client.h"
|
||||
#include "evaluate.h"
|
||||
#include "queue.h"
|
||||
#include "events.h"
|
||||
#include "http_server.h"
|
||||
|
||||
static volatile sig_atomic_t g_running = 1;
|
||||
|
||||
static void signal_handler(int sig) {
|
||||
(void)sig;
|
||||
g_running = 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
const char *config_path = "config.yaml";
|
||||
if (argc > 1) config_path = argv[1];
|
||||
|
||||
struct sigaction sa = {0};
|
||||
sa.sa_handler = signal_handler;
|
||||
sa.sa_flags = 0;
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
sigaction(SIGTERM, &sa, NULL);
|
||||
|
||||
sigset_t block_mask;
|
||||
sigemptyset(&block_mask);
|
||||
sigaddset(&block_mask, SIGINT);
|
||||
sigaddset(&block_mask, SIGTERM);
|
||||
sigaddset(&block_mask, SIGPIPE);
|
||||
pthread_sigmask(SIG_BLOCK, &block_mask, NULL);
|
||||
|
||||
/* Early OpenSSL init to avoid provider thread issues */
|
||||
SSL_library_init();
|
||||
OpenSSL_add_all_algorithms();
|
||||
SSL_load_error_strings();
|
||||
|
||||
log_init();
|
||||
|
||||
log_write("[MAIN] Loading config from '%s'...\n", config_path);
|
||||
config_t cfg;
|
||||
if (config_load(config_path, &cfg) != 0) {
|
||||
log_write("[MAIN] Failed to load config\n");
|
||||
return 1;
|
||||
}
|
||||
log_write("[MAIN] Config: threshold=%.1f bps, cooldown=%.0fs, hold=%u currencies\n",
|
||||
cfg.signal_threshold_bps, cfg.cooldown_seconds, cfg.hold_currency_count);
|
||||
|
||||
// Fetch fee table with auth (KuCoin /api/v1/base-fee)
|
||||
fee_entry_t *fees = NULL;
|
||||
uint32_t fee_count = 0;
|
||||
|
||||
log_write("[MAIN] API key present: %s\n", cfg.kucoin_api_key[0] ? "yes" : "no");
|
||||
if (cfg.kucoin_api_key[0] && cfg.kucoin_api_secret[0] && cfg.kucoin_api_passphrase[0]) {
|
||||
log_write("[MAIN] >>> Calling https_get_auth for /api/v1/base-fee\n");
|
||||
int out_len = 0;
|
||||
char *fee_json = https_get_auth("api.kucoin.com", 443, "/api/v1/base-fee",
|
||||
cfg.kucoin_api_key, cfg.kucoin_api_secret,
|
||||
cfg.kucoin_api_passphrase, &out_len);
|
||||
log_write("[MAIN] <<< https_get_auth returned: %p, len=%d\n", fee_json, out_len);
|
||||
if (fee_json && out_len > 0) {
|
||||
if (load_fee_table(fee_json, &fees, &fee_count) == 0) {
|
||||
log_write("[MAIN] Fee table: %u currencies loaded\n", fee_count);
|
||||
} else {
|
||||
log_write("[MAIN] Fee table parse failed, using default 0.1%% fees\n");
|
||||
}
|
||||
free(fee_json);
|
||||
} else {
|
||||
log_write("[MAIN] Could not fetch fee table, using default 0.1%% fees\n");
|
||||
}
|
||||
} else {
|
||||
log_write("[MAIN] No API credentials in config, using default 0.1%% fees\n");
|
||||
}
|
||||
|
||||
// Discover symbols from KuCoin: fetch pairs, enumerate triangles, populate table
|
||||
log_write("[MAIN] >>> Initializing symbol table\n");
|
||||
symbol_table_t symbols;
|
||||
symbol_table_init(&symbols);
|
||||
|
||||
for (uint32_t i = 0; i < cfg.symbol_count; i++) {
|
||||
symbol_table_add(&symbols, cfg.symbols[i]);
|
||||
}
|
||||
|
||||
log_write("[MAIN] >>> Calling discover_symbols\n");
|
||||
triangle_set_t triangles;
|
||||
if (discover_symbols(&symbols, &triangles, &cfg, fees, fee_count) != 0) {
|
||||
log_write("[MAIN] Symbol discovery failed\n");
|
||||
free_fee_table(fees);
|
||||
return 1;
|
||||
}
|
||||
log_write("[MAIN] <<< discover_symbols done: %u symbols, %u triangles\n", symbols.count, triangles.triangle_count);
|
||||
|
||||
log_write("[MAIN] >>> Allocating books array\n");
|
||||
order_book_t *books = calloc(MAX_SYMBOLS, sizeof(order_book_t));
|
||||
log_write("[MAIN] books=%p, size=%zu\n", (void*)books, sizeof(order_book_t));
|
||||
|
||||
log_write("[MAIN] >>> Init SPSC queue\n");
|
||||
if (!books) {
|
||||
log_write("[MAIN] Failed to allocate books\n");
|
||||
free_fee_table(fees);
|
||||
return 1;
|
||||
}
|
||||
|
||||
spsc_queue_t signal_queue;
|
||||
log_write("[MAIN] >>> Calling spsc_init\n");
|
||||
int wakeup_fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);
|
||||
if (wakeup_fd < 0 || spsc_init(&signal_queue, wakeup_fd) != 0) {
|
||||
log_write("[MAIN] Failed to init signal queue\n");
|
||||
triangle_set_free(&triangles);
|
||||
free_fee_table(fees);
|
||||
return 1;
|
||||
}
|
||||
|
||||
evaluator_t evaluator;
|
||||
evaluator_init(&evaluator, &triangles, books, &cfg, &signal_queue,
|
||||
cfg.kcs_discount_active);
|
||||
|
||||
ws_client_t ws_client;
|
||||
if (ws_client_init(&ws_client, &cfg, &symbols, books, &evaluator) != 0) {
|
||||
log_write("[MAIN] Failed to init WS client\n");
|
||||
spsc_destroy(&signal_queue);
|
||||
triangle_set_free(&triangles);
|
||||
free_fee_table(fees);
|
||||
return 1;
|
||||
}
|
||||
|
||||
event_loops_t events;
|
||||
if (event_loops_init(&events, &ws_client, &signal_queue, &cfg, wakeup_fd) != 0) {
|
||||
log_write("[MAIN] Failed to init event loops\n");
|
||||
ws_client_destroy(&ws_client);
|
||||
spsc_destroy(&signal_queue);
|
||||
triangle_set_free(&triangles);
|
||||
free_fee_table(fees);
|
||||
return 1;
|
||||
}
|
||||
|
||||
http_server_t http_srv;
|
||||
if (http_server_init(&http_srv, cfg.rest_host, cfg.rest_port,
|
||||
books, &symbols, &ws_client, &evaluator, &cfg) != 0) {
|
||||
log_write("[MAIN] HTTP server init failed (non-fatal)\n");
|
||||
}
|
||||
|
||||
if (symbols.count > 0) {
|
||||
uint16_t all_indices[MAX_SYMBOLS];
|
||||
uint32_t n = symbols.count > MAX_SYMBOLS ? MAX_SYMBOLS : symbols.count;
|
||||
for (uint32_t i = 0; i < n; i++) {
|
||||
all_indices[i] = (uint16_t)i;
|
||||
}
|
||||
|
||||
uint32_t conns_needed = (n + 399) / 400;
|
||||
if (conns_needed < 1) conns_needed = 1;
|
||||
if (conns_needed > WS_MAX_CONNECTIONS) conns_needed = WS_MAX_CONNECTIONS;
|
||||
ws_client.connection_count = conns_needed;
|
||||
|
||||
log_write("[MAIN] Connecting %u WS connections...\n", conns_needed);
|
||||
for (uint32_t i = 0; i < conns_needed; i++) {
|
||||
if (ws_client_connect(&ws_client, i) != 0) {
|
||||
log_write("[MAIN] WS connection %u failed\n", i);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-subscribe up to 400 symbols per WS connection
|
||||
uint32_t batch_start = 0;
|
||||
for (uint32_t conn_idx = 0; conn_idx < conns_needed; conn_idx++) {
|
||||
uint32_t batch_end = batch_start + 400;
|
||||
if (batch_end > n) batch_end = n;
|
||||
uint32_t batch_count = batch_end - batch_start;
|
||||
if (batch_count > 0) {
|
||||
ws_client_subscribe(&ws_client, conn_idx,
|
||||
all_indices + batch_start, batch_count);
|
||||
}
|
||||
batch_start = batch_end;
|
||||
}
|
||||
log_write("[MAIN] Subscribed to %u symbols across %u WS connections\n",
|
||||
n, ws_client.connection_count);
|
||||
}
|
||||
|
||||
log_write("[MAIN] Spawning threads...\n");
|
||||
pthread_t hot_thread, cold_thread;
|
||||
pthread_create(&hot_thread, NULL, event_hot_thread, &events);
|
||||
pthread_create(&cold_thread, NULL, event_cold_thread, &events);
|
||||
|
||||
// Unblock signals in main thread only; worker threads inherit blocked mask
|
||||
sigset_t unblock_mask;
|
||||
sigemptyset(&unblock_mask);
|
||||
sigaddset(&unblock_mask, SIGINT);
|
||||
sigaddset(&unblock_mask, SIGTERM);
|
||||
pthread_sigmask(SIG_UNBLOCK, &unblock_mask, NULL);
|
||||
|
||||
log_write("[MAIN] Fused engine running. Press Ctrl+C to stop.\n");
|
||||
|
||||
// Main loop: accept HTTP connections and reconnect disconnected WS
|
||||
while (g_running) {
|
||||
if (http_srv.listen_fd >= 0 && http_srv.client_fd < 0) {
|
||||
http_server_accept(&http_srv);
|
||||
}
|
||||
if (http_srv.client_fd >= 0) {
|
||||
http_server_handle_request(&http_srv);
|
||||
}
|
||||
|
||||
struct timespec ts = {0, 100000000};
|
||||
nanosleep(&ts, NULL);
|
||||
|
||||
for (uint32_t i = 0; i < ws_client.connection_count; i++) {
|
||||
ws_connection_t *conn = &ws_client.connections[i];
|
||||
if (conn->state == WS_STATE_DISCONNECTED && g_running) {
|
||||
uint64_t now = ws_client_now_ms();
|
||||
if (now - conn->last_activity_ms > 5000) {
|
||||
log_write("[MAIN] Reconnecting WS %u...\n", i);
|
||||
if (ws_client_connect(&ws_client, i) == 0) {
|
||||
if (conn->symbol_count > 0) {
|
||||
ws_client_subscribe(&ws_client, i,
|
||||
conn->symbol_indices, conn->symbol_count);
|
||||
}
|
||||
}
|
||||
conn->last_activity_ms = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log_write("[MAIN] Shutting down...\n");
|
||||
events.running = false;
|
||||
ws_client.running = false;
|
||||
|
||||
uint64_t val = 1;
|
||||
ssize_t wr = write(events.wakeup_fd, &val, sizeof(val));
|
||||
(void)wr;
|
||||
|
||||
pthread_join(hot_thread, NULL);
|
||||
pthread_join(cold_thread, NULL);
|
||||
|
||||
http_server_destroy(&http_srv);
|
||||
event_loops_destroy(&events);
|
||||
ws_client_destroy(&ws_client);
|
||||
spsc_destroy(&signal_queue);
|
||||
triangle_set_free(&triangles);
|
||||
free(books);
|
||||
free_fee_table(fees);
|
||||
|
||||
log_write("[MAIN] Shutdown complete.\n");
|
||||
log_shutdown();
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* queue.c - Lock-free single-producer single-consumer (SPSC) bounded queue
|
||||
*
|
||||
* Uses C11 atomics with acquire/release ordering for correct head/tail
|
||||
* synchronization without locks. The eventfd notify on push wakes the
|
||||
* consumer's epoll loop (cold thread) for immediate dispatch.
|
||||
*/
|
||||
|
||||
#include "queue.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/eventfd.h>
|
||||
#include <errno.h>
|
||||
|
||||
int spsc_init(spsc_queue_t *q, int wakeup_fd) {
|
||||
memset(q, 0, sizeof(*q));
|
||||
q->buffer = calloc(Spsc_QUEUE_DEPTH, sizeof(signal_entry_t));
|
||||
if (!q->buffer) return -1;
|
||||
q->head = 0;
|
||||
q->tail = 0;
|
||||
q->depth = Spsc_QUEUE_DEPTH;
|
||||
q->dropped = 0;
|
||||
q->eventfd = wakeup_fd;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void spsc_destroy(spsc_queue_t *q) {
|
||||
/* eventfd is owned by caller (event_loops), don't close */
|
||||
q->eventfd = -1;
|
||||
free(q->buffer);
|
||||
q->buffer = NULL;
|
||||
}
|
||||
|
||||
static inline void eventfd_notify(int fd) {
|
||||
uint64_t val = 1;
|
||||
ssize_t ret;
|
||||
do {
|
||||
ret = write(fd, &val, sizeof(val));
|
||||
} while (ret < 0 && errno == EINTR);
|
||||
(void)ret;
|
||||
}
|
||||
|
||||
bool spsc_push(spsc_queue_t *q, const signal_entry_t *entry) {
|
||||
uint32_t head = atomic_load_explicit(&q->head, memory_order_relaxed);
|
||||
uint32_t tail = atomic_load_explicit(&q->tail, memory_order_acquire);
|
||||
|
||||
uint32_t next_head = head + 1;
|
||||
if (next_head >= q->depth) next_head = 0;
|
||||
|
||||
if (next_head == tail) {
|
||||
q->dropped++;
|
||||
return false;
|
||||
}
|
||||
|
||||
q->buffer[head] = *entry;
|
||||
|
||||
atomic_store_explicit(&q->head, next_head, memory_order_release);
|
||||
eventfd_notify(q->eventfd);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool spsc_pop(spsc_queue_t *q, signal_entry_t *entry) {
|
||||
uint32_t tail = atomic_load_explicit(&q->tail, memory_order_relaxed);
|
||||
uint32_t head = atomic_load_explicit(&q->head, memory_order_acquire);
|
||||
|
||||
if (tail == head) return false;
|
||||
|
||||
*entry = q->buffer[tail];
|
||||
|
||||
uint32_t next_tail = tail + 1;
|
||||
if (next_tail >= q->depth) next_tail = 0;
|
||||
|
||||
atomic_store_explicit(&q->tail, next_tail, memory_order_release);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool spsc_empty(const spsc_queue_t *q) {
|
||||
uint32_t head = atomic_load_explicit(&q->head, memory_order_acquire);
|
||||
uint32_t tail = atomic_load_explicit(&q->tail, memory_order_acquire);
|
||||
return head == tail;
|
||||
}
|
||||
|
||||
uint32_t spsc_count(const spsc_queue_t *q) {
|
||||
uint32_t head = atomic_load_explicit(&q->head, memory_order_acquire);
|
||||
uint32_t tail = atomic_load_explicit(&q->tail, memory_order_acquire);
|
||||
if (head >= tail) return head - tail;
|
||||
return q->depth - tail + head;
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
#ifndef FUSED_QUEUE_H
|
||||
#define FUSED_QUEUE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdatomic.h>
|
||||
#include "book.h"
|
||||
#include "triangle.h"
|
||||
|
||||
#define MAX_SIGNAL_LEN 4096
|
||||
#define Spsc_QUEUE_DEPTH 1024
|
||||
|
||||
/* Single price+size level in an order book snapshot */
|
||||
typedef struct {
|
||||
double price; /* price at this level */
|
||||
double size; /* available size at this level */
|
||||
} book_level_t;
|
||||
|
||||
/* Snapshot of one leg's order book included in a signal */
|
||||
typedef struct {
|
||||
char symbol[SYMBOL_NAME_LEN]; /* trading pair symbol name */
|
||||
int64_t ts_ms; /* book timestamp (milliseconds) */
|
||||
book_level_t bids[MAX_BOOK_LEVELS]; /* bid levels (top of book) */
|
||||
book_level_t asks[MAX_BOOK_LEVELS]; /* ask levels (top of book) */
|
||||
uint8_t bid_count; /* number of valid bid levels */
|
||||
uint8_t ask_count; /* number of valid ask levels */
|
||||
} signal_book_t;
|
||||
|
||||
/* One leg of a triangular arbitrage signal */
|
||||
typedef struct {
|
||||
char symbol[SYMBOL_NAME_LEN]; /* trading pair symbol */
|
||||
char input_currency[CURRENCY_NAME_LEN]; /* currency being traded in */
|
||||
char output_currency[CURRENCY_NAME_LEN]; /* currency being traded out */
|
||||
char fee_currency[CURRENCY_NAME_LEN]; /* currency used to pay fees */
|
||||
double fee_rate; /* fee rate for this leg */
|
||||
double exchange_rate; /* computed exchange rate for the leg */
|
||||
char side[5]; /* trade side: "buy" or "sell" */
|
||||
char order_param[32]; /* order parameter string for the executor */
|
||||
double quote_volume; /* notional quote volume for the leg */
|
||||
double base_increment; /* base asset lot size step */
|
||||
double quote_increment; /* quote asset lot size step */
|
||||
double base_min_size; /* minimum base asset order size */
|
||||
} signal_leg_t;
|
||||
|
||||
/* Collection of up to 3 legs comprising a triangular signal */
|
||||
typedef struct {
|
||||
uint8_t leg_count; /* number of legs (typically 3) */
|
||||
signal_leg_t legs[3]; /* the individual leg descriptors */
|
||||
} signal_legs_t;
|
||||
|
||||
/* Entry describing one triangular arbitrage opportunity */
|
||||
typedef struct {
|
||||
char triangle_key[CURRENCY_NAME_LEN * 3 + 4]; /* unique triangle identifier e.g. "BTC-ETH-USDT" */
|
||||
char primary_quote[CURRENCY_NAME_LEN]; /* primary quote currency of the triangle */
|
||||
double predicted_bps; /* predicted profit in basis points */
|
||||
char max_volume[32]; /* max trade volume as string */
|
||||
double starting_volume; /* recommended starting volume */
|
||||
bool live; /* whether this entry is from live (vs simulated) data */
|
||||
int64_t ts_ms; /* signal generation timestamp */
|
||||
int64_t book_ts_ms; /* reference order book timestamp */
|
||||
int64_t t_sock_arrive_ms; /* timestamp when bytes arrived at SSL_read */
|
||||
int64_t t_arrive_ms; /* arrival timestamp at evaluator (post-parse) */
|
||||
int64_t t_eval_ms; /* evaluation completion timestamp */
|
||||
uint8_t book_count; /* number of books used (typically 3) */
|
||||
signal_book_t books[3]; /* order book snapshots for each leg */
|
||||
signal_legs_t legs; /* leg descriptors for execution */
|
||||
} signal_entry_t;
|
||||
|
||||
/* Lock-free single-producer single-consumer queue for signal entries */
|
||||
typedef struct {
|
||||
signal_entry_t *buffer; /* ring buffer of entries */
|
||||
_Atomic uint32_t head; /* consumer read index (atomic) */
|
||||
_Atomic uint32_t tail; /* producer write index (atomic) */
|
||||
int eventfd; /* eventfd for waking consumer */
|
||||
uint32_t depth; /* capacity of the ring buffer */
|
||||
uint32_t dropped; /* count of dropped entries due to full queue */
|
||||
} spsc_queue_t;
|
||||
|
||||
/* Initialise an SPSC queue backed by a wakeup eventfd */
|
||||
int spsc_init(spsc_queue_t *q, int wakeup_fd);
|
||||
/* Destroy an SPSC queue and free its buffer */
|
||||
void spsc_destroy(spsc_queue_t *q);
|
||||
/* Push an entry into the queue (non-blocking); returns false if full */
|
||||
bool spsc_push(spsc_queue_t *q, const signal_entry_t *entry);
|
||||
/* Pop an entry from the queue (non-blocking); returns false if empty */
|
||||
bool spsc_pop(spsc_queue_t *q, signal_entry_t *entry);
|
||||
/* Returns true if the queue is empty */
|
||||
bool spsc_empty(const spsc_queue_t *q);
|
||||
/* Returns the number of entries currently in the queue */
|
||||
uint32_t spsc_count(const spsc_queue_t *q);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,426 @@
|
|||
/*
|
||||
* symbols_api.c - KuCoin symbol discovery and triangle enumeration
|
||||
*
|
||||
* Fetches all trading pairs from KuCoin /api/v2/symbols, filters by exclusion list,
|
||||
* enumerates all triangular arbitrage paths over the hold currencies.
|
||||
*
|
||||
* Triangle enumeration: for each hold currency H, find all pairs (H, A) and (H, B),
|
||||
* then for each unordered pair {A, B} check if pair (A, B) exists, forming triangle
|
||||
* H -> A -> B -> H in both directional orders.
|
||||
*/
|
||||
|
||||
#include "log.h"
|
||||
#include "symbols_api.h"
|
||||
#include "http_client.h"
|
||||
#include "cJSON.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#define MAX_PAIRS 4096
|
||||
#define MAX_SYMBOLS 2048
|
||||
#define SYMBOL_NAME_LEN 16
|
||||
#define CURRENCY_NAME_LEN 8
|
||||
#define PAIR_HASH_SIZE 4096
|
||||
#define MAX_NEIGHBORS 2048
|
||||
|
||||
/* --- Pair hash table: unordered currency pair -> pair index --- */
|
||||
|
||||
typedef struct {
|
||||
char a[CURRENCY_NAME_LEN];
|
||||
char b[CURRENCY_NAME_LEN];
|
||||
uint32_t pair_idx;
|
||||
bool used;
|
||||
} ph_entry_t;
|
||||
|
||||
typedef struct {
|
||||
ph_entry_t entries[PAIR_HASH_SIZE];
|
||||
} pair_hash_t;
|
||||
|
||||
static uint32_t ph_hash(const char *a, const char *b) {
|
||||
uint32_t h = 5381;
|
||||
const uint8_t *s = (const uint8_t *)a;
|
||||
while (*s) { h = (h << 5) + h + *s; s++; }
|
||||
s = (const uint8_t *)b;
|
||||
while (*s) { h = (h << 5) + h + *s; s++; }
|
||||
return h % PAIR_HASH_SIZE;
|
||||
}
|
||||
|
||||
static void ph_init(pair_hash_t *ph) {
|
||||
memset(ph, 0, sizeof(*ph));
|
||||
}
|
||||
|
||||
static void ph_insert(pair_hash_t *ph, const char *c1, const char *c2, uint32_t idx) {
|
||||
// Normalize: store lexicographically smaller first for unordered lookup
|
||||
const char *a = c1, *b = c2;
|
||||
if (strcmp(a, b) > 0) { a = c2; b = c1; }
|
||||
uint32_t h = ph_hash(a, b);
|
||||
while (ph->entries[h].used) {
|
||||
if (strcmp(ph->entries[h].a, a) == 0 && strcmp(ph->entries[h].b, b) == 0)
|
||||
return; /* already inserted, keep first */
|
||||
h = (h + 1) % PAIR_HASH_SIZE;
|
||||
}
|
||||
strncpy(ph->entries[h].a, a, CURRENCY_NAME_LEN - 1);
|
||||
strncpy(ph->entries[h].b, b, CURRENCY_NAME_LEN - 1);
|
||||
ph->entries[h].pair_idx = idx;
|
||||
ph->entries[h].used = true;
|
||||
}
|
||||
|
||||
static bool ph_find(const pair_hash_t *ph, const char *c1, const char *c2, uint32_t *idx) {
|
||||
const char *a = c1, *b = c2;
|
||||
if (strcmp(a, b) > 0) { a = c2; b = c1; }
|
||||
uint32_t h = ph_hash(a, b);
|
||||
while (ph->entries[h].used) {
|
||||
if (strcmp(ph->entries[h].a, a) == 0 && strcmp(ph->entries[h].b, b) == 0) {
|
||||
*idx = ph->entries[h].pair_idx;
|
||||
return true;
|
||||
}
|
||||
h = (h + 1) % PAIR_HASH_SIZE;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --- Fee lookup helper --- */
|
||||
|
||||
/* Fee rate per category: cat1=0.1%, cat2=0.2%, cat3=0.3% */
|
||||
static double fee_rate_for_pair(const trading_pair_t *pair, bool kcs_discount) {
|
||||
double base = pair->fee_category * 0.001;
|
||||
double rate = base * pair->taker_fee_coeff;
|
||||
return kcs_discount ? rate * 0.8 : rate;
|
||||
}
|
||||
|
||||
int fetch_trading_pairs(pair_list_t *out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->capacity = MAX_PAIRS;
|
||||
out->pairs = calloc(out->capacity, sizeof(trading_pair_t));
|
||||
if (!out->pairs) return -1;
|
||||
|
||||
int out_len = 0;
|
||||
char *json = https_get("api.kucoin.com", 443, "/api/v2/symbols", &out_len);
|
||||
if (!json || out_len <= 0) {
|
||||
log_write("[SYMBOLS] Failed to fetch trading pairs\n");
|
||||
free(out->pairs);
|
||||
out->pairs = NULL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_Parse(json);
|
||||
free(json);
|
||||
if (!root) {
|
||||
log_write("[SYMBOLS] Failed to parse symbols JSON\n");
|
||||
free(out->pairs);
|
||||
out->pairs = NULL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *code = cJSON_GetObjectItem(root, "code");
|
||||
if (!cJSON_IsString(code) || strcmp(code->valuestring, "200000") != 0) {
|
||||
log_write("[SYMBOLS] API error: %s\n", cJSON_PrintUnformatted(root));
|
||||
cJSON_Delete(root);
|
||||
free(out->pairs);
|
||||
out->pairs = NULL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *data = cJSON_GetObjectItem(root, "data");
|
||||
if (!cJSON_IsArray(data)) {
|
||||
log_write("[SYMBOLS] 'data' is not an array\n");
|
||||
cJSON_Delete(root);
|
||||
free(out->pairs);
|
||||
out->pairs = NULL;
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint32_t count = 0;
|
||||
cJSON *item;
|
||||
cJSON_ArrayForEach(item, data) {
|
||||
if (count >= out->capacity) break;
|
||||
if (!cJSON_IsObject(item)) continue;
|
||||
|
||||
cJSON *sym = cJSON_GetObjectItem(item, "symbol");
|
||||
cJSON *base = cJSON_GetObjectItem(item, "baseCurrency");
|
||||
cJSON *quote = cJSON_GetObjectItem(item, "quoteCurrency");
|
||||
cJSON *fee_cur = cJSON_GetObjectItem(item, "feeCurrency");
|
||||
cJSON *enable = cJSON_GetObjectItem(item, "enableTrading");
|
||||
cJSON *fee_cat = cJSON_GetObjectItem(item, "feeCategory");
|
||||
cJSON *taker_coeff = cJSON_GetObjectItem(item, "takerFeeCoefficient");
|
||||
cJSON *base_inc = cJSON_GetObjectItem(item, "baseIncrement");
|
||||
cJSON *quote_inc = cJSON_GetObjectItem(item, "quoteIncrement");
|
||||
cJSON *base_min = cJSON_GetObjectItem(item, "baseMinSize");
|
||||
|
||||
if (!cJSON_IsString(sym) || !cJSON_IsString(base) || !cJSON_IsString(quote)) continue;
|
||||
if (!cJSON_IsBool(enable) || !cJSON_IsTrue(enable)) continue;
|
||||
|
||||
trading_pair_t *pair = &out->pairs[count++];
|
||||
strncpy(pair->symbol, sym->valuestring, SYMBOL_NAME_LEN - 1);
|
||||
strncpy(pair->base, base->valuestring, CURRENCY_NAME_LEN - 1);
|
||||
strncpy(pair->quote, quote->valuestring, CURRENCY_NAME_LEN - 1);
|
||||
if (cJSON_IsString(fee_cur)) {
|
||||
strncpy(pair->fee_currency, fee_cur->valuestring, CURRENCY_NAME_LEN - 1);
|
||||
}
|
||||
pair->fee_category = cJSON_IsNumber(fee_cat) ? (uint8_t)fee_cat->valueint : 1;
|
||||
pair->taker_fee_coeff = cJSON_IsNumber(taker_coeff) ? taker_coeff->valuedouble : 1.0;
|
||||
pair->maker_fee_coeff = 1.0;
|
||||
pair->base_increment = cJSON_IsString(base_inc) ? atof(base_inc->valuestring) : 0.0;
|
||||
pair->quote_increment = cJSON_IsString(quote_inc) ? atof(quote_inc->valuestring) : 0.0;
|
||||
pair->base_min_size = cJSON_IsString(base_min) ? atof(base_min->valuestring) : 0.0;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
out->count = count;
|
||||
log_write("[SYMBOLS] Fetched %u trading pairs\n", count);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Discover triangles from trading pairs.
|
||||
*
|
||||
* For each configured hold currency H:
|
||||
* 1. Find all neighbor currencies A such that pair(H, A) exists
|
||||
* 2. For each unordered pair (A, B) of neighbors, check if pair(A, B) exists
|
||||
* 3. If yes, two directed triangles are formed:
|
||||
* dir0: H -> A -> B -> H (H->A, A->B, B->H)
|
||||
* dir1: H -> B -> A -> H (H->B, B->A, A->H)
|
||||
*
|
||||
* use_bid logic (per leg):
|
||||
* For a leg trading pair X-Y:
|
||||
* We want output_currency = Y (the next currency in the cycle).
|
||||
* If Y equals pair.quote, we can buy Y (pay X) at ask (use_bid=0).
|
||||
* BUT the code uses use_bid=1 when output_currency == pair.quote
|
||||
* because selling X gets us quote currency (Y).
|
||||
* Actually: use_bid=1 means hit bid = sell base = receive quote.
|
||||
* use_bid=0 means hit ask = buy base = pay quote.
|
||||
*
|
||||
* t->use_bid[leg] = 1 when the output currency matches pair.quote:
|
||||
* We reach this leg with "next currency = quote" -> to get from base to quote,
|
||||
* we sell base at bid (use_bid=1).
|
||||
* t->use_bid[leg] = 0 when the output currency matches pair.base:
|
||||
* We need to go from quote to base -> buy base at ask (use_bid=0).
|
||||
*
|
||||
* After enumeration, converts pair indices to symbol table indices and builds
|
||||
* tri_index (symbol -> flat triangle list) for fast symbol-based lookup.
|
||||
*/
|
||||
int discover_symbols(symbol_table_t *symbols, triangle_set_t *triangles,
|
||||
const config_t *cfg, const fee_entry_t *fees, uint32_t fee_count) {
|
||||
pair_list_t pairs;
|
||||
if (fetch_trading_pairs(&pairs) != 0) {
|
||||
log_write("[SYMBOLS] Failed to fetch pairs\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Filter out excluded currencies */
|
||||
if (cfg->excluded_currency_count > 0) {
|
||||
uint32_t w = 0;
|
||||
for (uint32_t i = 0; i < pairs.count; i++) {
|
||||
const trading_pair_t *p = &pairs.pairs[i];
|
||||
bool skip = false;
|
||||
for (uint32_t j = 0; j < cfg->excluded_currency_count; j++) {
|
||||
if (strcmp(p->base, cfg->excluded_currencies[j]) == 0 ||
|
||||
strcmp(p->quote, cfg->excluded_currencies[j]) == 0) { skip = true; break; }
|
||||
}
|
||||
if (!skip) { if (w != i) pairs.pairs[w] = pairs.pairs[i]; w++; }
|
||||
}
|
||||
log_write("[SYMBOLS] excluded %u/%u pairs (excluded_currencies=%u)\n",
|
||||
pairs.count - w, pairs.count, cfg->excluded_currency_count);
|
||||
pairs.count = w;
|
||||
}
|
||||
log_write("[SYMBOLS] %u pairs after filtering\n", pairs.count);
|
||||
|
||||
/* Build pair hash table: unordered {c1,c2} -> pair index */
|
||||
pair_hash_t ph;
|
||||
ph_init(&ph);
|
||||
for (uint32_t i = 0; i < pairs.count; i++) {
|
||||
ph_insert(&ph, pairs.pairs[i].base, pairs.pairs[i].quote, i);
|
||||
}
|
||||
|
||||
/* Collect unique symbols */
|
||||
uint32_t sym_count = 0;
|
||||
char discovered_symbols[MAX_SYMBOLS][SYMBOL_NAME_LEN];
|
||||
for (uint32_t i = 0; i < pairs.count; i++) {
|
||||
const char *sym = pairs.pairs[i].symbol;
|
||||
bool dup = false;
|
||||
for (uint32_t j = 0; j < sym_count; j++) {
|
||||
if (strcmp(discovered_symbols[j], sym) == 0) { dup = true; break; }
|
||||
}
|
||||
if (!dup && sym_count < MAX_SYMBOLS) {
|
||||
strncpy(discovered_symbols[sym_count++], sym, SYMBOL_NAME_LEN - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enumerate triangles for each hold currency */
|
||||
uint32_t tri_count = 0;
|
||||
triangle_t *tris = calloc(MAX_TRIANGLES, sizeof(triangle_t));
|
||||
if (!tris) { free(pairs.pairs); return -1; }
|
||||
bool kcs = cfg->kcs_discount_active;
|
||||
|
||||
for (uint32_t hi = 0; hi < cfg->hold_currency_count; hi++) {
|
||||
const char *hold = cfg->hold_currencies[hi];
|
||||
|
||||
/* Collect neighbors: all currencies c where pair(hold, c) exists,
|
||||
excluding the hold itself and other hold currencies. */
|
||||
char neighbors[MAX_NEIGHBORS][CURRENCY_NAME_LEN];
|
||||
uint32_t nb_count = 0;
|
||||
for (uint32_t i = 0; i < pairs.count; i++) {
|
||||
const trading_pair_t *p = &pairs.pairs[i];
|
||||
const char *other = NULL;
|
||||
if (strcmp(p->base, hold) == 0) other = p->quote;
|
||||
else if (strcmp(p->quote, hold) == 0) other = p->base;
|
||||
else continue;
|
||||
bool dup = false;
|
||||
for (uint32_t j = 0; j < nb_count; j++) {
|
||||
if (strcmp(neighbors[j], other) == 0) { dup = true; break; }
|
||||
}
|
||||
if (!dup && nb_count < MAX_NEIGHBORS) {
|
||||
strncpy(neighbors[nb_count++], other, CURRENCY_NAME_LEN - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sort neighbors for deterministic ordering */
|
||||
for (uint32_t i = 0; i < nb_count; i++) {
|
||||
for (uint32_t j = 0; j < nb_count - 1 - i; j++) {
|
||||
if (strcmp(neighbors[j], neighbors[j+1]) > 0) {
|
||||
char t[CURRENCY_NAME_LEN];
|
||||
strcpy(t, neighbors[j]);
|
||||
strcpy(neighbors[j], neighbors[j+1]);
|
||||
strcpy(neighbors[j+1], t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* For each unique unordered pair {a,b} of neighbors, check if pair(a,b) exists */
|
||||
for (uint32_t a = 0; a < nb_count; a++) {
|
||||
for (uint32_t b = a + 1; b < nb_count; b++) {
|
||||
uint32_t ab_idx;
|
||||
if (!ph_find(&ph, neighbors[a], neighbors[b], &ab_idx)) continue;
|
||||
/* pair(a,b) exists -> triangle (hold, neighbors[a], neighbors[b]) */
|
||||
|
||||
/*
|
||||
* Two directions:
|
||||
* dir0: hold -> a -> b -> hold (via pairs hold-a, a-b, b-hold)
|
||||
* dir1: hold -> b -> a -> hold (via pairs hold-b, b-a, a-hold)
|
||||
*/
|
||||
for (int dir = 0; dir < 2; dir++) {
|
||||
const char *x = (dir == 0) ? neighbors[a] : neighbors[b];
|
||||
const char *y = (dir == 0) ? neighbors[b] : neighbors[a];
|
||||
|
||||
uint32_t i1, i2, i3;
|
||||
if (!ph_find(&ph, hold, x, &i1)) continue;
|
||||
if (!ph_find(&ph, x, y, &i2)) continue;
|
||||
if (!ph_find(&ph, y, hold, &i3)) continue;
|
||||
|
||||
if (tri_count >= MAX_TRIANGLES) goto done;
|
||||
|
||||
triangle_t *t = &tris[tri_count];
|
||||
t->symbol_idx[0] = (uint16_t)i1;
|
||||
t->symbol_idx[1] = (uint16_t)i2;
|
||||
t->symbol_idx[2] = (uint16_t)i3;
|
||||
|
||||
/*
|
||||
* use_bid[leg] = 1 if we want to receive pair.quote (sell base at bid),
|
||||
* = 0 if we want to receive pair.base (buy base at ask).
|
||||
*
|
||||
* For leg 0 (hold -> x): we hold 'hold' and want to end up with 'x'.
|
||||
* If pair.quote == x: we can sell 'hold' to get 'x' at bid -> use_bid=1
|
||||
* If pair.base == x: we need to buy 'x' paying 'hold' at ask -> use_bid=0
|
||||
*/
|
||||
t->use_bid[0] = (strcmp(pairs.pairs[i1].quote, x) == 0) ? 1 : 0;
|
||||
t->use_bid[1] = (strcmp(pairs.pairs[i2].quote, y) == 0) ? 1 : 0;
|
||||
t->use_bid[2] = (strcmp(pairs.pairs[i3].quote, hold) == 0) ? 1 : 0;
|
||||
t->id = (uint16_t)tri_count;
|
||||
strncpy(t->symbol_names[0], pairs.pairs[i1].symbol, SYMBOL_NAME_LEN - 1);
|
||||
strncpy(t->symbol_names[1], pairs.pairs[i2].symbol, SYMBOL_NAME_LEN - 1);
|
||||
strncpy(t->symbol_names[2], pairs.pairs[i3].symbol, SYMBOL_NAME_LEN - 1);
|
||||
strncpy(t->fee_currency[0], pairs.pairs[i1].fee_currency, CURRENCY_NAME_LEN - 1);
|
||||
strncpy(t->fee_currency[1], pairs.pairs[i2].fee_currency, CURRENCY_NAME_LEN - 1);
|
||||
strncpy(t->fee_currency[2], pairs.pairs[i3].fee_currency, CURRENCY_NAME_LEN - 1);
|
||||
strncpy(t->base, hold, CURRENCY_NAME_LEN - 1);
|
||||
strncpy(t->mid, x, CURRENCY_NAME_LEN - 1);
|
||||
strncpy(t->quote, y, CURRENCY_NAME_LEN - 1);
|
||||
t->fee_factor[0] = 1.0 - fee_rate_for_pair(&pairs.pairs[i1], kcs);
|
||||
t->fee_factor[1] = 1.0 - fee_rate_for_pair(&pairs.pairs[i2], kcs);
|
||||
t->fee_factor[2] = 1.0 - fee_rate_for_pair(&pairs.pairs[i3], kcs);
|
||||
t->base_increment[0] = pairs.pairs[i1].base_increment;
|
||||
t->base_increment[1] = pairs.pairs[i2].base_increment;
|
||||
t->base_increment[2] = pairs.pairs[i3].base_increment;
|
||||
t->quote_increment[0] = pairs.pairs[i1].quote_increment;
|
||||
t->quote_increment[1] = pairs.pairs[i2].quote_increment;
|
||||
t->quote_increment[2] = pairs.pairs[i3].quote_increment;
|
||||
t->base_min_size[0] = pairs.pairs[i1].base_min_size;
|
||||
t->base_min_size[1] = pairs.pairs[i2].base_min_size;
|
||||
t->base_min_size[2] = pairs.pairs[i3].base_min_size;
|
||||
|
||||
tri_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
done:
|
||||
triangles->triangles = tris;
|
||||
triangles->triangle_count = tri_count;
|
||||
|
||||
/* Populate symbol table first (so symbols->count is final) */
|
||||
for (uint32_t i = 0; i < sym_count; i++) {
|
||||
symbol_table_add(symbols, discovered_symbols[i]);
|
||||
}
|
||||
symbol_table_sort(symbols);
|
||||
|
||||
/* Convert triangle symbol_idx from pair index to symbol table index */
|
||||
uint32_t convert_miss = 0;
|
||||
for (uint32_t i = 0; i < tri_count; i++) {
|
||||
for (int leg = 0; leg < 3; leg++) {
|
||||
uint32_t pair_idx = tris[i].symbol_idx[leg];
|
||||
int16_t sym_idx = symbol_table_lookup(symbols, pairs.pairs[pair_idx].symbol);
|
||||
tris[i].symbol_idx[leg] = (uint16_t)(sym_idx >= 0 ? sym_idx : 0);
|
||||
if (sym_idx < 0) convert_miss++;
|
||||
}
|
||||
}
|
||||
log_write("[SYMBOLS] converted %u triangle legs, %u lookup failures\n",
|
||||
3 * tri_count, convert_miss);
|
||||
|
||||
/* Allocate tri_index based on final symbol count */
|
||||
triangles->tri_index = calloc(symbols->count, sizeof(tri_index_entry_t));
|
||||
|
||||
/*
|
||||
* Build triangle flat index by symbol.
|
||||
* Phase 1: count triangles per symbol (all 3 legs)
|
||||
* Phase 2: compute offsets (prefix sum)
|
||||
* Phase 3: fill flat index array (tri_flat)
|
||||
*/
|
||||
for (uint32_t i = 0; i < tri_count; i++) {
|
||||
for (int leg = 0; leg < 3; leg++) {
|
||||
uint16_t si = tris[i].symbol_idx[leg];
|
||||
if (si < symbols->count) triangles->tri_index[si].count++;
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t syms_with_tri = 0;
|
||||
for (uint32_t s = 0; s < symbols->count; s++) {
|
||||
if (triangles->tri_index[s].count > 0) syms_with_tri++;
|
||||
}
|
||||
log_write("[SYMBOLS] %u/%u symbols have triangles (total_refs=%u)\n",
|
||||
syms_with_tri, symbols->count, 3 * tri_count);
|
||||
uint32_t off = 0;
|
||||
uint32_t total_refs = 0;
|
||||
for (uint32_t s = 0; s < symbols->count; s++) {
|
||||
triangles->tri_index[s].offset = off;
|
||||
off += triangles->tri_index[s].count;
|
||||
total_refs += triangles->tri_index[s].count;
|
||||
triangles->tri_index[s].count = 0; // reset for phase 3
|
||||
}
|
||||
uint32_t *tri_flat = calloc(total_refs, sizeof(uint32_t));
|
||||
triangles->tri_flat = tri_flat;
|
||||
for (uint32_t i = 0; i < tri_count; i++) {
|
||||
for (int leg = 0; leg < 3; leg++) {
|
||||
uint16_t si = tris[i].symbol_idx[leg];
|
||||
if (si < symbols->count) {
|
||||
tri_flat[triangles->tri_index[si].offset + triangles->tri_index[si].count++] = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log_write("[SYMBOLS] %u symbols, %u triangles\n", symbols->count, tri_count);
|
||||
free(pairs.pairs);
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
#ifndef FUSED_SYMBOLS_API_H
|
||||
#define FUSED_SYMBOLS_API_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "hash.h"
|
||||
#include "config.h"
|
||||
#include "triangle.h"
|
||||
|
||||
/* Describes one trading pair as returned by the KuCoin REST API */
|
||||
typedef struct {
|
||||
char symbol[SYMBOL_NAME_LEN]; /* trading pair symbol e.g. "BTC-USDT" */
|
||||
char base[CURRENCY_NAME_LEN]; /* base currency code */
|
||||
char quote[CURRENCY_NAME_LEN]; /* quote currency code */
|
||||
char fee_currency[CURRENCY_NAME_LEN]; /* currency used for trading fee */
|
||||
uint8_t fee_category; /* fee category identifier */
|
||||
double taker_fee_coeff; /* taker fee coefficient */
|
||||
double maker_fee_coeff; /* maker fee coefficient */
|
||||
double base_increment; /* base lot size step */
|
||||
double quote_increment; /* quote lot size step */
|
||||
double base_min_size; /* minimum base order size */
|
||||
} trading_pair_t;
|
||||
|
||||
/* Growable list of trading pairs */
|
||||
typedef struct {
|
||||
trading_pair_t *pairs; /* dynamic array of trading pairs */
|
||||
uint32_t count; /* number of pairs currently stored */
|
||||
uint32_t capacity; /* allocated capacity */
|
||||
} pair_list_t;
|
||||
|
||||
/* Fetch all trading pairs from the KuCoin REST API */
|
||||
int fetch_trading_pairs(pair_list_t *out);
|
||||
|
||||
/* Full symbol discovery: fetch pairs, enumerate triangles, populate tables */
|
||||
int discover_symbols(symbol_table_t *symbols, triangle_set_t *triangles,
|
||||
const config_t *cfg, const fee_entry_t *fees, uint32_t fee_count);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* triangle.c - Triangle data structure management and fee table parsing
|
||||
*
|
||||
* triangle_set_init is a stub; actual triangle enumeration happens in
|
||||
* symbols_api.c:discover_symbols. This file provides fee parsing from
|
||||
* KuCoin's /api/v1/base-fee response (both object and array formats).
|
||||
*/
|
||||
|
||||
#include "triangle.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "cJSON.h"
|
||||
|
||||
int triangle_set_init(triangle_set_t *set, const symbol_table_t *symbols,
|
||||
const config_t *cfg, const fee_entry_t *fees,
|
||||
uint32_t fee_count) {
|
||||
/* Triangle enumeration is now done in discover_symbols (symbols_api.c) */
|
||||
(void)set; (void)symbols; (void)cfg; (void)fees; (void)fee_count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void triangle_set_free(triangle_set_t *set) {
|
||||
free(set->triangles);
|
||||
free(set->tri_index);
|
||||
free(set->tri_flat);
|
||||
set->triangles = NULL;
|
||||
set->tri_index = NULL;
|
||||
set->tri_flat = NULL;
|
||||
set->triangle_count = 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Parse KuCoin /api/v1/base-fee JSON response.
|
||||
* Supports two formats:
|
||||
* - Object: {"takerFeeRate": 0.001, ...}
|
||||
* - Array: [{"currency": "BTC", "takerFeeRate": 0.001}, ...]
|
||||
* Stores result as fee_entry_t array (single "ALL" entry for object format).
|
||||
*/
|
||||
int load_fee_table(const char *json, fee_entry_t **out_fees, uint32_t *out_count) {
|
||||
cJSON *root = cJSON_Parse(json);
|
||||
if (!root) return -1;
|
||||
|
||||
cJSON *code = cJSON_GetObjectItem(root, "code");
|
||||
if (!cJSON_IsString(code) || strcmp(code->valuestring, "200000") != 0) {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *data = cJSON_GetObjectItem(root, "data");
|
||||
if (!data) {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_count = 0;
|
||||
|
||||
if (cJSON_IsObject(data)) {
|
||||
// Single global fee rate
|
||||
*out_fees = calloc(1, sizeof(fee_entry_t));
|
||||
if (!*out_fees) { cJSON_Delete(root); return -1; }
|
||||
fee_entry_t *fee = *out_fees;
|
||||
strncpy(fee->currency, "ALL", CURRENCY_NAME_LEN - 1);
|
||||
cJSON *taker = cJSON_GetObjectItem(data, "takerFeeRate");
|
||||
cJSON *maker = cJSON_GetObjectItem(data, "makerFeeRate");
|
||||
if (cJSON_IsNumber(taker)) fee->taker_fee = taker->valuedouble;
|
||||
if (cJSON_IsNumber(maker)) fee->maker_fee = maker->valuedouble;
|
||||
*out_count = 1;
|
||||
} else if (cJSON_IsArray(data)) {
|
||||
// Per-currency fee rates array
|
||||
int arr_size = cJSON_GetArraySize(data);
|
||||
*out_fees = calloc(arr_size, sizeof(fee_entry_t));
|
||||
if (!*out_fees) { cJSON_Delete(root); return -1; }
|
||||
cJSON *item;
|
||||
int idx = 0;
|
||||
cJSON_ArrayForEach(item, data) {
|
||||
if (!cJSON_IsObject(item)) continue;
|
||||
fee_entry_t *fee = &((*out_fees)[idx]);
|
||||
cJSON *cur = cJSON_GetObjectItem(item, "currency");
|
||||
cJSON *taker = cJSON_GetObjectItem(item, "takerFeeRate");
|
||||
cJSON *maker = cJSON_GetObjectItem(item, "makerFeeRate");
|
||||
if (cJSON_IsString(cur)) strncpy(fee->currency, cur->valuestring, CURRENCY_NAME_LEN - 1);
|
||||
if (cJSON_IsNumber(taker)) fee->taker_fee = taker->valuedouble;
|
||||
if (cJSON_IsNumber(maker)) fee->maker_fee = maker->valuedouble;
|
||||
idx++;
|
||||
}
|
||||
*out_count = idx;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void free_fee_table(fee_entry_t *fees) {
|
||||
free(fees);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
#ifndef FUSED_TRIANGLE_H
|
||||
#define FUSED_TRIANGLE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "book.h"
|
||||
#include "hash.h"
|
||||
#include "config.h"
|
||||
|
||||
#define MAX_TRIANGLES 16384
|
||||
#define MAX_CURRENCIES 512
|
||||
|
||||
/* Describes one triangular arbitrage path of three trading pairs */
|
||||
typedef struct {
|
||||
uint16_t symbol_idx[3]; /* indices into the symbol table for each leg */
|
||||
uint8_t use_bid[3]; /* 1=use bid price, 0=use ask price for this leg */
|
||||
double fee_factor[3]; /* fee multiplier (1 - fee_rate) for each leg */
|
||||
double base_increment[3]; /* base asset lot step for each leg */
|
||||
double quote_increment[3]; /* quote asset lot step for each leg */
|
||||
double base_min_size[3]; /* min base order size for each leg */
|
||||
uint16_t id; /* unique triangle ID */
|
||||
char symbol_names[3][SYMBOL_NAME_LEN]; /* trading pair names for each leg */
|
||||
char fee_currency[3][CURRENCY_NAME_LEN]; /* fee currency per leg */
|
||||
char base[CURRENCY_NAME_LEN]; /* base currency of the triangle */
|
||||
char mid[CURRENCY_NAME_LEN]; /* intermediate currency */
|
||||
char quote[CURRENCY_NAME_LEN]; /* quote currency of the triangle */
|
||||
} triangle_t;
|
||||
|
||||
/* Index entry into the per-currency triangle lookup table */
|
||||
typedef struct {
|
||||
uint32_t offset; /* start index in the flat triangle array */
|
||||
uint32_t count; /* number of triangles for this currency */
|
||||
} tri_index_entry_t;
|
||||
|
||||
/* Complete set of enumerated triangles with fast per-currency indexing */
|
||||
typedef struct {
|
||||
triangle_t *triangles; /* contiguous array of all triangles */
|
||||
uint32_t triangle_count; /* total number of triangles */
|
||||
tri_index_entry_t *tri_index; /* per-currency index into tri_flat */
|
||||
uint32_t *tri_flat; /* flat array mapping currency->triangle indices */
|
||||
} triangle_set_t;
|
||||
|
||||
/* Fee rate entry for a specific currency (from fee table) */
|
||||
typedef struct {
|
||||
char currency[CURRENCY_NAME_LEN]; /* currency name */
|
||||
double taker_fee; /* taker fee rate for this currency */
|
||||
double maker_fee; /* maker fee rate for this currency */
|
||||
} fee_entry_t;
|
||||
|
||||
/* Initialise triangle set: enumerate all triangles from the symbol table */
|
||||
int triangle_set_init(triangle_set_t *set, const symbol_table_t *symbols,
|
||||
const config_t *cfg, const fee_entry_t *fees,
|
||||
uint32_t fee_count);
|
||||
/* Free all memory owned by a triangle set */
|
||||
void triangle_set_free(triangle_set_t *set);
|
||||
|
||||
/* Parse fee table JSON into an array of fee_entry_t */
|
||||
int load_fee_table(const char *json, fee_entry_t **out_fees, uint32_t *out_count);
|
||||
/* Free a fee table array */
|
||||
void free_fee_table(fee_entry_t *fees);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,785 @@
|
|||
/*
|
||||
* ws_client.c - KuCoin WebSocket client for level2 depth (top 5) book updates
|
||||
*
|
||||
* Handles: token fetch via REST /api/v1/bullet-public, TLS connections,
|
||||
* RFC 6455 frame read/write/mask, subscription management,
|
||||
* book update JSON parsing, and per-connection epoll-driven reads.
|
||||
* Each WS connection handles up to 400 symbol subscriptions.
|
||||
*/
|
||||
|
||||
#include "log.h"
|
||||
#include "ws_client.h"
|
||||
#include "http_client.h"
|
||||
#include "cJSON.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <inttypes.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <poll.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <openssl/bio.h>
|
||||
#include <openssl/ssl.h>
|
||||
#include <openssl/err.h>
|
||||
|
||||
static uint64_t now_ms_impl(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_MONOTONIC, &ts);
|
||||
return (uint64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
uint64_t ws_client_now_ms(void) {
|
||||
return now_ms_impl();
|
||||
}
|
||||
|
||||
static uint64_t now_realtime_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
return (uint64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
static void ws_connection_reset(ws_connection_t *conn) {
|
||||
conn->state = WS_STATE_DISCONNECTED;
|
||||
conn->read_pos = 0;
|
||||
conn->read_len = 0;
|
||||
conn->frame_payload_len = 0;
|
||||
conn->frame_finished = false;
|
||||
conn->frame_opcode = 0;
|
||||
if (conn->ssl) {
|
||||
SSL_free(conn->ssl);
|
||||
conn->ssl = NULL;
|
||||
}
|
||||
if (conn->bio_mem) {
|
||||
BIO_free(conn->bio_mem);
|
||||
conn->bio_mem = NULL;
|
||||
}
|
||||
if (conn->bio_ssl) {
|
||||
BIO_free(conn->bio_ssl);
|
||||
conn->bio_ssl = NULL;
|
||||
}
|
||||
if (conn->bio_socket) {
|
||||
BIO_free(conn->bio_socket);
|
||||
conn->bio_socket = NULL;
|
||||
}
|
||||
if (conn->fd >= 0) {
|
||||
close(conn->fd);
|
||||
conn->fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
static SSL_CTX *create_ssl_ctx(void) {
|
||||
SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
|
||||
if (!ctx) {
|
||||
log_write("[WS] SSL_CTX_new failed: %s\n",
|
||||
ERR_error_string(ERR_get_error(), NULL));
|
||||
return NULL;
|
||||
}
|
||||
SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, NULL);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
static int resolve_and_connect(const char *host, int port) {
|
||||
struct addrinfo hints = {0}, *res = NULL;
|
||||
hints.ai_family = AF_UNSPEC;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
|
||||
char port_str[8];
|
||||
snprintf(port_str, sizeof(port_str), "%d", port);
|
||||
|
||||
int ret = getaddrinfo(host, port_str, &hints, &res);
|
||||
if (ret != 0) {
|
||||
log_write("[WS] getaddrinfo failed for %s:%d: %s\n", host, port, gai_strerror(ret));
|
||||
return -1;
|
||||
}
|
||||
|
||||
int fd = -1;
|
||||
for (struct addrinfo *rp = res; rp; rp = rp->ai_next) {
|
||||
fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
|
||||
if (fd < 0) continue;
|
||||
|
||||
if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) break;
|
||||
close(fd);
|
||||
fd = -1;
|
||||
}
|
||||
freeaddrinfo(res);
|
||||
return fd;
|
||||
}
|
||||
|
||||
static int setup_tls(ws_connection_t *conn) {
|
||||
conn->ssl = SSL_new(conn->ctx);
|
||||
if (!conn->ssl) {
|
||||
log_write("[WS] SSL_new failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (SSL_set_tlsext_host_name(conn->ssl, conn->host) != 1) {
|
||||
log_write("[WS] SSL_set_tlsext_host_name failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
SSL_set_fd(conn->ssl, conn->fd);
|
||||
log_write("[WS] Connecting SSL to %s:%d (fd=%d)\n", conn->host, conn->port, conn->fd);
|
||||
|
||||
int r = SSL_connect(conn->ssl);
|
||||
if (r != 1) {
|
||||
int err = SSL_get_error(conn->ssl, r);
|
||||
unsigned long err2 = ERR_peek_last_error();
|
||||
log_write("[WS] TLS handshake failed: SSL_error=%d errno=%d r=%d err2=%lu\n", err, errno, r, err2);
|
||||
if (err2) {
|
||||
char err_str[256];
|
||||
ERR_error_string_n(err2, err_str, sizeof(err_str));
|
||||
log_write("[WS] OpenSSL error: %s\n", err_str);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
log_write("[WS] TLS handshake OK, cipher=%s\n", SSL_get_cipher(conn->ssl));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ws_client_init(ws_client_t *client, const config_t *cfg,
|
||||
symbol_table_t *symbols, order_book_t *books,
|
||||
evaluator_t *evaluator) {
|
||||
memset(client, 0, sizeof(*client));
|
||||
client->cfg = cfg;
|
||||
client->symbols = symbols;
|
||||
client->books = books;
|
||||
client->evaluator = evaluator;
|
||||
client->running = true;
|
||||
|
||||
SSL_CTX *shared_ctx = create_ssl_ctx();
|
||||
if (!shared_ctx) {
|
||||
log_write("[WS] Failed to create SSL context\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < WS_MAX_CONNECTIONS; i++) {
|
||||
ws_connection_t *conn = &client->connections[i];
|
||||
conn->fd = -1;
|
||||
conn->ctx = shared_ctx;
|
||||
conn->reconnect_base_delay = cfg->reconnect_base_delay;
|
||||
conn->reconnect_max_delay = cfg->reconnect_max_delay;
|
||||
conn->reconnect_delay = cfg->reconnect_base_delay;
|
||||
strncpy(conn->host, "ws-api-spot.kucoin.com", sizeof(conn->host));
|
||||
conn->port = 443;
|
||||
}
|
||||
client->connection_count = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ws_client_destroy(ws_client_t *client) {
|
||||
client->running = false;
|
||||
SSL_CTX *ctx = NULL;
|
||||
for (uint32_t i = 0; i < client->connection_count; i++) {
|
||||
ws_connection_t *conn = &client->connections[i];
|
||||
if (i == 0 && conn->ctx) ctx = conn->ctx;
|
||||
ws_connection_reset(conn);
|
||||
conn->ctx = NULL;
|
||||
}
|
||||
if (ctx) SSL_CTX_free(ctx);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fetch a WebSocket token and server endpoint from KuCoin's /api/v1/bullet-public.
|
||||
* Stores token, host, ping_interval_ms, ping_timeout_ms in the connection struct.
|
||||
*/
|
||||
int ws_client_fetch_token(ws_connection_t *conn) {
|
||||
const char *body = "";
|
||||
|
||||
int out_len = 0;
|
||||
char *response = https_post("api.kucoin.com", 443, "/api/v1/bullet-public",
|
||||
body, strlen(body), &out_len);
|
||||
if (!response || out_len <= 0) {
|
||||
log_write("[WS] Failed to fetch token\n");
|
||||
free(response);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *root = cJSON_Parse(response);
|
||||
free(response);
|
||||
if (!root) {
|
||||
log_write("[WS] Failed to parse token response\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *data = cJSON_GetObjectItem(root, "data");
|
||||
if (!cJSON_IsObject(data)) {
|
||||
log_write("[WS] No 'data' in token response\n");
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON *token = cJSON_GetObjectItem(data, "token");
|
||||
if (cJSON_IsString(token)) {
|
||||
strncpy(conn->token, token->valuestring, sizeof(conn->token) - 1);
|
||||
}
|
||||
|
||||
cJSON *servers = cJSON_GetObjectItem(data, "instanceServers");
|
||||
if (cJSON_IsArray(servers) && cJSON_GetArraySize(servers) > 0) {
|
||||
cJSON *inst = cJSON_GetArrayItem(servers, 0);
|
||||
cJSON *endpoint = cJSON_GetObjectItem(inst, "endpoint");
|
||||
cJSON *pingInterval = cJSON_GetObjectItem(inst, "pingInterval");
|
||||
cJSON *pingTimeout = cJSON_GetObjectItem(inst, "pingTimeout");
|
||||
|
||||
if (cJSON_IsString(endpoint)) {
|
||||
const char *ep = endpoint->valuestring;
|
||||
const char *slash_pair = strstr(ep, "//");
|
||||
char host_start[256] = {0};
|
||||
if (slash_pair) {
|
||||
strncpy(host_start, slash_pair + 2, sizeof(host_start) - 1);
|
||||
} else {
|
||||
strncpy(host_start, ep, sizeof(host_start) - 1);
|
||||
}
|
||||
char *slash = strchr(host_start, '/');
|
||||
if (slash) *slash = '\0';
|
||||
strncpy(conn->host, host_start, sizeof(conn->host) - 1);
|
||||
}
|
||||
|
||||
if (cJSON_IsNumber(pingInterval)) conn->ping_interval_ms = (uint32_t)pingInterval->valuedouble;
|
||||
if (cJSON_IsNumber(pingTimeout)) conn->ping_timeout_ms = (uint32_t)pingTimeout->valuedouble;
|
||||
}
|
||||
|
||||
if (!conn->token[0]) {
|
||||
log_write("[WS] Empty token received\n");
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!conn->ping_interval_ms) conn->ping_interval_ms = 18000;
|
||||
if (!conn->ping_timeout_ms) conn->ping_timeout_ms = 10000;
|
||||
|
||||
log_write("[WS] Token fetched, ping_interval=%u ms, host='%s'\n", conn->ping_interval_ms, conn->host);
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ws_client_connect(ws_client_t *client, uint32_t conn_idx) {
|
||||
if (conn_idx >= WS_MAX_CONNECTIONS) return -1;
|
||||
ws_connection_t *conn = &client->connections[conn_idx];
|
||||
|
||||
if (conn->state == WS_STATE_CONNECTED) return 0;
|
||||
|
||||
ws_connection_reset(conn);
|
||||
|
||||
if (!conn->ctx) {
|
||||
conn->ctx = create_ssl_ctx();
|
||||
if (!conn->ctx) return -1;
|
||||
}
|
||||
|
||||
if (!conn->token[0]) {
|
||||
conn->state = WS_STATE_GETTING_TOKEN;
|
||||
if (ws_client_fetch_token(conn) != 0) return -1;
|
||||
}
|
||||
|
||||
snprintf(conn->connect_id, sizeof(conn->connect_id), "fused-%" PRIu32, conn_idx + 1);
|
||||
|
||||
conn->fd = resolve_and_connect(conn->host, conn->port);
|
||||
if (conn->fd < 0) {
|
||||
log_write("[WS] Connection failed for %s:%d\n", conn->host, conn->port);
|
||||
return -1;
|
||||
}
|
||||
|
||||
{
|
||||
int opt = 1;
|
||||
setsockopt(conn->fd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));
|
||||
int rcvbuf = 256 * 1024;
|
||||
setsockopt(conn->fd, SOL_SOCKET, SO_RCVBUF, &rcvbuf, sizeof(rcvbuf));
|
||||
}
|
||||
|
||||
if (setup_tls(conn) != 0) {
|
||||
log_write("[WS] TLS setup failed\n");
|
||||
close(conn->fd);
|
||||
conn->fd = -1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
char url[512];
|
||||
snprintf(url, sizeof(url),
|
||||
"GET /?token=%s&connectId=%s HTTP/1.1\r\n"
|
||||
"Host: %s\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"\r\n",
|
||||
conn->token, conn->connect_id, conn->host);
|
||||
|
||||
int r = ws_client_write(conn, url, strlen(url));
|
||||
if (r < 0) {
|
||||
log_write("[WS] Failed to send upgrade request\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
conn->state = WS_STATE_CONNECTING;
|
||||
conn->last_activity_ms = now_ms_impl();
|
||||
|
||||
// Read HTTP 101 Switching Protocols response
|
||||
char resp_buf[1024];
|
||||
int resp_len = SSL_read(conn->ssl, resp_buf, sizeof(resp_buf) - 1);
|
||||
if (resp_len <= 0) {
|
||||
int err = SSL_get_error(conn->ssl, resp_len);
|
||||
log_write("[WS] No response to upgrade request: SSL_error=%d\n", err);
|
||||
return -1;
|
||||
}
|
||||
resp_buf[resp_len] = '\0';
|
||||
|
||||
if (strstr(resp_buf, "101 Switching Protocols") == NULL) {
|
||||
log_write("[WS] Upgrade failed:\n%s\n", resp_buf);
|
||||
return -1;
|
||||
}
|
||||
|
||||
conn->state = WS_STATE_CONNECTED;
|
||||
log_write("[WS] Connected to %s:%d\n", conn->host, conn->port);
|
||||
|
||||
if (conn->symbol_count > 0) {
|
||||
ws_client_subscribe(client, conn_idx, conn->symbol_indices, conn->symbol_count);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
void ws_client_disconnect(ws_client_t *client, uint32_t conn_idx) {
|
||||
if (conn_idx >= WS_MAX_CONNECTIONS) return;
|
||||
ws_connection_t *conn = &client->connections[conn_idx];
|
||||
ws_connection_reset(conn);
|
||||
}
|
||||
|
||||
int ws_client_write(ws_connection_t *conn, const void *data, size_t len) {
|
||||
if (conn->ssl == NULL) return -1;
|
||||
int written = 0;
|
||||
while ((size_t)written < len) {
|
||||
int r = SSL_write(conn->ssl, (const char *)data + written, len - written);
|
||||
if (r <= 0) {
|
||||
int err = SSL_get_error(conn->ssl, r);
|
||||
log_write("[WS] SSL_write error: %d\n", err);
|
||||
return -1;
|
||||
}
|
||||
written += r;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/*
|
||||
* Build and send an RFC 6455 WebSocket frame with masking.
|
||||
* Client frames MUST be masked (bit 0x80 in byte 1).
|
||||
* Supports payload lengths < 126 (inline), < 65536 (16-bit ext), and >= 65536 (64-bit ext).
|
||||
* Masking key derived from monotonic clock to avoid predictable patterns.
|
||||
*/
|
||||
static int ws_send_frame(ws_connection_t *conn, uint8_t opcode,
|
||||
const uint8_t *payload, size_t payload_len) {
|
||||
uint8_t header[14];
|
||||
int hdr_len = 2;
|
||||
|
||||
header[0] = 0x80 | opcode;
|
||||
if (payload_len < 126) {
|
||||
header[1] = 0x80 | (uint8_t)payload_len;
|
||||
} else if (payload_len < 65536) {
|
||||
header[1] = 0x80 | 126;
|
||||
header[2] = (uint8_t)(payload_len >> 8);
|
||||
header[3] = (uint8_t)(payload_len & 0xFF);
|
||||
hdr_len = 4;
|
||||
} else {
|
||||
header[1] = 0x80 | 127;
|
||||
uint64_t len64 = (uint64_t)payload_len;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
header[2 + i] = (uint8_t)(len64 >> ((7 - i) * 8));
|
||||
}
|
||||
hdr_len = 10;
|
||||
}
|
||||
|
||||
uint8_t mask[4];
|
||||
mask[0] = (uint8_t)(now_ms_impl() & 0xFF);
|
||||
mask[1] = (uint8_t)((now_ms_impl() >> 8) & 0xFF);
|
||||
mask[2] = (uint8_t)((now_ms_impl() >> 16) & 0xFF);
|
||||
mask[3] = (uint8_t)((now_ms_impl() >> 24) & 0xFF);
|
||||
memcpy(header + hdr_len, mask, 4);
|
||||
hdr_len += 4;
|
||||
|
||||
int total = 0;
|
||||
int r = ws_client_write(conn, header, (size_t)hdr_len);
|
||||
if (r < 0) return r;
|
||||
total += r;
|
||||
|
||||
if (payload_len > 0) {
|
||||
// XOR payload with mask before sending (RFC 6455 §5.3)
|
||||
uint8_t buf[4096];
|
||||
size_t off = 0;
|
||||
while (off < payload_len) {
|
||||
size_t chunk = payload_len - off;
|
||||
if (chunk > sizeof(buf)) chunk = sizeof(buf);
|
||||
for (size_t i = 0; i < chunk; i++) {
|
||||
buf[i] = payload[off + i] ^ mask[(off + i) % 4];
|
||||
}
|
||||
r = ws_client_write(conn, buf, chunk);
|
||||
if (r < 0) return r;
|
||||
total += r;
|
||||
off += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
conn->last_activity_ms = now_ms_impl();
|
||||
return total;
|
||||
}
|
||||
|
||||
static int ws_send_text(ws_connection_t *conn, const char *text) {
|
||||
return ws_send_frame(conn, 0x1, (const uint8_t *)text, strlen(text));
|
||||
}
|
||||
|
||||
int ws_client_send_ping(ws_connection_t *conn) {
|
||||
return ws_send_frame(conn, 0x9, NULL, 0);
|
||||
}
|
||||
|
||||
int ws_client_subscribe(ws_client_t *client, uint32_t conn_idx,
|
||||
const uint16_t *symbol_indices, uint32_t count) {
|
||||
if (conn_idx >= WS_MAX_CONNECTIONS) return -1;
|
||||
ws_connection_t *conn = &client->connections[conn_idx];
|
||||
|
||||
// Batch subscriptions: KuCoin accepts up to ~100 symbols per subscribe message
|
||||
for (uint32_t batch = 0; batch < count; batch += 100) {
|
||||
uint32_t batch_end = batch + 100;
|
||||
if (batch_end > count) batch_end = count;
|
||||
uint32_t batch_count = batch_end - batch;
|
||||
|
||||
// Build topic: /spotMarket/level2Depth5:S1,S2,...
|
||||
char sym_list[4096] = {0};
|
||||
for (uint32_t i = batch; i < batch_end; i++) {
|
||||
char sep = (i == batch) ? ':' : ',';
|
||||
char part[SYMBOL_NAME_LEN + 1];
|
||||
strncpy(part, client->symbols->entries[symbol_indices[i]].name, SYMBOL_NAME_LEN);
|
||||
part[SYMBOL_NAME_LEN] = '\0';
|
||||
char tmp[SYMBOL_NAME_LEN + 2];
|
||||
snprintf(tmp, sizeof(tmp), "%c%s", sep, part);
|
||||
strncat(sym_list, tmp, sizeof(sym_list) - 1);
|
||||
}
|
||||
|
||||
char msg[4600];
|
||||
snprintf(msg, sizeof(msg),
|
||||
"{\"type\":\"subscribe\",\"topic\":\"/spotMarket/level2Depth5%s\",\"response\":true}",
|
||||
sym_list);
|
||||
|
||||
int r = ws_send_text(conn, msg);
|
||||
if (r < 0) {
|
||||
log_write("[WS] Subscribe failed for batch %u\n", batch);
|
||||
return -1;
|
||||
}
|
||||
log_write("[WS] Subscribed to %u symbols (batch %u-%u)\n",
|
||||
batch_count, batch, batch_end - 1);
|
||||
}
|
||||
|
||||
conn->symbol_count = count;
|
||||
for (uint32_t i = 0; i < count && i < MAX_SYMBOLS; i++) {
|
||||
conn->symbol_indices[i] = symbol_indices[i];
|
||||
}
|
||||
conn->state = WS_STATE_CONNECTED;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int ws_client_unsubscribe(ws_client_t *client, uint32_t conn_idx,
|
||||
const uint16_t *symbol_indices, uint32_t count) {
|
||||
if (conn_idx >= WS_MAX_CONNECTIONS) return -1;
|
||||
ws_connection_t *conn = &client->connections[conn_idx];
|
||||
|
||||
char topic[4096];
|
||||
topic[0] = '\0';
|
||||
for (uint32_t i = 0; i < count; i++) {
|
||||
char sep = (i == 0) ? ':' : ',';
|
||||
char part[SYMBOL_NAME_LEN + 1];
|
||||
strncpy(part, client->symbols->entries[symbol_indices[i]].name, SYMBOL_NAME_LEN);
|
||||
part[SYMBOL_NAME_LEN] = '\0';
|
||||
char tmp[SYMBOL_NAME_LEN + 2];
|
||||
snprintf(tmp, sizeof(tmp), "%c%s", sep, part);
|
||||
strncat(topic, tmp, sizeof(topic) - 1);
|
||||
}
|
||||
char full_topic[4096];
|
||||
snprintf(full_topic, sizeof(full_topic), "/spotMarket/level2Depth5:%s", topic);
|
||||
|
||||
char msg[4600];
|
||||
snprintf(msg, sizeof(msg),
|
||||
"{\"type\":\"unsubscribe\",\"topic\":\"%s\",\"response\":true}", full_topic);
|
||||
|
||||
return ws_send_text(conn, msg);
|
||||
}
|
||||
|
||||
/*
|
||||
* Parse a KuCoin level2Depth5 book update JSON and update the in-memory order book.
|
||||
* Topic format: /spotMarket/level2Depth5:{symbol}
|
||||
* Extracts timestamp, sequence, bids array, asks array (each [price, size]).
|
||||
* Falls back through timestamp fields: timestamp -> sequence -> time.
|
||||
* Converts both numeric and string-encoded price/size values.
|
||||
* After updating the book, triggers evaluate_symbol for the updated symbol.
|
||||
*/
|
||||
static void parse_book_update(ws_connection_t *conn, cJSON *root,
|
||||
ws_client_t *client, uint32_t conn_idx) {
|
||||
cJSON *type = cJSON_GetObjectItem(root, "type");
|
||||
if (type && cJSON_IsString(type) && strcmp(type->valuestring, "message") == 0) {
|
||||
cJSON *topic = cJSON_GetObjectItem(root, "topic");
|
||||
cJSON *data = cJSON_GetObjectItem(root, "data");
|
||||
if (!cJSON_IsString(topic) || !cJSON_IsObject(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract symbol from topic: /spotMarket/level2Depth5:{symbol}
|
||||
const char *topic_str = topic->valuestring;
|
||||
const char *sym_start = strstr(topic_str, "level2Depth5:");
|
||||
if (!sym_start) { return; }
|
||||
sym_start += 13;
|
||||
char symbol[SYMBOL_NAME_LEN] = {0};
|
||||
strncpy(symbol, sym_start, SYMBOL_NAME_LEN - 1);
|
||||
char *comma = strchr(symbol, ',');
|
||||
if (comma) *comma = '\0';
|
||||
|
||||
int16_t sym_idx = symbol_table_lookup(client->symbols, symbol);
|
||||
if (sym_idx < 0) { return; }
|
||||
|
||||
order_book_t *book = &client->books[sym_idx];
|
||||
|
||||
// Try multiple timestamp fields (KuCoin version-dependent)
|
||||
cJSON *ts_val = cJSON_GetObjectItem(data, "timestamp");
|
||||
cJSON *seq_val = cJSON_GetObjectItem(data, "sequence");
|
||||
cJSON *seqNum_val = cJSON_GetObjectItem(data, "sequenceNum");
|
||||
if (cJSON_IsNumber(ts_val)) book->ts_ms = (int64_t)ts_val->valuedouble;
|
||||
if (!book->ts_ms && cJSON_IsNumber(seq_val)) book->ts_ms = (int64_t)seq_val->valuedouble;
|
||||
if (!book->ts_ms) {
|
||||
cJSON *time_val = cJSON_GetObjectItem(data, "time");
|
||||
if (cJSON_IsNumber(time_val)) book->ts_ms = (int64_t)time_val->valuedouble;
|
||||
}
|
||||
if (cJSON_IsNumber(seq_val)) book->sequence = (int64_t)seq_val->valuedouble;
|
||||
else if (cJSON_IsNumber(seqNum_val)) book->sequence = (int64_t)seqNum_val->valuedouble;
|
||||
|
||||
cJSON *bids = cJSON_GetObjectItem(data, "bids");
|
||||
cJSON *asks = cJSON_GetObjectItem(data, "asks");
|
||||
|
||||
if (cJSON_IsArray(bids)) {
|
||||
int count = 0;
|
||||
cJSON *bid;
|
||||
cJSON_ArrayForEach(bid, bids) {
|
||||
if (count >= MAX_BOOK_LEVELS) break;
|
||||
if (cJSON_IsArray(bid) && cJSON_GetArraySize(bid) >= 2) {
|
||||
cJSON *price = cJSON_GetArrayItem(bid, 0);
|
||||
cJSON *size = cJSON_GetArrayItem(bid, 1);
|
||||
double p = cJSON_IsNumber(price) ? price->valuedouble :
|
||||
cJSON_IsString(price) ? atof(price->valuestring) : 0.0;
|
||||
double s = cJSON_IsNumber(size) ? size->valuedouble :
|
||||
cJSON_IsString(size) ? atof(size->valuestring) : 0.0;
|
||||
if (p > 0 && s > 0) {
|
||||
book->bids[count][0] = p;
|
||||
book->bids[count][1] = s;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
book->bid_count = (uint8_t)count;
|
||||
}
|
||||
|
||||
if (cJSON_IsArray(asks)) {
|
||||
int count = 0;
|
||||
cJSON *ask;
|
||||
cJSON_ArrayForEach(ask, asks) {
|
||||
if (count >= MAX_BOOK_LEVELS) break;
|
||||
if (cJSON_IsArray(ask) && cJSON_GetArraySize(ask) >= 2) {
|
||||
cJSON *price = cJSON_GetArrayItem(ask, 0);
|
||||
cJSON *size = cJSON_GetArrayItem(ask, 1);
|
||||
double p = cJSON_IsNumber(price) ? price->valuedouble :
|
||||
cJSON_IsString(price) ? atof(price->valuestring) : 0.0;
|
||||
double s = cJSON_IsNumber(size) ? size->valuedouble :
|
||||
cJSON_IsString(size) ? atof(size->valuestring) : 0.0;
|
||||
if (p > 0 && s > 0) {
|
||||
book->asks[count][0] = p;
|
||||
book->asks[count][1] = s;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
book->ask_count = (uint8_t)count;
|
||||
}
|
||||
|
||||
book->symbol_idx = (uint16_t)sym_idx;
|
||||
strncpy(book->symbol, symbol, SYMBOL_NAME_LEN - 1);
|
||||
|
||||
static uint64_t book_count = 0;
|
||||
book_count++;
|
||||
|
||||
int64_t t_arrive = (int64_t)now_realtime_ms();
|
||||
evaluate_symbol(client->evaluator, (uint16_t)sym_idx, conn->t_sock_arrive_ms, t_arrive);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Process a single complete WebSocket frame after it has been fully read.
|
||||
* Handles: ping (0x9) -> pong (0xA), close (0x8), text (0x1).
|
||||
* Text frames are JSON-parsed and dispatched: welcome, ack, message (-> book update), error.
|
||||
*/
|
||||
void ws_client_process_frame(ws_client_t *client, uint32_t conn_idx) {
|
||||
if (conn_idx >= WS_MAX_CONNECTIONS) return;
|
||||
ws_connection_t *conn = &client->connections[conn_idx];
|
||||
|
||||
uint8_t *payload = conn->frame_buf;
|
||||
size_t payload_len = conn->frame_payload_len;
|
||||
uint8_t opcode = conn->frame_opcode;
|
||||
if (payload_len == 0 && opcode != 0x8 && opcode != 0xA) return;
|
||||
|
||||
if (opcode == 0x9) {
|
||||
ws_send_frame(conn, 0xA, payload, payload_len);
|
||||
conn->last_activity_ms = now_ms_impl();
|
||||
conn->frame_payload_len = 0;
|
||||
conn->frame_finished = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opcode == 0x8) {
|
||||
if (payload_len >= 2) {
|
||||
uint16_t code = ((uint16_t)payload[0] << 8) | payload[1];
|
||||
char reason[128] = {0};
|
||||
if (payload_len > 2) {
|
||||
uint32_t rlen = payload_len - 2;
|
||||
if (rlen > sizeof(reason) - 1) rlen = sizeof(reason) - 1;
|
||||
memcpy(reason, payload + 2, rlen);
|
||||
}
|
||||
log_write("[WS] Close frame on conn %u: code=%u reason='%s'\n", conn_idx, code, reason);
|
||||
} else {
|
||||
log_write("[WS] Close frame received on conn %u\n", conn_idx);
|
||||
}
|
||||
conn->state = WS_STATE_DISCONNECTED;
|
||||
conn->frame_payload_len = 0;
|
||||
conn->frame_finished = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opcode == 0xA) {
|
||||
conn->frame_payload_len = 0;
|
||||
conn->frame_finished = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opcode == 0x1) {
|
||||
cJSON *msg_root = cJSON_ParseWithLength((const char *)payload, payload_len);
|
||||
if (!msg_root) {
|
||||
static int parse_fails = 0;
|
||||
if (++parse_fails <= 3) log_write("[WS] JSON parse fail: %.*s\n", (int)(payload_len > 100 ? 100 : payload_len), (const char *)payload);
|
||||
conn->frame_payload_len = 0;
|
||||
conn->frame_finished = false;
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON *msg_type = cJSON_GetObjectItem(msg_root, "type");
|
||||
if (cJSON_IsString(msg_type)) {
|
||||
if (strcmp(msg_type->valuestring, "welcome") == 0) {
|
||||
log_write("[WS] Welcome message received\n");
|
||||
} else if (strcmp(msg_type->valuestring, "ack") == 0) {
|
||||
static int ack_count = 0;
|
||||
if (++ack_count <= 5) log_write("[WS] Ack #%d: %.*s\n", ack_count,
|
||||
(int)(payload_len > 200 ? 200 : payload_len), (const char *)payload);
|
||||
} else if (strcmp(msg_type->valuestring, "message") == 0) {
|
||||
parse_book_update(conn, msg_root, client, conn_idx);
|
||||
} else if (strcmp(msg_type->valuestring, "error") == 0) {
|
||||
log_write("[WS] Error message: %.*s\n",
|
||||
(int)(payload_len > 200 ? 200 : payload_len), (const char *)payload);
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(msg_root);
|
||||
}
|
||||
|
||||
conn->frame_payload_len = 0;
|
||||
conn->frame_finished = false;
|
||||
}
|
||||
|
||||
/*
|
||||
* Read raw bytes from SSL socket and reassemble WebSocket frames.
|
||||
* Handles mask unmasking (RFC 6455: server frames are unmasked) and
|
||||
* the extended payload length encoding (16-bit and 64-bit extended fields).
|
||||
* Accumulates partial frames across multiple read() calls.
|
||||
* Dispatches complete frames via ws_client_process_frame.
|
||||
*/
|
||||
int ws_client_read(ws_client_t *client, uint32_t conn_idx) {
|
||||
if (conn_idx >= WS_MAX_CONNECTIONS) return -1;
|
||||
ws_connection_t *conn = &client->connections[conn_idx];
|
||||
if (conn->state != WS_STATE_CONNECTED) return -1;
|
||||
|
||||
int r = SSL_read(conn->ssl, conn->read_buf + conn->read_len,
|
||||
WS_READ_BUF_SIZE - conn->read_len);
|
||||
if (r < 0) {
|
||||
int err = SSL_get_error(conn->ssl, r);
|
||||
if (err == SSL_ERROR_WANT_READ || err == SSL_ERROR_WANT_WRITE) return 0;
|
||||
log_write("[WS] SSL_read error: %d\n", err);
|
||||
return -1;
|
||||
}
|
||||
if (r == 0) {
|
||||
log_write("[WS] Connection closed on conn %u\n", conn_idx);
|
||||
conn->state = WS_STATE_DISCONNECTED;
|
||||
return -1;
|
||||
}
|
||||
conn->read_len += (size_t)r;
|
||||
conn->t_sock_arrive_ms = (int64_t)now_realtime_ms();
|
||||
|
||||
// Process all complete frames in the read buffer
|
||||
while (conn->read_len >= 2) {
|
||||
if (!conn->frame_finished && conn->frame_payload_len == 0) {
|
||||
uint8_t fin = conn->read_buf[0] & 0x80;
|
||||
bool masked = (conn->read_buf[1] & 0x80) != 0;
|
||||
uint64_t payload_len_raw = conn->read_buf[1] & 0x7F;
|
||||
|
||||
size_t hdr_len = 2;
|
||||
if (payload_len_raw == 126) {
|
||||
if (conn->read_len < 4) break;
|
||||
payload_len_raw = ((uint64_t)conn->read_buf[2] << 8) | conn->read_buf[3];
|
||||
hdr_len = 4;
|
||||
} else if (payload_len_raw == 127) {
|
||||
if (conn->read_len < 10) break;
|
||||
payload_len_raw = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
payload_len_raw = (payload_len_raw << 8) | conn->read_buf[2 + i];
|
||||
}
|
||||
hdr_len = 10;
|
||||
}
|
||||
|
||||
if (masked) hdr_len += 4;
|
||||
|
||||
if (conn->read_len < hdr_len) break;
|
||||
|
||||
if (payload_len_raw > WS_MAX_FRAME_SIZE) {
|
||||
log_write("[WS] Frame too large: %lu\n", (unsigned long)payload_len_raw);
|
||||
conn->state = WS_STATE_DISCONNECTED;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Unmask payload in-place (server frames from KuCoin are unmasked,
|
||||
// but handle masking per spec just in case)
|
||||
if (masked) {
|
||||
uint8_t mask[4];
|
||||
memcpy(mask, conn->read_buf + hdr_len - 4, 4);
|
||||
for (uint64_t i = 0; i < payload_len_raw; i++) {
|
||||
conn->read_buf[hdr_len + i] ^= mask[i % 4];
|
||||
}
|
||||
}
|
||||
|
||||
size_t frame_size = hdr_len + (size_t)payload_len_raw;
|
||||
if (conn->read_len < frame_size) break;
|
||||
conn->frame_payload_len = (size_t)payload_len_raw;
|
||||
conn->frame_opcode = conn->read_buf[0] & 0x0F;
|
||||
memcpy(conn->frame_buf, conn->read_buf + hdr_len, payload_len_raw);
|
||||
conn->frame_finished = (fin != 0);
|
||||
|
||||
// Consume the frame data from the read buffer
|
||||
memmove(conn->read_buf, conn->read_buf + frame_size, conn->read_len - frame_size);
|
||||
conn->read_len -= frame_size;
|
||||
}
|
||||
|
||||
if (conn->frame_finished && conn->frame_payload_len > 0) {
|
||||
ws_client_process_frame(client, conn_idx);
|
||||
}
|
||||
if (conn->frame_finished && conn->frame_payload_len == 0) {
|
||||
conn->frame_finished = false;
|
||||
}
|
||||
|
||||
if (conn->read_len == 0) break;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
#ifndef FUSED_WS_CLIENT_H
|
||||
#define FUSED_WS_CLIENT_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <openssl/ssl.h>
|
||||
#include <openssl/err.h>
|
||||
#include "book.h"
|
||||
#include "config.h"
|
||||
#include "hash.h"
|
||||
#include "evaluate.h"
|
||||
|
||||
#define WS_MAX_FRAME_SIZE (128 * 1024)
|
||||
#define WS_MAX_CONNECTIONS 8
|
||||
#define WS_READ_BUF_SIZE (64 * 1024)
|
||||
|
||||
/* WebSocket connection state machine */
|
||||
typedef enum {
|
||||
WS_STATE_DISCONNECTED, /* not connected */
|
||||
WS_STATE_CONNECTING, /* TCP/TLS handshake in progress */
|
||||
WS_STATE_GETTING_TOKEN, /* awaiting KuCoin WebSocket token */
|
||||
WS_STATE_SUBSCRIBING, /* sending subscription messages */
|
||||
WS_STATE_CONNECTED, /* fully connected and subscribed */
|
||||
WS_STATE_CLOSING /* graceful close in progress */
|
||||
} ws_state_t;
|
||||
|
||||
/* State for a single WebSocket connection */
|
||||
typedef struct {
|
||||
int fd; /* TCP socket fd */
|
||||
SSL *ssl; /* OpenSSL SSL object */
|
||||
SSL_CTX *ctx; /* OpenSSL SSL context */
|
||||
BIO *bio_mem; /* memory BIO for SSL read buffering */
|
||||
BIO *bio_ssl; /* SSL BIO */
|
||||
BIO *bio_socket; /* socket BIO */
|
||||
|
||||
char host[256]; /* WebSocket server hostname */
|
||||
int port; /* WebSocket server port */
|
||||
char token[256]; /* KuCoin connection token */
|
||||
char connect_id[64]; /* KuCoin connection ID */
|
||||
uint32_t ping_interval_ms; /* negotiated ping interval (ms) */
|
||||
uint32_t ping_timeout_ms; /* ping response timeout (ms) */
|
||||
|
||||
ws_state_t state; /* current connection state */
|
||||
uint64_t last_ping_ms; /* timestamp of last sent ping */
|
||||
uint64_t last_activity_ms; /* timestamp of last rx/tx activity */
|
||||
uint32_t reconnect_count; /* consecutive reconnection count */
|
||||
double reconnect_delay; /* current reconnect backoff delay */
|
||||
double reconnect_base_delay; /* initial reconnect delay */
|
||||
double reconnect_max_delay; /* max reconnect delay */
|
||||
|
||||
uint8_t read_buf[WS_READ_BUF_SIZE]; /* raw socket read buffer */
|
||||
size_t read_pos; /* current read position */
|
||||
size_t read_len; /* bytes available in read_buf */
|
||||
|
||||
uint8_t frame_buf[WS_MAX_FRAME_SIZE]; /* reassembled WebSocket frame */
|
||||
size_t frame_payload_len; /* payload length of current frame */
|
||||
bool frame_finished; /* true when full frame received */
|
||||
uint8_t frame_opcode; /* opcode of current frame */
|
||||
int64_t t_sock_arrive_ms; /* wall-clock when last SSL_read returned */
|
||||
|
||||
uint16_t symbol_indices[MAX_SYMBOLS]; /* subscribed symbol indices */
|
||||
uint32_t symbol_count; /* number of subscribed symbols */
|
||||
} ws_connection_t;
|
||||
|
||||
/* Top-level WebSocket client managing multiple connections */
|
||||
typedef struct {
|
||||
ws_connection_t connections[WS_MAX_CONNECTIONS]; /* fixed-size connection pool */
|
||||
uint32_t connection_count; /* number of active connections */
|
||||
const config_t *cfg; /* pointer to configuration */
|
||||
symbol_table_t *symbols; /* pointer to symbol table */
|
||||
order_book_t *books; /* pointer to shared order books */
|
||||
evaluator_t *evaluator; /* pointer to evaluator */
|
||||
bool running; /* false signals client to stop */
|
||||
} ws_client_t;
|
||||
|
||||
/* Initialise a WebSocket client with config, symbol table, books, and evaluator */
|
||||
int ws_client_init(ws_client_t *client, const config_t *cfg,
|
||||
symbol_table_t *symbols, order_book_t *books,
|
||||
evaluator_t *evaluator);
|
||||
/* Destroy WebSocket client and close all connections */
|
||||
void ws_client_destroy(ws_client_t *client);
|
||||
/* Initiate a WebSocket connection (non-blocking) */
|
||||
int ws_client_connect(ws_client_t *client, uint32_t conn_idx);
|
||||
/* Disconnect a WebSocket connection */
|
||||
void ws_client_disconnect(ws_client_t *client, uint32_t conn_idx);
|
||||
/* Read and process data from a WebSocket connection */
|
||||
int ws_client_read(ws_client_t *client, uint32_t conn_idx);
|
||||
/* Write raw data to a WebSocket connection */
|
||||
int ws_client_write(ws_connection_t *conn, const void *data, size_t len);
|
||||
/* Subscribe to a set of symbols on a connection */
|
||||
int ws_client_subscribe(ws_client_t *client, uint32_t conn_idx,
|
||||
const uint16_t *symbol_indices, uint32_t count);
|
||||
/* Unsubscribe from a set of symbols on a connection */
|
||||
int ws_client_unsubscribe(ws_client_t *client, uint32_t conn_idx,
|
||||
const uint16_t *symbol_indices, uint32_t count);
|
||||
/* Fetch a WebSocket token from the KuCoin API */
|
||||
int ws_client_fetch_token(ws_connection_t *conn);
|
||||
/* Process a received WebSocket frame (dispatch to book updates, etc.) */
|
||||
void ws_client_process_frame(ws_client_t *client, uint32_t conn_idx);
|
||||
/* Send a WebSocket ping frame */
|
||||
int ws_client_send_ping(ws_connection_t *conn);
|
||||
/* Get current monotonic timestamp in milliseconds */
|
||||
uint64_t ws_client_now_ms(void);
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Test harness for fused_engine binary.
|
||||
|
||||
Starts the binary, adds symbols via REST API, listens for signals
|
||||
on the executor Unix socket, and reports results.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
BINARY = Path(__file__).parent / "build" / "fused_engine"
|
||||
CONFIG = Path(__file__).parent / "config.yaml"
|
||||
REST_URL = "http://127.0.0.1:8000"
|
||||
SOCKET_PATH = "/tmp/executor.sock"
|
||||
SIGNAL_FILE = Path(__file__).parent / "test_signals.jsonl"
|
||||
LOG_FILE = Path(__file__).parent / "test_stderr.log"
|
||||
TEST_DURATION = 120 # seconds
|
||||
SYMBOLS_TO_ADD = [
|
||||
"BTC-USDT", "ETH-USDT", "ETH-BTC",
|
||||
"BNB-USDT", "BNB-BTC", "BNB-ETH",
|
||||
"XRP-USDT", "XRP-BTC", "XRP-ETH",
|
||||
"SOL-USDT", "SOL-BTC", "SOL-ETH",
|
||||
"ADA-USDT", "ADA-BTC", "ADA-ETH",
|
||||
"DOGE-USDT", "DOGE-BTC", "DOGE-ETH",
|
||||
"MATIC-USDT", "MATIC-BTC", "MATIC-ETH",
|
||||
"DOT-USDT", "DOT-BTC", "DOT-ETH",
|
||||
]
|
||||
|
||||
|
||||
async def wait_for_rest(timeout=15):
|
||||
"""Wait until the REST API is reachable."""
|
||||
import urllib.request
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
req = urllib.request.urlopen(f"{REST_URL}/health", timeout=2)
|
||||
if req.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
async def add_symbols():
|
||||
"""Add symbols via POST /symbols."""
|
||||
import urllib.request
|
||||
try:
|
||||
data = json.dumps(SYMBOLS_TO_ADD).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{REST_URL}/symbols",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
resp = urllib.request.urlopen(req, timeout=5)
|
||||
body = json.loads(resp.read())
|
||||
print(f" Added symbols: {body.get('added', [])}")
|
||||
except Exception as e:
|
||||
print(f" Symbol add failed: {e}")
|
||||
|
||||
|
||||
async def check_health():
|
||||
"""Print health status."""
|
||||
import urllib.request
|
||||
try:
|
||||
req = urllib.request.urlopen(f"{REST_URL}/health", timeout=2)
|
||||
body = json.loads(req.read())
|
||||
return body
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
async def listen_for_signals(duration):
|
||||
"""Listen on the executor Unix socket for signal JSON lines."""
|
||||
signals = []
|
||||
errors = []
|
||||
deadline = time.time() + duration
|
||||
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.settimeout(2.0)
|
||||
sock.connect(SOCKET_PATH)
|
||||
buf = b""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
sig = json.loads(line)
|
||||
signals.append(sig)
|
||||
bps = sig.get("predicted_bps", "?")
|
||||
key = sig.get("triangle_key", "?")
|
||||
t_arrive = sig.get("t_arrive_ms", "?")
|
||||
t_eval = sig.get("t_eval_ms", "?")
|
||||
print(f" [SIGNAL] {bps} bps | {key} | arrive={t_arrive} eval={t_eval}")
|
||||
except json.JSONDecodeError:
|
||||
errors.append(f"Bad JSON: {line[:80]}")
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as e:
|
||||
errors.append(f"Read error: {e}")
|
||||
break
|
||||
sock.close()
|
||||
except Exception as e:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
return signals, errors
|
||||
|
||||
|
||||
async def main():
|
||||
print(f"=== Fused Engine Test ===")
|
||||
print(f"Binary: {BINARY}")
|
||||
print(f"Config: {CONFIG}")
|
||||
print(f"Duration: {TEST_DURATION}s")
|
||||
print(f"Symbols to add: {len(SYMBOLS_TO_ADD)}")
|
||||
print()
|
||||
|
||||
if not BINARY.exists():
|
||||
print(f"ERROR: Binary not found at {BINARY}")
|
||||
sys.exit(1)
|
||||
|
||||
# Clean up old socket
|
||||
try:
|
||||
os.unlink(SOCKET_PATH)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Start the binary
|
||||
print("[1] Starting fused_engine...")
|
||||
with LOG_FILE.open("w") as log_f:
|
||||
proc = subprocess.Popen(
|
||||
[str(BINARY), str(CONFIG)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=log_f,
|
||||
text=True,
|
||||
)
|
||||
print(f" PID: {proc.pid}")
|
||||
|
||||
# Wait for REST API
|
||||
print("[2] Waiting for REST API...")
|
||||
if not await wait_for_rest(20):
|
||||
print(" ERROR: REST API did not come up")
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
print(" stderr output:")
|
||||
print(LOG_FILE.read_text()[-2000:])
|
||||
sys.exit(1)
|
||||
print(" REST API is up")
|
||||
|
||||
# Check health
|
||||
health = await check_health()
|
||||
print(f" Health: {health}")
|
||||
|
||||
# Add symbols
|
||||
print("[3] Adding symbols...")
|
||||
await add_symbols()
|
||||
|
||||
# Wait a moment for subscriptions to take effect
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# Check health again
|
||||
health = await check_health()
|
||||
print(f" Health after add: {health}")
|
||||
|
||||
# Listen for signals
|
||||
print(f"[4] Listening for signals for {TEST_DURATION}s...")
|
||||
signals, errors = await listen_for_signals(TEST_DURATION)
|
||||
|
||||
# Stop the binary
|
||||
print("[5] Stopping fused_engine...")
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
# Report results
|
||||
print()
|
||||
print(f"=== Results ===")
|
||||
print(f"Signals received: {len(signals)}")
|
||||
print(f"Socket errors: {len(errors)}")
|
||||
|
||||
if signals:
|
||||
SIGNAL_FILE.write_text(json.dumps(signals, indent=2) + "\n")
|
||||
print(f"Signals saved to: {SIGNAL_FILE}")
|
||||
|
||||
bps_values = [s.get("predicted_bps", 0) for s in signals]
|
||||
print(f"BPS range: {min(bps_values):.4f} - {max(bps_values):.4f}")
|
||||
|
||||
triangles = set(s.get("triangle_key", "?") for s in signals)
|
||||
print(f"Unique triangles: {triangles}")
|
||||
|
||||
for s in signals[:5]:
|
||||
print(f" {s.get('triangle_key', '?')} | {s.get('predicted_bps', '?')} bps | "
|
||||
f"t_arrive={s.get('t_arrive_ms', '?')} t_eval={s.get('t_eval_ms', '?')}")
|
||||
else:
|
||||
print("No signals received during test period.")
|
||||
|
||||
if errors:
|
||||
print(f"Errors: {errors[:5]}")
|
||||
|
||||
# Print last lines of stderr log
|
||||
print()
|
||||
print("=== Last 40 lines of stderr ===")
|
||||
log_text = LOG_FILE.read_text()
|
||||
lines = log_text.strip().split("\n")
|
||||
for line in lines[-40:]:
|
||||
print(f" {line}")
|
||||
|
||||
print(f"\nFull log: {LOG_FILE}")
|
||||
print(f"Signals: {SIGNAL_FILE if signals else '(none)'}")
|
||||
print()
|
||||
|
||||
if len(signals) > 0:
|
||||
print("PASS: Signals were received.")
|
||||
else:
|
||||
print("INFO: No signals. This may be normal if no arbitrage opportunities arose.")
|
||||
print(" Check stderr above for connection/subscription issues.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Loading…
Reference in New Issue