157 lines
6.4 KiB
JavaScript
157 lines
6.4 KiB
JavaScript
/**
|
||||
|
|
* sniperDetector.js
|
|||
|
|
* Detects upcoming 5-minute markets for configured assets (ETH, SOL, XRP, …)
|
|||
|
|
* using deterministic slug construction — same logic as mmDetector but for
|
|||
|
|
* multiple assets simultaneously.
|
|||
|
|
*
|
|||
|
|
* Slug format: {asset}-updown-5m-{eventStartTimestamp}
|
|||
|
|
* e.g. eth-updown-5m-1771790700
|
|||
|
|
* sol-updown-5m-1771790700
|
|||
|
|
* xrp-updown-5m-1771790700
|
|||
|
|
*
|
|||
|
|
* NEVER enters the currently active market — always the NEXT upcoming slot.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import config from '../config/index.js';
|
|||
|
|
import logger from '../utils/logger.js';
|
|||
|
|
|
|||
|
|
const SLOT_SEC = 5 * 60; // 300 seconds
|
|||
|
|
|
|||
|
|
let pollTimer = null;
|
|||
|
|
let onMarketCb = null;
|
|||
|
|
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already handled
|
|||
|
|
|
|||
|
|
// ── Slot helpers ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
function currentSlot() {
|
|||
|
|
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function nextSlot() {
|
|||
|
|
return currentSlot() + SLOT_SEC;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Gamma API fetch ───────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function fetchBySlug(asset, slotTimestamp) {
|
|||
|
|
const slug = `${asset}-updown-5m-${slotTimestamp}`;
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
|||
|
|
if (!resp.ok) return null;
|
|||
|
|
const data = await resp.json();
|
|||
|
|
return data?.conditionId ? data : null;
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Market data extraction ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
function extractMarketData(market, asset) {
|
|||
|
|
const conditionId = market.conditionId || market.condition_id || '';
|
|||
|
|
if (!conditionId) return null;
|
|||
|
|
|
|||
|
|
let tokenIds = market.clobTokenIds ?? market.clob_token_ids;
|
|||
|
|
if (typeof tokenIds === 'string') {
|
|||
|
|
try { tokenIds = JSON.parse(tokenIds); } catch { tokenIds = null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let yesTokenId, noTokenId;
|
|||
|
|
if (Array.isArray(tokenIds) && tokenIds.length >= 2) {
|
|||
|
|
[yesTokenId, noTokenId] = tokenIds;
|
|||
|
|
} else if (Array.isArray(market.tokens) && market.tokens.length >= 2) {
|
|||
|
|
yesTokenId = market.tokens[0]?.token_id ?? market.tokens[0]?.tokenId;
|
|||
|
|
noTokenId = market.tokens[1]?.token_id ?? market.tokens[1]?.tokenId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!yesTokenId || !noTokenId) return null;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
asset,
|
|||
|
|
conditionId,
|
|||
|
|
question: market.question || market.title || '',
|
|||
|
|
endTime: market.endDate || market.end_date_iso || market.endDateIso,
|
|||
|
|
eventStartTime: market.eventStartTime || market.event_start_time,
|
|||
|
|
yesTokenId: String(yesTokenId),
|
|||
|
|
noTokenId: String(noTokenId),
|
|||
|
|
negRisk: market.negRisk ?? market.neg_risk ?? false,
|
|||
|
|
tickSize: String(market.orderPriceMinTickSize ?? market.minimum_tick_size ?? '0.01'),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Schedule an asset slot ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
|
|||
|
|
const key = `${asset}-${slotTimestamp}`;
|
|||
|
|
if (seenKeys.has(key)) return;
|
|||
|
|
|
|||
|
|
const market = await fetchBySlug(asset, slotTimestamp);
|
|||
|
|
if (!market) return; // not in API yet, poll will retry
|
|||
|
|
|
|||
|
|
const data = extractMarketData(market, asset);
|
|||
|
|
if (!data) {
|
|||
|
|
logger.warn(`SNIPER: skipping ${asset} slot ${slotTimestamp} — missing token IDs`);
|
|||
|
|
seenKeys.add(key);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
seenKeys.add(key);
|
|||
|
|
|
|||
|
|
if (isCurrent) {
|
|||
|
|
// Current slot: only place orders if there's at least 30 seconds of market left
|
|||
|
|
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
|
|||
|
|
const secsLeft = Math.round((endAt - Date.now()) / 1000);
|
|||
|
|
if (secsLeft < 30) {
|
|||
|
|
logger.info(`SNIPER: ${asset.toUpperCase()} current market closing soon (${secsLeft}s) — skipping`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
logger.success(`SNIPER: ${asset.toUpperCase()} current market active (${secsLeft}s left) — placing orders now`);
|
|||
|
|
} else {
|
|||
|
|
// Next slot: market hasn't opened yet
|
|||
|
|
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
|||
|
|
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
|||
|
|
logger.success(`SNIPER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}"${secsUntilOpen > 0 ? ` — ${secsUntilOpen}s before open` : ''}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (onMarketCb) onMarketCb(data);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Poll ──────────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
async function poll() {
|
|||
|
|
try {
|
|||
|
|
const curr = currentSlot();
|
|||
|
|
const next = nextSlot();
|
|||
|
|
// Check current active market AND the upcoming next one, in parallel for each asset
|
|||
|
|
await Promise.all(config.sniperAssets.flatMap((asset) => [
|
|||
|
|
scheduleAsset(asset, curr, true), // current market (if still has time left)
|
|||
|
|
scheduleAsset(asset, next, false), // next upcoming market
|
|||
|
|
]));
|
|||
|
|
} catch (err) {
|
|||
|
|
logger.error('SNIPER detector poll error:', err.message);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export function startSniperDetector(onNewMarket) {
|
|||
|
|
onMarketCb = onNewMarket;
|
|||
|
|
seenKeys.clear();
|
|||
|
|
|
|||
|
|
poll();
|
|||
|
|
pollTimer = setInterval(poll, config.mmPollInterval);
|
|||
|
|
|
|||
|
|
const ns = nextSlot();
|
|||
|
|
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
|||
|
|
logger.info(`SNIPER detector started — assets: ${config.sniperAssets.join(', ').toUpperCase()}`);
|
|||
|
|
logger.info(`Next slot: *-updown-5m-${ns} (opens in ${secsUntil}s)`);
|
|||
|
|
logger.info(`Order: $${config.sniperPrice} × ${config.sniperShares} shares per side`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function stopSniperDetector() {
|
|||
|
|
if (pollTimer) {
|
|||
|
|
clearInterval(pollTimer);
|
|||
|
|
pollTimer = null;
|
|||
|
|
}
|
|||
|
|
}
|