cleanup: remove stale files, add session to .gitignore
This commit is contained in:
parent
e6ca8ac624
commit
3c4bbfde8b
|
|
@ -12,3 +12,4 @@ deploy.sh
|
||||||
deploy_amzn.sh
|
deploy_amzn.sh
|
||||||
build/
|
build/
|
||||||
triangular_arb.egg-info/
|
triangular_arb.egg-info/
|
||||||
|
session
|
||||||
|
|
|
||||||
|
|
@ -1,766 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
## 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.
|
|
||||||
File diff suppressed because one or more lines are too long
238
test_fused.py
238
test_fused.py
|
|
@ -1,238 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Test harness for fused_engine binary.
|
|
||||||
|
|
||||||
Starts the binary, adds symbols via REST API, listens for signals
|
|
||||||
on the executor Unix socket, and reports results.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
BINARY = Path(__file__).parent / "build" / "fused_engine"
|
|
||||||
CONFIG = Path(__file__).parent / "config.yaml"
|
|
||||||
REST_URL = "http://127.0.0.1:8000"
|
|
||||||
SOCKET_PATH = "/tmp/executor.sock"
|
|
||||||
SIGNAL_FILE = Path(__file__).parent / "test_signals.jsonl"
|
|
||||||
LOG_FILE = Path(__file__).parent / "test_stderr.log"
|
|
||||||
TEST_DURATION = 120 # seconds
|
|
||||||
SYMBOLS_TO_ADD = [
|
|
||||||
"BTC-USDT", "ETH-USDT", "ETH-BTC",
|
|
||||||
"BNB-USDT", "BNB-BTC", "BNB-ETH",
|
|
||||||
"XRP-USDT", "XRP-BTC", "XRP-ETH",
|
|
||||||
"SOL-USDT", "SOL-BTC", "SOL-ETH",
|
|
||||||
"ADA-USDT", "ADA-BTC", "ADA-ETH",
|
|
||||||
"DOGE-USDT", "DOGE-BTC", "DOGE-ETH",
|
|
||||||
"MATIC-USDT", "MATIC-BTC", "MATIC-ETH",
|
|
||||||
"DOT-USDT", "DOT-BTC", "DOT-ETH",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_rest(timeout=15):
|
|
||||||
"""Wait until the REST API is reachable."""
|
|
||||||
import urllib.request
|
|
||||||
deadline = time.time() + timeout
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
req = urllib.request.urlopen(f"{REST_URL}/health", timeout=2)
|
|
||||||
if req.status == 200:
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await asyncio.sleep(0.5)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def add_symbols():
|
|
||||||
"""Add symbols via POST /symbols."""
|
|
||||||
import urllib.request
|
|
||||||
try:
|
|
||||||
data = json.dumps(SYMBOLS_TO_ADD).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
f"{REST_URL}/symbols",
|
|
||||||
data=data,
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
resp = urllib.request.urlopen(req, timeout=5)
|
|
||||||
body = json.loads(resp.read())
|
|
||||||
print(f" Added symbols: {body.get('added', [])}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f" Symbol add failed: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
async def check_health():
|
|
||||||
"""Print health status."""
|
|
||||||
import urllib.request
|
|
||||||
try:
|
|
||||||
req = urllib.request.urlopen(f"{REST_URL}/health", timeout=2)
|
|
||||||
body = json.loads(req.read())
|
|
||||||
return body
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
|
|
||||||
async def listen_for_signals(duration):
|
|
||||||
"""Listen on the executor Unix socket for signal JSON lines."""
|
|
||||||
signals = []
|
|
||||||
errors = []
|
|
||||||
deadline = time.time() + duration
|
|
||||||
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
sock.settimeout(2.0)
|
|
||||||
sock.connect(SOCKET_PATH)
|
|
||||||
buf = b""
|
|
||||||
while time.time() < deadline:
|
|
||||||
try:
|
|
||||||
chunk = sock.recv(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
buf += chunk
|
|
||||||
while b"\n" in buf:
|
|
||||||
line, buf = buf.split(b"\n", 1)
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
sig = json.loads(line)
|
|
||||||
signals.append(sig)
|
|
||||||
bps = sig.get("predicted_bps", "?")
|
|
||||||
key = sig.get("triangle_key", "?")
|
|
||||||
t_arrive = sig.get("t_arrive_ms", "?")
|
|
||||||
t_eval = sig.get("t_eval_ms", "?")
|
|
||||||
print(f" [SIGNAL] {bps} bps | {key} | arrive={t_arrive} eval={t_eval}")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
errors.append(f"Bad JSON: {line[:80]}")
|
|
||||||
except socket.timeout:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
errors.append(f"Read error: {e}")
|
|
||||||
break
|
|
||||||
sock.close()
|
|
||||||
except Exception as e:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
return signals, errors
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
print(f"=== Fused Engine Test ===")
|
|
||||||
print(f"Binary: {BINARY}")
|
|
||||||
print(f"Config: {CONFIG}")
|
|
||||||
print(f"Duration: {TEST_DURATION}s")
|
|
||||||
print(f"Symbols to add: {len(SYMBOLS_TO_ADD)}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if not BINARY.exists():
|
|
||||||
print(f"ERROR: Binary not found at {BINARY}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Clean up old socket
|
|
||||||
try:
|
|
||||||
os.unlink(SOCKET_PATH)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Start the binary
|
|
||||||
print("[1] Starting fused_engine...")
|
|
||||||
with LOG_FILE.open("w") as log_f:
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
[str(BINARY), str(CONFIG)],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=log_f,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
print(f" PID: {proc.pid}")
|
|
||||||
|
|
||||||
# Wait for REST API
|
|
||||||
print("[2] Waiting for REST API...")
|
|
||||||
if not await wait_for_rest(20):
|
|
||||||
print(" ERROR: REST API did not come up")
|
|
||||||
proc.terminate()
|
|
||||||
proc.wait()
|
|
||||||
print(" stderr output:")
|
|
||||||
print(LOG_FILE.read_text()[-2000:])
|
|
||||||
sys.exit(1)
|
|
||||||
print(" REST API is up")
|
|
||||||
|
|
||||||
# Check health
|
|
||||||
health = await check_health()
|
|
||||||
print(f" Health: {health}")
|
|
||||||
|
|
||||||
# Add symbols
|
|
||||||
print("[3] Adding symbols...")
|
|
||||||
await add_symbols()
|
|
||||||
|
|
||||||
# Wait a moment for subscriptions to take effect
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
|
|
||||||
# Check health again
|
|
||||||
health = await check_health()
|
|
||||||
print(f" Health after add: {health}")
|
|
||||||
|
|
||||||
# Listen for signals
|
|
||||||
print(f"[4] Listening for signals for {TEST_DURATION}s...")
|
|
||||||
signals, errors = await listen_for_signals(TEST_DURATION)
|
|
||||||
|
|
||||||
# Stop the binary
|
|
||||||
print("[5] Stopping fused_engine...")
|
|
||||||
proc.terminate()
|
|
||||||
try:
|
|
||||||
proc.wait(timeout=5)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
proc.kill()
|
|
||||||
proc.wait()
|
|
||||||
|
|
||||||
# Report results
|
|
||||||
print()
|
|
||||||
print(f"=== Results ===")
|
|
||||||
print(f"Signals received: {len(signals)}")
|
|
||||||
print(f"Socket errors: {len(errors)}")
|
|
||||||
|
|
||||||
if signals:
|
|
||||||
SIGNAL_FILE.write_text(json.dumps(signals, indent=2) + "\n")
|
|
||||||
print(f"Signals saved to: {SIGNAL_FILE}")
|
|
||||||
|
|
||||||
bps_values = [s.get("predicted_bps", 0) for s in signals]
|
|
||||||
print(f"BPS range: {min(bps_values):.4f} - {max(bps_values):.4f}")
|
|
||||||
|
|
||||||
triangles = set(s.get("triangle_key", "?") for s in signals)
|
|
||||||
print(f"Unique triangles: {triangles}")
|
|
||||||
|
|
||||||
for s in signals[:5]:
|
|
||||||
print(f" {s.get('triangle_key', '?')} | {s.get('predicted_bps', '?')} bps | "
|
|
||||||
f"t_arrive={s.get('t_arrive_ms', '?')} t_eval={s.get('t_eval_ms', '?')}")
|
|
||||||
else:
|
|
||||||
print("No signals received during test period.")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
print(f"Errors: {errors[:5]}")
|
|
||||||
|
|
||||||
# Print last lines of stderr log
|
|
||||||
print()
|
|
||||||
print("=== Last 40 lines of stderr ===")
|
|
||||||
log_text = LOG_FILE.read_text()
|
|
||||||
lines = log_text.strip().split("\n")
|
|
||||||
for line in lines[-40:]:
|
|
||||||
print(f" {line}")
|
|
||||||
|
|
||||||
print(f"\nFull log: {LOG_FILE}")
|
|
||||||
print(f"Signals: {SIGNAL_FILE if signals else '(none)'}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if len(signals) > 0:
|
|
||||||
print("PASS: Signals were received.")
|
|
||||||
else:
|
|
||||||
print("INFO: No signals. This may be normal if no arbitrage opportunities arose.")
|
|
||||||
print(" Check stderr above for connection/subscription issues.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
Loading…
Reference in New Issue