fix: authenticated session warmup, balance-aware cascade, books always in signal

executor/executor.py:
- Replace unauthenticated /api/v1/time warmup with authenticated /api/v1/accounts
- Keepalive interval 15s -> 30s, uses authenticated warmup_session
- After sell leg, override filled_volume with latest balance from WS (net of fee)

executor/kucoin_api.py:
- Add warmup_session() method for GET /api/v1/accounts (authenticated)
- Pre-heats TCP/TLS connection pool to reduce first-order latency

executor/ws_client.py:
- Add latest_balance() method to expose WS balance cache

src/events.c:
- Always include book tops in signal (remove !sig->live gate)
- Only serialize top bid/ask level (not all 5)
This commit is contained in:
nicolas 2026-05-25 22:34:24 -03:00
parent 0d3acc62cb
commit affe18cbac
4 changed files with 47 additions and 23 deletions

View File

@ -242,17 +242,11 @@ class Executor:
async def start(self) -> None:
"""Pre-initialize and warm up the aiohttp session so the first trade is not slowed."""
self._session = self._create_session()
async def _warm_up():
try:
async with self._session.get("https://api.kucoin.com/api/v1/time") as resp:
await resp.text()
except Exception:
pass
await _warm_up()
await self._api.warmup_session(self._session)
self._keepalive_task = asyncio.create_task(self._keepalive_loop())
_KEEPALIVE_INTERVAL = 15.0
_KEEPALIVE_INTERVAL = 30.0
def _create_session(self) -> aiohttp.ClientSession:
connector = TCPConnector(
@ -269,13 +263,12 @@ class Executor:
)
async def _keepalive_loop(self) -> None:
"""Ping KuCoin periodically to keep the authenticated POST path warm."""
"""Ping KuCoin periodically to keep the authenticated connection pool warm."""
while True:
try:
await asyncio.sleep(self._KEEPALIVE_INTERVAL)
if self._live_mode:
async with self._session.get("https://api.kucoin.com/api/v1/time") as resp:
await resp.text()
await self._api.warmup_session(self._session)
else:
await self._api.order_test(
session=self._session,
@ -703,6 +696,10 @@ class Executor:
await self._ws_client.await_balance(
output_ccy, fills[-1].filled_volume, 2000
)
if side == "sell":
bal = self._ws_client.latest_balance(output_ccy)
if bal > 0:
fills[-1].filled_volume = bal
in_flight.last_trade_ts_ms = int(time.time() * 1000)
continue

View File

@ -432,3 +432,26 @@ class KuCoinAPI:
except (aiohttp.ClientError, OSError) as e:
self._log.error("private_token_http_error", error=str(e))
return None
async def warmup_session(self, session: aiohttp.ClientSession) -> None:
"""Warm up the authenticated HTTP connection pool with a minimal GET."""
timestamp = str(int(time.time() * 1000))
path = "/api/v1/accounts"
sign = _signRequest(timestamp, "GET", 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",
}
try:
async with session.get(
f"https://api.kucoin.com{path}",
headers=headers,
) as resp:
await resp.read()
self._log.info("session_warmed", status=resp.status)
except Exception as e:
self._log.warning("session_warmup_failed", error=str(e))

View File

@ -239,6 +239,10 @@ class KuCoinWSClient:
if self._balance_futures.get(key) is future:
del self._balance_futures[key]
def latest_balance(self, currency: str) -> Decimal:
"""Return the latest known available balance for *currency*, or 0."""
return self._latest_balance.get(currency.upper(), _D0)
async def _connection_worker(self) -> None:
"""Main connection loop with exponential backoff reconnection."""
while self._running:

View File

@ -257,24 +257,24 @@ static void send_signal_to_executor(event_loops_t *loops, signal_entry_t *sig) {
}
}
// Full book snapshot included when !live (paper trading mode)
// Book tops included for display and drift analysis
char books_json_str[2048] = "";
if (!sig->live && sig->book_count > 0) {
if (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++) {
// Only top-of-book level included for display / drift analysis
char tmp[64];
snprintf(tmp, sizeof(tmp), "%s{\"price\":\"%.6g\",\"size\":\"%.8g\"}",
lev ? "," : "", sb->bids[lev].price, sb->bids[lev].size);
if (sb->bid_count > 0) {
snprintf(tmp, sizeof(tmp), "{\"price\":\"%.6g\",\"size\":\"%.8g\"}",
sb->bids[0].price, sb->bids[0].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);
if (sb->ask_count > 0) {
snprintf(tmp, sizeof(tmp), "{\"price\":\"%.6g\",\"size\":\"%.8g\"}",
sb->asks[0].price, sb->asks[0].size);
strncat(ask_arr, tmp, sizeof(ask_arr) - 1);
}
int n = snprintf(bp, rem,
@ -298,9 +298,9 @@ static void send_signal_to_executor(event_loops_t *loops, signal_entry_t *sig) {
(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\":[",
sig->book_count == 0 ? "" : ",\"books\":[",
books_json_str[0] ? books_json_str : "",
(sig->live || sig->book_count == 0) ? "" : "]");
sig->book_count == 0 ? "" : "]");
size_t to_send = strlen(json_buf);
size_t sent = 0;