refactor: move order sizing to engine, simplify executor

Engine (evaluate.c):
- Compute per-leg minimum order size from quoteMinSize
  (max(baseMinSize * price, quoteMinSize), rounded to quoteIncrement)
- Convert each leg's minimum to starting-quote via pure-rate product (no fees)
- Viability gate: skip triangle if candidate < min_volume (strictest leg)
- Floor starting_volume at min_volume; supersedes old base_min_size guard

Data (symbols_api.h, triangle.h, symbols_api.c):
- Parse quoteMinSize from KuCoin /api/v2/symbols; propagate to triangle struct

Executor (executor.py):
- Remove _precheck_volume: sizing is the engine's responsibility
- Live mode: don't deduct estimated fee from filled_volume (exchange nets fees)
- Live mode: LegFill.fee always zero
This commit is contained in:
nicolas 2026-05-24 17:49:09 -03:00
parent 2a82086683
commit 71ed25fe56
5 changed files with 44 additions and 66 deletions

View File

@ -351,17 +351,6 @@ class Executor:
self._log.debug("signal_discarded_stale", correlation_id=correlation_id, book_ts_ms=book_ts_ms, stale_ts=stale_ts) self._log.debug("signal_discarded_stale", correlation_id=correlation_id, book_ts_ms=book_ts_ms, stale_ts=stale_ts)
return return
precheck_ok, min_required, leg_minima = self._precheck_volume(signal)
if not precheck_ok:
self._log.debug(
"signal_discarded_vol_too_small",
correlation_id=correlation_id,
starting_volume=str(signal.get("starting_volume", "")),
min_required=str(min_required),
leg_minima=leg_minima,
)
return
async with self._isolation_lock: async with self._isolation_lock:
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
self._session = self._create_session() self._session = self._create_session()
@ -390,38 +379,6 @@ class Executor:
async with self._isolation_lock: async with self._isolation_lock:
self._in_flight.pop(correlation_id, None) self._in_flight.pop(correlation_id, None)
def _precheck_volume(self, signal: dict) -> tuple[bool, Decimal, dict[str, str]]:
"""
Verify the signal's starting volume can clear each leg's base_min_size.
Uses the top-of-book price to convert quote-currency order_param to
base-currency for buy legs. This is a cheap rejection filter before
we commit to execution avoids hitting the exchange with an order
that would be rejected for being below the minimum size.
"""
legs = signal.get("legs", [])
starting_volume = Decimal(str(signal.get("starting_volume", "0")))
if not legs or starting_volume <= 0:
return (False, _D0_1, {})
books = signal.get("books", [])
for i, leg in enumerate(legs):
order_param = Decimal(str(leg.get("order_param", "0")))
base_min = Decimal(str(leg.get("base_min_size", "0")))
if order_param <= 0 or base_min <= 0:
continue
side = leg.get("side", "")
book = books[i] if i < len(books) else {}
if side == "buy":
asks = book.get("asks", [])
price = Decimal(str(asks[0]["price"])) if asks else _D0
if price > 0 and order_param / price < base_min:
return (False, base_min, {leg.get("pair", ""): str(base_min)})
else:
if order_param < base_min:
return (False, base_min, {leg.get("pair", ""): str(base_min)})
return (True, _D0_1, {})
def _is_blocked(self, triangle_key: frozenset, pair_symbols: frozenset, primary_quote: str) -> bool: def _is_blocked(self, triangle_key: frozenset, pair_symbols: frozenset, primary_quote: str) -> bool:
""" """
Return True if the given triangle_key, pair_symbols, or primary_quote Return True if the given triangle_key, pair_symbols, or primary_quote
@ -553,12 +510,7 @@ class Executor:
input_vol = order_param input_vol = order_param
else: else:
prev = fills[i - 1] prev = fills[i - 1]
if prev.side == "buy": input_vol = prev.filled_volume
# Buy output is base asset: use filled_volume directly.
input_vol = prev.filled_volume
else:
# Sell output is quote: deduct fee before passing as next input.
input_vol = prev.filled_volume * (_D1 - fee_rate)
if base_min_size > 0 and side == "sell" and input_vol < base_min_size: if base_min_size > 0 and side == "sell" and input_vol < base_min_size:
self._log.info("execution_aborted_below_min", self._log.info("execution_aborted_below_min",
@ -695,12 +647,7 @@ class Executor:
weighted_avg_price = Decimal(str(bids[0]["price"])) if bids else _D0 weighted_avg_price = Decimal(str(bids[0]["price"])) if bids else _D0
avg_price = weighted_avg_price if weighted_avg_price > 0 else _D0 avg_price = weighted_avg_price if weighted_avg_price > 0 else _D0
# Estimate fee: KuCoin charges fee in the currency being received. fee = _D0
fee = (
total_size * fee_rate
if fee_ccy == base_ccy
else total_funds * fee_rate
)
fills.append(LegFill( fills.append(LegFill(
leg=i, leg=i,

View File

@ -201,6 +201,41 @@ bool evaluate_symbol(evaluator_t *ev, uint16_t symbol_idx, int64_t t_sock_arrive
} }
} }
/*
* Minimum starting-quote volume required to clear every leg's
* exchange minimum order size. Each leg's minimum is expressed
* in its quote currency and then converted back to starting-quote
* units via the cumulative pure-rate product (no fees).
*/
double cumulative_rate = 1.0;
double leg_min_starting[3];
for (int leg = 0; leg < 3; leg++) {
const order_book_t *bk = books_arr[leg];
bool is_buy = !tri->use_bid[leg];
double price = is_buy ? bk->asks[0][0] : bk->bids[0][0];
double qi = tri->quote_increment[leg];
double bms = tri->base_min_size[leg];
double qms = tri->quote_min_size[leg];
double min_quote = fmax(bms * price, qms);
if (qi > 0) min_quote = ceil(min_quote / qi - 1e-12) * qi;
leg_min_starting[leg] = min_quote / cumulative_rate;
cumulative_rate *= rates[leg];
}
double min_volume = leg_min_starting[0];
for (int leg = 1; leg < 3; leg++) {
if (leg_min_starting[leg] > min_volume)
min_volume = leg_min_starting[leg];
}
/* Viability gate: must be able to clear every leg's minimum */
if (max_volume < min_volume) continue;
/* Floor at the strictest leg's minimum */
max_volume = fmax(max_volume, min_volume);
int64_t cooldown_ms = (int64_t)(cfg->cooldown_seconds * 1000); int64_t cooldown_ms = (int64_t)(cfg->cooldown_seconds * 1000);
if (now - ev->last_signal_ts_ms[i] < cooldown_ms) continue; if (now - ev->last_signal_ts_ms[i] < cooldown_ms) continue;
ev->last_signal_ts_ms[i] = now; ev->last_signal_ts_ms[i] = now;
@ -265,17 +300,6 @@ bool evaluate_symbol(evaluator_t *ev, uint16_t symbol_idx, int64_t t_sock_arrive
sig.starting_volume = leg_quote_vol[0]; sig.starting_volume = leg_quote_vol[0];
sig.live = cfg->live_mode; 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", snprintf(sig.triangle_key, sizeof(sig.triangle_key), "%s/%s/%s",
tri->base, tri->mid, tri->quote); tri->base, tri->mid, tri->quote);
strncpy(sig.primary_quote, tri->base, CURRENCY_NAME_LEN); strncpy(sig.primary_quote, tri->base, CURRENCY_NAME_LEN);

View File

@ -148,6 +148,7 @@ int fetch_trading_pairs(pair_list_t *out) {
cJSON *base_inc = cJSON_GetObjectItem(item, "baseIncrement"); cJSON *base_inc = cJSON_GetObjectItem(item, "baseIncrement");
cJSON *quote_inc = cJSON_GetObjectItem(item, "quoteIncrement"); cJSON *quote_inc = cJSON_GetObjectItem(item, "quoteIncrement");
cJSON *base_min = cJSON_GetObjectItem(item, "baseMinSize"); cJSON *base_min = cJSON_GetObjectItem(item, "baseMinSize");
cJSON *quote_min = cJSON_GetObjectItem(item, "quoteMinSize");
if (!cJSON_IsString(sym) || !cJSON_IsString(base) || !cJSON_IsString(quote)) continue; if (!cJSON_IsString(sym) || !cJSON_IsString(base) || !cJSON_IsString(quote)) continue;
if (!cJSON_IsBool(enable) || !cJSON_IsTrue(enable)) continue; if (!cJSON_IsBool(enable) || !cJSON_IsTrue(enable)) continue;
@ -165,6 +166,7 @@ int fetch_trading_pairs(pair_list_t *out) {
pair->base_increment = cJSON_IsString(base_inc) ? atof(base_inc->valuestring) : 0.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->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; pair->base_min_size = cJSON_IsString(base_min) ? atof(base_min->valuestring) : 0.0;
pair->quote_min_size = cJSON_IsString(quote_min) ? atof(quote_min->valuestring) : 0.0;
} }
cJSON_Delete(root); cJSON_Delete(root);
@ -349,6 +351,9 @@ int discover_symbols(symbol_table_t *symbols, triangle_set_t *triangles,
t->base_min_size[0] = pairs.pairs[i1].base_min_size; 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[1] = pairs.pairs[i2].base_min_size;
t->base_min_size[2] = pairs.pairs[i3].base_min_size; t->base_min_size[2] = pairs.pairs[i3].base_min_size;
t->quote_min_size[0] = pairs.pairs[i1].quote_min_size;
t->quote_min_size[1] = pairs.pairs[i2].quote_min_size;
t->quote_min_size[2] = pairs.pairs[i3].quote_min_size;
tri_count++; tri_count++;
} }

View File

@ -19,6 +19,7 @@ typedef struct {
double base_increment; /* base lot size step */ double base_increment; /* base lot size step */
double quote_increment; /* quote lot size step */ double quote_increment; /* quote lot size step */
double base_min_size; /* minimum base order size */ double base_min_size; /* minimum base order size */
double quote_min_size; /* minimum quote order size */
} trading_pair_t; } trading_pair_t;
/* Growable list of trading pairs */ /* Growable list of trading pairs */

View File

@ -18,6 +18,7 @@ typedef struct {
double base_increment[3]; /* base asset lot step 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 quote_increment[3]; /* quote asset lot step for each leg */
double base_min_size[3]; /* min base order size for each leg */ double base_min_size[3]; /* min base order size for each leg */
double quote_min_size[3]; /* min quote order size for each leg */
uint16_t id; /* unique triangle ID */ uint16_t id; /* unique triangle ID */
char symbol_names[3][SYMBOL_NAME_LEN]; /* trading pair names for each leg */ 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 fee_currency[3][CURRENCY_NAME_LEN]; /* fee currency per leg */