diff --git a/.env.example b/.env.example index 813283d..8018147 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,18 @@ SNIPER_TIER3_PRICE=0.01 # Lowest price, largest allocation (50%) # Example: 30 shares → 6@3c + 9@2c + 15@1c SNIPER_MAX_SHARES=15 +# ── Sniper Sizing Multiplier (UTC+8) ─────────────────────── +# Time-based bet sizing multiplier. Format: HH:MM-HH:MM:factor,... +# Multiplies SNIPER_MAX_SHARES during the specified time windows. +# Default = 1.0x outside any window. +# Example: US afternoon bump + Asia morning discount +SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85 + +# ── Sniper Pause After Win ───────────────────────────────── +# Number of 5-min rounds to pause an asset after a win is detected. +# Set to 0 to disable. +SNIPER_PAUSE_ROUNDS_AFTER_WIN=3 + # ── Sniper Session Schedule (all times UTC+8) ────────────── # Format: HH:MM-HH:MM,HH:MM-HH:MM (comma-separated sessions) # Assets without a schedule entry are always active. diff --git a/src/config/index.js b/src/config/index.js index 06b84d8..19d4fb4 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -88,6 +88,24 @@ const config = { sniperMaxShares: parseFloat(process.env.SNIPER_MAX_SHARES || '15'), // max total per side sniperMinSharesPerTier: 5, // minimum shares for each tier + // ── Sniper Sizing Multiplier (UTC+8) ────────────────────────── + // Time-based bet sizing multiplier. Format: HH:MM-HH:MM:factor,... + // Example: SNIPER_MULTIPLIERS=21:00-00:00:1.41,06:00-12:00:0.85 + // Default multiplier outside any window = 1.0 + sniperMultipliers: (() => { + const raw = process.env.SNIPER_MULTIPLIERS || ''; + if (!raw.trim()) return []; + return raw.split(',').map((s) => s.trim()).filter(Boolean).map((entry) => { + const m = entry.match(/^(\d{1,2}:\d{2})\s*[-–]\s*(\d{1,2}:\d{2}):(\d+\.?\d*)$/); + if (!m) return null; + return { start: m[1], end: m[2], multiplier: parseFloat(m[3]) }; + }).filter(Boolean); + })(), + + // ── Sniper Pause After Win ─────────────────────────────────── + // Number of rounds (5-min slots) to pause an asset after a win is detected. + sniperPauseRoundsAfterWin: parseInt(process.env.SNIPER_PAUSE_ROUNDS_AFTER_WIN || '3', 10), + // ── Sniper Schedule (UTC+8) ──────────────────────────────────── // Per-asset session windows. Format: SNIPER_SCHEDULE_{ASSET}=HH:MM-HH:MM,HH:MM-HH:MM // Assets without a schedule are always active. diff --git a/src/services/ctf.js b/src/services/ctf.js index 2d2478f..8d6a6d2 100644 --- a/src/services/ctf.js +++ b/src/services/ctf.js @@ -542,6 +542,17 @@ export async function redeemMMPositions() { const _failedConditions = new Set(); const _skippedLosses = new Set(); +// Callback invoked when a win is detected — receives conditionId +let _onWinCallback = null; + +/** + * Register a callback to be called when a sniper win is detected. + * Callback signature: (conditionId: string) => void + */ +export function onSniperWin(cb) { + _onWinCallback = cb; +} + /** * Redeem sniper positions via Gnosis Safe. * Only redeems WINNING positions — skip losses (they can be manually cleared). @@ -616,37 +627,46 @@ export async function redeemSniperPositions() { continue; } - // Estimate payout from numerators (for logging) - const payoutFractions = await Promise.all( + // Check outcome via payoutNumerators — which outcome index won? + const payoutNums = await Promise.all( [0, 1].map((i) => - ctf.payoutNumerators(conditionId, i) - .then((n) => n.toNumber() / denominator.toNumber()) + ctf.payoutNumerators(conditionId, i).then((n) => n.toNumber()) ) ); + const denom = denominator.toNumber(); + const payoutFractions = payoutNums.map((n) => n / denom); + + // Determine winning outcome index (the one with payoutFraction > 0) + const winningOutcome = payoutFractions[0] > 0 ? 0 : payoutFractions[1] > 0 ? 1 : -1; + + // Win = we hold shares on the winning outcome side + const isWin = winningOutcome >= 0 && balances[winningOutcome] > 0; const expectedUsdc = balances.reduce( (sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0 ); const label = conditionId.slice(0, 12) + '...'; - const isWin = expectedUsdc >= 0.01; // SNIPER: only redeem WINNERS — cache losses to skip next time if (!isWin) { _skippedLosses.add(conditionId); if (config.dryRun) { - logger.info(`SNIPER[SIM] skip loss: ${label} — ${totalShares.toFixed(3)} shares (cached, no future checks)`); + logger.info(`SNIPER[SIM] skip loss: ${label} — ${totalShares.toFixed(3)} shares, outcome=${winningOutcome} (cached)`); } else { - logger.info(`SNIPER redeemer: skip loss ${label} — cached for future cycles`); + logger.info(`SNIPER redeemer: skip loss ${label} — outcome=${winningOutcome}, no shares on winner`); } continue; } + // Track win for pause-after-win (notify via callback) + if (_onWinCallback) _onWinCallback(conditionId); + if (config.dryRun) { - logger.money(`SNIPER[SIM] redeem: ${label} — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`); + logger.money(`SNIPER[SIM] redeem: ${label} — ${balances[winningOutcome].toFixed(3)} shares on outcome ${winningOutcome} → ~$${expectedUsdc.toFixed(2)} USDC (WIN)`); continue; } - logger.info(`SNIPER redeemer: ${label} resolved WIN — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`); + logger.info(`SNIPER redeemer: ${label} resolved WIN — outcome ${winningOutcome}, ${balances[winningOutcome].toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`); // Call redeemPositions through Safe — winners only const data = ctfIface.encodeFunctionData('redeemPositions', [ diff --git a/src/services/sniperExecutor.js b/src/services/sniperExecutor.js index c2c8937..d51835a 100644 --- a/src/services/sniperExecutor.js +++ b/src/services/sniperExecutor.js @@ -4,21 +4,29 @@ * - Tier 1: 3c price, smallest size (20% of max) * - Tier 2: 2c price, medium size (30% of max) * - Tier 3: 1c price, largest size (50% of max) - * Min 5 shares per tier, total = SNIPER_MAX_SHARES + * Min 5 shares per tier, total = SNIPER_MAX_SHARES × timeMultiplier */ import { Side, OrderType } from '@polymarket/clob-client'; import config from '../config/index.js'; import { getClient } from './client.js'; import logger from '../utils/logger.js'; +import { getTimeMultiplier } from './sniperSizing.js'; // In-memory tracking of placed snipe orders const activeSnipes = []; +// conditionId → asset mapping (for win detection → pause linkage) +const conditionAssetMap = new Map(); + export function getActiveSnipes() { return [...activeSnipes]; } +export function getConditionAsset(conditionId) { + return conditionAssetMap.get(conditionId) || null; +} + /** * Calculate tier sizes based on max shares. * Distribution: 20% | 30% | 50% (high→low price) @@ -48,15 +56,26 @@ export async function executeSnipe(market) { const label = question.slice(0, 40); const sim = config.dryRun ? '[SIM] ' : ''; + // Track conditionId → asset for win detection + conditionAssetMap.set(conditionId, asset.toLowerCase()); + const sides = [ { name: 'UP', tokenId: yesTokenId }, { name: 'DOWN', tokenId: noTokenId }, ]; - const prices = config.sniperTierPrices; - const sizes = calculateTierSizes(config.sniperMaxShares, config.sniperMinSharesPerTier); + // Apply time-based multiplier + const { multiplier, label: mulLabel } = getTimeMultiplier(); + const effectiveMaxShares = Math.max( + config.sniperMinSharesPerTier * 3, + Math.round(config.sniperMaxShares * multiplier), + ); - logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}`); + const prices = config.sniperTierPrices; + const sizes = calculateTierSizes(effectiveMaxShares, config.sniperMinSharesPerTier); + + const mulInfo = multiplier !== 1.0 ? ` | mul ${mulLabel}` : ''; + logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | 3-tier: 3c×${sizes[0]} | 2c×${sizes[1]} | 1c×${sizes[2]}${mulInfo}`); for (const { name, tokenId } of sides) { // Place 3 orders per side diff --git a/src/services/sniperSizing.js b/src/services/sniperSizing.js new file mode 100644 index 0000000..72a42fd --- /dev/null +++ b/src/services/sniperSizing.js @@ -0,0 +1,50 @@ +/** + * sniperSizing.js + * Time-based multiplier for sniper bet sizing. + * All time windows are specified in UTC+8. + */ + +import config from '../config/index.js'; + +const UTC8_OFFSET = 8; + +/** + * Convert HH:MM (UTC+8) to minutes-since-midnight UTC. + */ +function utc8ToUtcMinutes(hhmm) { + const [h, m] = hhmm.split(':').map(Number); + let totalMin = (h * 60 + m) - (UTC8_OFFSET * 60); + if (totalMin < 0) totalMin += 1440; + if (totalMin >= 1440) totalMin -= 1440; + return totalMin; +} + +function inRange(nowMin, startMin, endMin) { + if (startMin <= endMin) { + return nowMin >= startMin && nowMin < endMin; + } + // overnight wrap + return nowMin >= startMin || nowMin < endMin; +} + +/** + * Get the current time multiplier based on configured SNIPER_MULTIPLIERS windows. + * Returns { multiplier, label } where label describes the active window (or 'default'). + */ +export function getTimeMultiplier() { + const windows = config.sniperMultipliers; + if (!windows || windows.length === 0) return { multiplier: 1.0, label: 'default' }; + + const now = new Date(); + const nowMin = now.getUTCHours() * 60 + now.getUTCMinutes(); + + for (const w of windows) { + const startMin = utc8ToUtcMinutes(w.start); + const endMin = utc8ToUtcMinutes(w.end); + if (inRange(nowMin, startMin, endMin)) { + return { multiplier: w.multiplier, label: `${w.start}-${w.end} UTC+8 → ${w.multiplier}x` }; + } + } + + return { multiplier: 1.0, label: 'default' }; +} diff --git a/src/sniper-tui.js b/src/sniper-tui.js index da9dde4..1d9703a 100644 --- a/src/sniper-tui.js +++ b/src/sniper-tui.js @@ -17,9 +17,10 @@ import { initClient } from './services/client.js'; import { getUsdcBalance } from './services/client.js'; import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js'; import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js'; -import { executeSnipe, getActiveSnipes } from './services/sniperExecutor.js'; -import { redeemSniperPositions } from './services/ctf.js'; +import { executeSnipe, getActiveSnipes, getConditionAsset } from './services/sniperExecutor.js'; +import { redeemSniperPositions, onSniperWin } from './services/ctf.js'; import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js'; +import { getTimeMultiplier } from './services/sniperSizing.js'; // ── Validate config ──────────────────────────────────────────────────────────── @@ -76,7 +77,20 @@ async function buildStatusContent() { lines.push(` 3-Tier : ${prices[0]}c/${prices[1]}c/${prices[2]}c`); lines.push(` Sizes : ${sizes[0]}/${sizes[1]}/${sizes[2]} shares`); const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]); - lines.push(` Cost : $${(costPerSide * 2 * config.sniperAssets.length).toFixed(3)} per slot`); + lines.push(` Cost : $${(costPerSide * 2 * config.sniperAssets.length).toFixed(3)} per slot (base)`); + const { multiplier, label: mulLabel } = getTimeMultiplier(); + if (config.sniperMultipliers.length > 0) { + lines.push(` Mul : ${mulLabel}`); + } + if (config.sniperPauseRoundsAfterWin > 0) { + lines.push(` Pause : ${config.sniperPauseRoundsAfterWin} rounds after win`); + } + // Show per-asset pause status + for (const a of config.sniperAssets) { + if (pauseCounters[a] > 0) { + lines.push(` {yellow-fg}${a.toUpperCase()} paused (${pauseCounters[a]} rounds){/yellow-fg}`); + } + } lines.push(''); // Session schedule @@ -139,9 +153,47 @@ function startRedeemer() { logger.info(`Sniper redeemer started — checking every ${config.redeemInterval / 1000}s`); } +// ── Pause-after-win tracking ───────────────────────────────────────────────── + +const pauseCounters = {}; + +function handleWin(conditionId) { + const asset = getConditionAsset(conditionId); + if (!asset) return; + const rounds = config.sniperPauseRoundsAfterWin; + pauseCounters[asset] = rounds; + logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`); +} + +function isAssetPaused(asset) { + const key = asset.toLowerCase(); + return pauseCounters[key] > 0; +} + +function tickPause(asset) { + const key = asset.toLowerCase(); + if (pauseCounters[key] > 0) { + pauseCounters[key]--; + if (pauseCounters[key] <= 0) { + logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`); + } + } +} + +onSniperWin(handleWin); + // ── Market handler ──────────────────────────────────────────────────────────── async function handleNewMarket(market) { + const asset = market.asset.toLowerCase(); + + tickPause(asset); + + if (isAssetPaused(asset)) { + logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`); + return; + } + executeSnipe(market).catch((err) => logger.error(`SNIPER execute error (${market.asset}): ${err.message}`) ); diff --git a/src/sniper.js b/src/sniper.js index 158657e..167ab8b 100644 --- a/src/sniper.js +++ b/src/sniper.js @@ -3,6 +3,11 @@ * Console-only entry point for the Orderbook Sniper bot. * Places tiny GTC BUY orders at a low price on both sides of 5-min markets. * + * Features: + * - Time-based multiplier sizing (SNIPER_MULTIPLIERS, UTC+8) + * - Pause N rounds per asset after a win (SNIPER_PAUSE_ROUNDS_AFTER_WIN) + * - Win detection via outcome (payoutNumerators), not redeem value + * * Run with: npm run sniper (live, console) * npm run sniper-sim (simulation, console) * @@ -14,9 +19,10 @@ import config from './config/index.js'; import logger from './utils/logger.js'; import { initClient } from './services/client.js'; import { startSniperDetector, stopSniperDetector } from './services/sniperDetector.js'; -import { executeSnipe } from './services/sniperExecutor.js'; -import { redeemSniperPositions } from './services/ctf.js'; +import { executeSnipe, getConditionAsset } from './services/sniperExecutor.js'; +import { redeemSniperPositions, onSniperWin } from './services/ctf.js'; import { getSchedule, isAssetInSession, getNextSessionInfo } from './services/schedule.js'; +import { getTimeMultiplier } from './services/sniperSizing.js'; // Set proxy before any network calls import './utils/proxy-patch.cjs'; @@ -44,6 +50,52 @@ try { process.exit(1); } +// ── Pause-after-win tracking ───────────────────────────────────────────────── + +// pauseCounters[asset] = number of rounds remaining to skip +const pauseCounters = {}; + +/** + * Called by the redeemer when a win is detected. + * Looks up the asset from the conditionId mapping and sets the pause counter. + */ +function handleWin(conditionId) { + const asset = getConditionAsset(conditionId); + if (!asset) { + logger.info(`SNIPER: win detected for ${conditionId.slice(0, 12)}... but no asset mapping found`); + return; + } + const rounds = config.sniperPauseRoundsAfterWin; + pauseCounters[asset] = rounds; + logger.success(`SNIPER: WIN on ${asset.toUpperCase()} — pausing ${rounds} rounds`); +} + +/** + * Check if an asset is currently paused due to a recent win. + * Each call with decrement=true counts as one round passing. + */ +function isAssetPaused(asset) { + const key = asset.toLowerCase(); + if (!pauseCounters[key] || pauseCounters[key] <= 0) return false; + return true; +} + +/** + * Decrement pause counter for an asset (called once per round/slot). + */ +function tickPause(asset) { + const key = asset.toLowerCase(); + if (pauseCounters[key] && pauseCounters[key] > 0) { + pauseCounters[key]--; + if (pauseCounters[key] <= 0) { + logger.info(`SNIPER: ${asset.toUpperCase()} pause ended — resuming`); + } + } +} + +// Register win callback +onSniperWin(handleWin); + // ── Log session schedule ────────────────────────────────────────────────────── function logSchedule() { @@ -83,6 +135,17 @@ function startRedeemer() { // ── Market handler ──────────────────────────────────────────────────────────── async function handleNewMarket(market) { + const asset = market.asset.toLowerCase(); + + // Tick pause counter for this asset (each new market = 1 round) + tickPause(asset); + + // Check if asset is paused after a recent win + if (isAssetPaused(asset)) { + logger.info(`SNIPER: ${asset.toUpperCase()} paused (${pauseCounters[asset]} rounds left) — skipping`); + return; + } + executeSnipe(market).catch((err) => logger.error(`SNIPER execute error (${market.asset}): ${err.message}`) ); @@ -108,7 +171,22 @@ const sizes = [Math.floor(config.sniperMaxShares * 0.20), Math.floor(config.snip const costPerSide = (sizes[0] * prices[0]) + (sizes[1] * prices[1]) + (sizes[2] * prices[2]); const costPerSlot = (costPerSide * 2 * config.sniperAssets.length).toFixed(3); logger.info(`SNIPER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`); -logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot`); +logger.info(`Assets: ${config.sniperAssets.join(', ').toUpperCase()} | 3-tier: 3c×${sizes[0]}+2c×${sizes[1]}+1c×${sizes[2]} = $${costPerSlot}/slot (base)`); + +// Log multiplier config +if (config.sniperMultipliers.length > 0) { + logger.info('─── Sizing Multipliers (UTC+8) ───'); + for (const w of config.sniperMultipliers) { + logger.info(` ${w.start}–${w.end} → ${w.multiplier}x`); + } + const { multiplier, label } = getTimeMultiplier(); + logger.info(` Current: ${label}`); + logger.info('──────────────────────────────────'); +} + +if (config.sniperPauseRoundsAfterWin > 0) { + logger.info(`Pause after win: ${config.sniperPauseRoundsAfterWin} rounds per asset`); +} logSchedule(); startRedeemer();