feat(api): add contract cost estimate + monitor endpoints
This commit is contained in:
@@ -347,6 +347,38 @@ function clampInt(value, min, max) {
|
|||||||
return Math.min(max, Math.max(min, n));
|
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) {
|
function parseTimeframeToSeconds(tf) {
|
||||||
const v = String(tf || '').trim().toLowerCase();
|
const v = String(tf || '').trim().toLowerCase();
|
||||||
if (!v) return 60;
|
if (!v) return 60;
|
||||||
@@ -359,6 +391,43 @@ function parseTimeframeToSeconds(tf) {
|
|||||||
return num * mult;
|
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) {
|
function mean(values) {
|
||||||
if (!values.length) return 0;
|
if (!values.length) return 0;
|
||||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
@@ -634,6 +703,329 @@ async function handler(cfg, req, res) {
|
|||||||
return;
|
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') {
|
if (req.method === 'POST' && pathname === '/v1/ingest/tick') {
|
||||||
const auth = await requireValidToken(cfg, req, 'write');
|
const auth = await requireValidToken(cfg, req, 'write');
|
||||||
if (!auth.ok) {
|
if (!auth.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user