/* * 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 #include #include #include #include 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; } /* ── Copied from executor.c for paper-trade simulation ── */ static double apply_fee_hold(double vol, double fee_rate, bool next_is_buy) { if (next_is_buy && fee_rate > 0) vol /= (1.0 + fee_rate); return vol; } static double apply_increment_floor(double vol, double inc) { if (inc > 0) vol = floor(vol / inc - 1e-12) * inc; return vol; } 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'; } /* * 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 full cost) * - Adjust by 1e-12 epsilon to avoid floating-point boundary errors * e.g. ceil(value / qi - 1e-12) ensures that 0.10000000000000001 doesn't * round up to 0.10000001 when qi = 0.01 * 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) { if (bk->bid_count == 0) { valid = false; break; } rate = bk->bids[0][0]; max_input = bk->bids[0][1]; } else { 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; 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_screen("[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; } } /* * 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; if (is_buy) { leg_min_starting[leg] = min_quote / cumulative_rate; } else { double denom = cumulative_rate * (price > 0 ? price : 1.0); leg_min_starting[leg] = min_quote / denom; } 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); 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)); /* Set leg 0 order_param from max_volume (used by the paper simulation) */ { double fi0 = tri->funds_increment[0]; double qi0 = tri->quote_increment[0]; double bi0 = tri->base_increment[0]; double ff0 = tri->fee_factor[0]; if (fi0 <= 0) fi0 = qi0; bool is_buy0 = !tri->use_bid[0]; double price0 = is_buy0 ? books_arr[0]->asks[0][0] : books_arr[0]->bids[0][0]; if (is_buy0) { double quote_input = floor(max_volume / qi0 - 1e-12) * qi0; double base = floor(quote_input * ff0 / price0 / bi0 - 1e-12) * bi0; double quote_cost = floor(base * price0 / fi0 - 1e-12) * fi0; sig.legs.legs[0].quote_volume = quote_cost; } else { double base = floor(max_volume / bi0 - 1e-12) * bi0; sig.legs.legs[0].quote_volume = floor(base * price0 / qi0 - 1e-12) * qi0; snprintf(sig.legs.legs[0].order_param, sizeof(sig.legs.legs[0].order_param), "%.8g", base); } if (is_buy0) { snprintf(sig.legs.legs[0].order_param, sizeof(sig.legs.legs[0].order_param), "%.8g", sig.legs.legs[0].quote_volume); } sig.legs.legs[0].base_increment = bi0; sig.legs.legs[0].quote_increment = qi0; sig.legs.legs[0].funds_increment = fi0; sig.legs.legs[0].fee_rate = 1.0 - ff0; strncpy(sig.legs.legs[0].symbol, tri->symbol_names[0], SYMBOL_NAME_LEN); strncpy(sig.legs.legs[0].fee_currency, tri->fee_currency[0], CURRENCY_NAME_LEN); if (!tri->use_bid[0]) strncpy(sig.legs.legs[0].side, "buy", 5); else strncpy(sig.legs.legs[0].side, "sell", 5); } /* Set legs 1-2 data needed for the simulation */ for (int l = 1; l < 3; l++) { strncpy(sig.legs.legs[l].symbol, tri->symbol_names[l], SYMBOL_NAME_LEN); strncpy(sig.legs.legs[l].fee_currency, tri->fee_currency[l], CURRENCY_NAME_LEN); sig.legs.legs[l].base_increment = tri->base_increment[l]; sig.legs.legs[l].quote_increment = tri->quote_increment[l]; sig.legs.legs[l].funds_increment = tri->funds_increment[l]; sig.legs.legs[l].fee_rate = 1.0 - tri->fee_factor[l]; if (!tri->use_bid[l]) strncpy(sig.legs.legs[l].side, "buy", 5); else strncpy(sig.legs.legs[l].side, "sell", 5); } /* Paper-trade simulation: exact copy of executor.c */ double leg_output[3] = {0}; double fills[3][6] = {{0}}; bool sim_ok = true; { for (int leg = 0; leg < 3; leg++) { signal_leg_t *sl = &sig.legs.legs[leg]; bool is_buy = (strcmp(sl->side, "buy") == 0); double input_vol; if (leg == 0) { input_vol = atof(sl->order_param); input_vol = apply_increment_floor(input_vol, is_buy ? (sl->funds_increment > 0 ? sl->funds_increment : sl->quote_increment) : sl->base_increment); } else { input_vol = leg_output[leg - 1]; input_vol = apply_increment_floor(input_vol, is_buy ? (sl->funds_increment > 0 ? sl->funds_increment : sl->quote_increment) : sl->base_increment); } fills[leg][4] = input_vol; double total_size = 0, total_funds = 0, avg_price = 0, total_fee = 0; { const order_book_t *bk = books_arr[leg]; if (is_buy && bk->ask_count > 0) { double ask_price = bk->asks[0][0]; total_size = input_vol / ask_price; if (sl->base_increment > 0) total_size = floor(total_size / sl->base_increment - 1e-12) * sl->base_increment; if (sl->quote_increment > 0) total_funds = floor(total_size * ask_price / sl->quote_increment - 1e-12) * sl->quote_increment; else total_funds = total_size * ask_price; avg_price = ask_price; } else if (!is_buy && bk->bid_count > 0) { double bid_price = bk->bids[0][0]; total_size = input_vol; if (sl->base_increment > 0) total_size = floor(total_size / sl->base_increment - 1e-12) * sl->base_increment; if (sl->quote_increment > 0) total_funds = floor(total_size * bid_price / sl->quote_increment - 1e-12) * sl->quote_increment; else total_funds = total_size * bid_price; avg_price = bid_price; } else { ev->stats.triangles_skipped++; sim_ok = false; break; } if (sl->fee_rate > 0) { double output_amt = is_buy ? total_size : total_funds; total_fee = output_amt * sl->fee_rate; } } leg_output[leg] = is_buy ? total_size : total_funds; fills[leg][0] = leg_output[leg]; fills[leg][1] = avg_price; fills[leg][2] = total_fee; fills[leg][3] = total_funds; if (leg < 2) { signal_leg_t *nsl = &sig.legs.legs[leg + 1]; bool nxt_buy = (strcmp(nsl->side, "buy") == 0); leg_output[leg] = apply_fee_hold(leg_output[leg], nsl->fee_rate, nxt_buy); leg_output[leg] = apply_increment_floor(leg_output[leg], nxt_buy ? (nsl->funds_increment > 0 ? nsl->funds_increment : nsl->quote_increment) : nsl->base_increment); } } } if (!sim_ok) continue; /* PnL: exact copy of executor.c */ double profit = 0; net_bps = 0; if (fills[2][0] > 0) { double leg0_in = fills[0][4]; double leg2_out = fills[2][0]; { const signal_leg_t *sl2 = &sig.legs.legs[2]; bool leg2_buy = (strcmp(sl2->side, "buy") == 0); const char *pair = sl2->symbol; const char *dash = strchr(pair, '-'); char base_ccy[16] = {0}, quote_ccy[16] = {0}; if (dash) { size_t blen = (size_t)(dash - pair); if (blen > 15) blen = 15; memcpy(base_ccy, pair, blen); strncpy(quote_ccy, dash + 1, 15); } const char *out_ccy = leg2_buy ? base_ccy : quote_ccy; if (out_ccy[0] && strcmp(sl2->fee_currency, out_ccy) == 0) { leg2_out -= fills[2][2]; } } profit = leg2_out - leg0_in; if (leg0_in > 0) net_bps = (profit / leg0_in) * 10000.0; } sig.predicted_bps = net_bps; 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; if (last_status_ms == 0 || now - last_status_ms >= 30000) { last_status_ms = now; log_write_screen("[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; } sig.starting_volume = fills[0][3]; sig.live = cfg->live_mode; 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); 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; 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", fills[leg][4]); } sl->base_increment = tri->base_increment[leg]; sl->quote_increment = tri->quote_increment[leg]; sl->funds_increment = tri->funds_increment[leg]; sl->base_min_size = tri->base_min_size[leg]; if (use_bid) { strncpy(sl->input_currency, base_cur, CURRENCY_NAME_LEN); strncpy(sl->output_currency, quote_cur, CURRENCY_NAME_LEN); strncpy(sl->side, "sell", 5); } else { 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", sig.predicted_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; }