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:
nicolas 2026-05-24 16:12:04 -03:00
commit 2a82086683
67 changed files with 14888 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -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/

109
README.md Normal file
View File

@ -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
```

6
common/__init__.py Normal file
View File

@ -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"]

65
common/config.py Normal file
View File

@ -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"}

133
common/log.py Normal file
View File

@ -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

85
compare_enum.py Normal file
View File

@ -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}")

31
config.yaml.example Normal file
View File

@ -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: ""

766
docs/fused-engine-plan.md Normal file
View File

@ -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

View File

@ -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]

114
docs/signal-contract-v2.md Normal file
View File

@ -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.

9
executor/__init__.py Normal file
View File

@ -0,0 +1,9 @@
"""
Executor package.
Re-exports the Executor class used by the fused_engine process.
"""
from executor.executor import Executor
__all__ = ["Executor"]

132
executor/__main__.py Normal file
View File

@ -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())

89
executor/config.py Normal file
View File

@ -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"}

1001
executor/executor.py Normal file

File diff suppressed because it is too large Load Diff

434
executor/kucoin_api.py Normal file
View File

@ -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

171
executor/rest_api.py Normal file
View File

@ -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

100
executor/socket_server.py Normal file
View File

@ -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")

406
executor/ws_client.py Normal file
View File

@ -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, {}))

15
fh_ob/__init__.py Normal file
View File

@ -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",
]

87
fh_ob/__main__.py Normal file
View File

@ -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())

70
fh_ob/book_store.py Normal file
View File

@ -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()

110
fh_ob/rest_server.py Normal file
View File

@ -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

95
fh_ob/socket_server.py Normal file
View File

@ -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

271
fh_ob/ws_client.py Normal file
View File

@ -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
oe_em/__init__.py Normal file
View File

223
oe_em/__main__.py Normal file
View File

@ -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())

205
oe_em/book_consumer.py Normal file
View File

@ -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")

90
oe_em/config.py Normal file
View File

@ -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"}

96
oe_em/kucoin_api.py Normal file
View File

@ -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"]]

408
oe_em/opportunity.py Normal file
View File

@ -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))

26
oe_em/risk.py Normal file
View File

@ -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

113
oe_em/socket_client.py Normal file
View File

@ -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

291
oe_em/triangle_enum.py Normal file
View File

@ -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() 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 (c1c2, c2c3, c3c1) 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)

29
pyproject.toml Normal file
View File

@ -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 = ["."]

1
response.txt Normal file

File diff suppressed because one or more lines are too long

26
scripts/install.sh Executable file
View File

@ -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"

1
session Normal file
View File

@ -0,0 +1 @@
opencode -s ses_1b4ebb4cdffey1e1nSPwzXAwbn

21
src/CMakeLists.txt Normal file
View File

@ -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
)

63
src/book.c Normal file
View File

@ -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;
}

23
src/book.h Normal file
View File

@ -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

3206
src/cJSON.c Normal file

File diff suppressed because it is too large Load Diff

306
src/cJSON.h Normal file
View File

@ -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

257
src/config.c Normal file
View File

@ -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;
}

60
src/config.h Normal file
View File

@ -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

368
src/evaluate.c Normal file
View File

@ -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;
}

42
src/evaluate.h Normal file
View File

@ -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

443
src/events.c Normal file
View File

@ -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;
}

72
src/events.h Normal file
View File

@ -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

78
src/hash.c Normal file
View File

@ -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;
}

34
src/hash.h Normal file
View File

@ -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

431
src/http_client.c Normal file
View File

@ -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;
}

23
src/http_client.h Normal file
View File

@ -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

360
src/http_server.c Normal file
View File

@ -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;
}

38
src/http_server.h Normal file
View File

@ -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

471
src/jsmn.h Normal file
View File

@ -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 */

93
src/log.c Normal file
View File

@ -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);
}
}

16
src/log.h Normal file
View File

@ -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

266
src/main.c Normal file
View File

@ -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;
}

90
src/queue.c Normal file
View File

@ -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;
}

92
src/queue.h Normal file
View File

@ -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

426
src/symbols_api.c Normal file
View File

@ -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;
}

38
src/symbols_api.h Normal file
View File

@ -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

96
src/triangle.c Normal file
View File

@ -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);
}

62
src/triangle.h Normal file
View File

@ -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

785
src/ws_client.c Normal file
View File

@ -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;
}

105
src/ws_client.h Normal file
View File

@ -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

238
test_fused.py Normal file
View File

@ -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())