From d72c07da86ae43bda832288b3bc7a06db5a73933 Mon Sep 17 00:00:00 2001 From: u1 Date: Mon, 2 Feb 2026 12:29:51 +0100 Subject: [PATCH] feat(api): add contract cost estimate + monitor endpoints --- services/api/server.mjs | 392 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) diff --git a/services/api/server.mjs b/services/api/server.mjs index ce32308..8c63706 100644 --- a/services/api/server.mjs +++ b/services/api/server.mjs @@ -347,6 +347,38 @@ function clampInt(value, min, max) { return Math.min(max, Math.max(min, n)); } +function clampNumber(value, min, max, fallback = min) { + const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN; + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, n)); +} + +function parseNumeric(value) { + if (value == null) return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'string') { + const t = value.trim(); + if (!t) return null; + const n = Number(t); + return Number.isFinite(n) ? n : null; + } + if (typeof value?.toString === 'function') { + try { + const n = Number(String(value.toString()).trim()); + return Number.isFinite(n) ? n : null; + } catch { + return null; + } + } + return null; +} + +function clampSeriesLimit(value, fallback) { + const n = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(n) || !Number.isInteger(n)) return fallback; + return Math.min(10_000, Math.max(1, n)); +} + function parseTimeframeToSeconds(tf) { const v = String(tf || '').trim().toLowerCase(); if (!v) return 60; @@ -359,6 +391,43 @@ function parseTimeframeToSeconds(tf) { return num * mult; } +function isTruthy(value) { + if (typeof value === 'boolean') return value; + const v = String(value ?? '').trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} + +const solPriceCache = { ts: 0, value: null }; + +async function readSolPriceUsd(cfg) { + const now = Date.now(); + if (now - solPriceCache.ts < 5000) return solPriceCache.value; + + const table = cfg.ticksTable; + const query = ` + query SolPrice($where: ${table}_bool_exp!) { + ${table}(where: $where, order_by: {ts: desc}, limit: 1) { + oracle_price + mark_price + ts + } + } + `; + + try { + const data = await hasuraRequest(cfg, { admin: true }, query, { where: { symbol: { _eq: 'SOL-PERP' } } }); + const row = (data?.[table] || [])[0]; + const price = parseNumeric(row?.oracle_price) ?? parseNumeric(row?.mark_price) ?? null; + solPriceCache.ts = now; + solPriceCache.value = price; + return price; + } catch { + solPriceCache.ts = now; + solPriceCache.value = null; + return null; + } +} + function mean(values) { if (!values.length) return 0; return values.reduce((a, b) => a + b, 0) / values.length; @@ -634,6 +703,329 @@ async function handler(cfg, req, res) { return; } + // Estimate costs for a new contract (read scope). + // Uses DLOB slippage table and simple fee/tx estimates (inputs). + if (req.method === 'POST' && pathname === '/v1/contracts/costs/estimate') { + const auth = await requireValidToken(cfg, req, 'read'); + if (!auth.ok) { + sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin); + return; + } + + let body; + try { + body = await readBodyJson(req, { maxBytes: 256 * 1024 }); + } catch (err) { + const msg = String(err?.message || err); + if (msg === 'payload_too_large') { + sendJson(res, 413, { ok: false, error: 'payload_too_large' }, cfg.corsOrigin); + return; + } + sendJson(res, 400, { ok: false, error: 'invalid_json' }, cfg.corsOrigin); + return; + } + + const market = String(body?.market_name || body?.market || '').trim(); + if (!market) { + sendJson(res, 400, { ok: false, error: 'missing_market' }, cfg.corsOrigin); + return; + } + + const notionalUsd = parseNumeric(body?.notional_usd ?? body?.notionalUsd ?? body?.size_usd ?? body?.sizeUsd); + if (notionalUsd == null || !(notionalUsd > 0)) { + sendJson(res, 400, { ok: false, error: 'invalid_notional_usd' }, cfg.corsOrigin); + return; + } + + const sideRaw = String(body?.side || 'long').trim().toLowerCase(); + const entrySide = sideRaw === 'short' || sideRaw === 'sell' ? 'short' : 'long'; + + const defaultTakerBps = parseNumeric(process.env.FEE_TAKER_BPS_DEFAULT) ?? 5; + const defaultMakerBps = parseNumeric(process.env.FEE_MAKER_BPS_DEFAULT) ?? 0; + const takerBps = clampNumber(parseNumeric(body?.fee_taker_bps) ?? defaultTakerBps, 0, 1000, defaultTakerBps); + const makerBps = clampNumber(parseNumeric(body?.fee_maker_bps) ?? defaultMakerBps, -1000, 1000, defaultMakerBps); + + const orderType = String(body?.order_type || body?.orderType || 'market').trim().toLowerCase(); + const isMarket = orderType === 'market' || orderType === 'taker'; + const feeBps = isMarket ? takerBps : makerBps; + + let txFeeUsdEst = parseNumeric(body?.tx_fee_usd_est); + if (txFeeUsdEst == null) { + const baseLamports = parseNumeric(process.env.TX_BASE_FEE_LAMPORTS_EST) ?? 5000; + const sigs = parseNumeric(process.env.TX_SIGNATURES_EST) ?? 1; + const priorityLamports = parseNumeric(process.env.TX_PRIORITY_FEE_LAMPORTS_EST) ?? 0; + const lamports = Math.max(0, baseLamports) * Math.max(1, sigs) + Math.max(0, priorityLamports); + const sol = lamports / 1_000_000_000; + const solUsd = await readSolPriceUsd(cfg); + txFeeUsdEst = solUsd != null ? sol * solUsd : 0; + } + txFeeUsdEst = clampNumber(txFeeUsdEst, 0, 100, 0); + + const defaultReprices = parseNumeric(process.env.EXPECTED_REPRICES_PER_ENTRY_DEFAULT) ?? 0; + const expectedReprices = clampInt(body?.expected_reprices_per_entry ?? body?.expectedReprices ?? String(defaultReprices), 0, 500); + const modifyTxCount = clampInt(body?.modify_tx_count ?? body?.modifyTxCount ?? '2', 0, 10); + + const wantedSide = entrySide === 'long' ? 'buy' : 'sell'; + + const pickNearest = (rows) => { + const candidates = (rows || []).filter((r) => String(r.side || '').toLowerCase() === wantedSide); + let best = null; + let bestD = Number.POSITIVE_INFINITY; + for (const r of candidates) { + const s = parseNumeric(r.size_usd); + if (s == null) continue; + const d = Math.abs(s - notionalUsd); + if (d < bestD) { + bestD = d; + best = r; + } + } + return best; + }; + + const fetchSlippageRows = async (tableName) => { + const query = ` + query Slippage($market: String!, $limit: Int!) { + ${tableName}(where: {market_name: {_eq: $market}}, order_by: {size_usd: asc}, limit: $limit) { + market_name + side + size_usd + mid_price + vwap_price + worst_price + impact_bps + fill_pct + updated_at + } + } + `; + const data = await hasuraRequest(cfg, { admin: true }, query, { market, limit: 500 }); + return data?.[tableName] || []; + }; + + try { + let rows = []; + let usedTable = null; + try { + rows = await fetchSlippageRows('dlob_slippage_latest_v2'); + usedTable = 'dlob_slippage_latest_v2'; + } catch (e) { + rows = await fetchSlippageRows('dlob_slippage_latest'); + usedTable = 'dlob_slippage_latest'; + } + + const best = pickNearest(rows); + const impactBps = parseNumeric(best?.impact_bps) ?? 0; + const slippageUsd = (notionalUsd * impactBps) / 10_000; + const tradeFeeUsd = (notionalUsd * feeBps) / 10_000; + const modifyCostUsd = expectedReprices * modifyTxCount * txFeeUsdEst; + const totalUsd = tradeFeeUsd + slippageUsd + txFeeUsdEst + modifyCostUsd; + const totalBps = (totalUsd / notionalUsd) * 10_000; + + sendJson( + res, + 200, + { + ok: true, + input: { + market_name: market, + notional_usd: notionalUsd, + side: entrySide, + order_type: orderType, + fee_bps: feeBps, + tx_fee_usd_est: txFeeUsdEst, + expected_reprices_per_entry: expectedReprices, + }, + dlob: best + ? { + table: usedTable, + size_usd: best.size_usd, + side: best.side, + mid_price: best.mid_price, + vwap_price: best.vwap_price, + impact_bps: best.impact_bps, + fill_pct: best.fill_pct, + updated_at: best.updated_at, + } + : null, + breakdown: { + trade_fee_usd: tradeFeeUsd, + slippage_usd: slippageUsd, + tx_fee_usd: txFeeUsdEst, + expected_modify_usd: modifyCostUsd, + total_usd: totalUsd, + total_bps: totalBps, + breakeven_bps: totalBps, + }, + }, + cfg.corsOrigin + ); + } catch (err) { + sendJson(res, 500, { ok: false, error: String(err?.message || err) }, cfg.corsOrigin); + } + return; + } + + // Monitor a pushed contract (read scope). + // Best-effort: if bot tables are not present yet in Hasura schema, return a clear error. + const monitorMatch = pathname.match(/^\/v1\/contracts\/([0-9a-f-]{36})\/monitor$/i); + if (req.method === 'GET' && monitorMatch) { + const auth = await requireValidToken(cfg, req, 'read'); + if (!auth.ok) { + sendJson(res, auth.status, { ok: false, error: auth.error }, cfg.corsOrigin); + return; + } + + const contractId = monitorMatch[1]; + const eventsLimit = clampSeriesLimit(url.searchParams.get('eventsLimit') || '2000', 2000); + const wantSeries = isTruthy(url.searchParams.get('series') || ''); + const seriesMax = clampSeriesLimit(url.searchParams.get('seriesMax') || '600', 600); + + const pickNum = (obj, keys) => { + if (!obj || typeof obj !== 'object') return 0; + for (const k of keys) { + const v = obj[k]; + const n = parseNumeric(v); + if (n != null) return n; + } + return 0; + }; + + try { + const q = ` + query ContractMonitor($id: uuid!, $limit: Int!) { + bot_contracts_by_pk(id: $id) { id } + bot_events(where: {contract_id: {_eq: $id}}, order_by: {created_at: asc}, limit: $limit) { + id + created_at + type + payload + } + } + `; + const data = await hasuraRequest(cfg, { admin: true }, q, { id: contractId, limit: eventsLimit }); + const contract = data?.bot_contracts_by_pk || null; + const events = Array.isArray(data?.bot_events) ? data.bot_events : []; + + const costs = { + tradeFeeUsd: 0, + txFeeUsd: 0, + slippageUsd: 0, + fundingUsd: 0, + realizedPnlUsd: 0, + totalCostsUsd: 0, + netPnlUsd: 0, + txCount: 0, + fillCount: 0, + cancelCount: 0, + modifyCount: 0, + errorCount: 0, + }; + + const series = []; + let cumTrade = 0; + let cumTx = 0; + let cumSlip = 0; + let cumFunding = 0; + let cumRealized = 0; + + for (const ev of events) { + const payload = ev?.payload && typeof ev.payload === 'object' ? ev.payload : {}; + const type = String(ev?.type || '').toLowerCase(); + + const tradeFeeUsd = pickNum(payload, ['trade_fee_usd', 'tradeFeeUsd', 'fee_usd', 'feeUsd']); + const txFeeUsd = pickNum(payload, ['tx_fee_usd', 'txFeeUsd', 'tx_usd', 'txUsd']); + const slippageUsd = pickNum(payload, ['slippage_usd', 'slippageUsd', 'impact_usd', 'impactUsd']); + const fundingUsd = pickNum(payload, ['funding_usd', 'fundingUsd']); + const realizedPnlUsd = pickNum(payload, ['realized_pnl_usd', 'realizedPnlUsd', 'pnl_usd', 'pnlUsd']); + + const txCount = Math.max( + 0, + Math.round( + pickNum(payload, ['tx_count', 'txCount', 'txs', 'txs_count']) || + (txFeeUsd > 0 ? 1 : 0) + ) + ); + + cumTrade += tradeFeeUsd; + cumTx += txFeeUsd; + cumSlip += slippageUsd; + cumFunding += fundingUsd; + cumRealized += realizedPnlUsd; + + costs.tradeFeeUsd = cumTrade; + costs.txFeeUsd = cumTx; + costs.slippageUsd = cumSlip; + costs.fundingUsd = cumFunding; + costs.realizedPnlUsd = cumRealized; + costs.txCount += txCount; + + const isFill = type.includes('fill'); + const isCancel = type.includes('cancel'); + const isModify = type.includes('modify') || type.includes('replace') || type.includes('reprice'); + const isError = type.includes('error') || type.includes('panic'); + + if (isFill) costs.fillCount += 1; + if (isCancel) costs.cancelCount += 1; + if (isModify) costs.modifyCount += 1; + if (isError) costs.errorCount += 1; + + if (wantSeries) { + const totalCostsUsd = cumTrade + cumTx + cumSlip + cumFunding; + const netPnlUsd = cumRealized - totalCostsUsd; + series.push({ + ts: ev?.created_at || getIsoNow(), + tradeFeeUsd: cumTrade, + txFeeUsd: cumTx, + slippageUsd: cumSlip, + fundingUsd: cumFunding, + totalCostsUsd, + realizedPnlUsd: cumRealized, + netPnlUsd, + }); + } + } + + costs.totalCostsUsd = costs.tradeFeeUsd + costs.txFeeUsd + costs.slippageUsd + costs.fundingUsd; + costs.netPnlUsd = costs.realizedPnlUsd - costs.totalCostsUsd; + + // Downsample to seriesMax if needed (keep last point). + let outSeries = null; + if (wantSeries) { + if (series.length <= seriesMax) { + outSeries = series; + } else { + const step = Math.max(1, Math.floor(series.length / seriesMax)); + const sampled = []; + for (let i = 0; i < series.length; i += step) sampled.push(series[i]); + if (sampled[sampled.length - 1] !== series[series.length - 1]) sampled.push(series[series.length - 1]); + outSeries = sampled.slice(-seriesMax); + } + } + + sendJson( + res, + 200, + { + ok: true, + contract, + eventsCount: events.length, + costs, + series: outSeries, + }, + cfg.corsOrigin + ); + } catch (err) { + const msg = String(err?.message || err); + if (msg.includes('bot_contracts') || msg.includes('bot_events')) { + sendJson(res, 501, { ok: false, error: 'bot_tables_missing' }, cfg.corsOrigin); + return; + } + sendJson(res, 500, { ok: false, error: msg }, cfg.corsOrigin); + } + return; + } + if (req.method === 'POST' && pathname === '/v1/ingest/tick') { const auth = await requireValidToken(cfg, req, 'write'); if (!auth.ok) {