feat: add time-based multiplier sizing, pause-after-win, and outcome-based win detection

- Time-based bet sizing multiplier (SNIPER_MULTIPLIERS, UTC+8 windows)
- Pause N rounds per asset after win (SNIPER_PAUSE_ROUNDS_AFTER_WIN)
- Win detection via payoutNumerators outcome check instead of redeem value threshold

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
direkturcrypto
2026-03-11 12:53:48 +07:00
co-authored by Claude Opus 4.6
parent 9e78fb1bfe
commit a8f28c961b
7 changed files with 268 additions and 19 deletions
+12
View File
@@ -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.
+18
View File
@@ -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.
+29 -9
View File
@@ -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', [
+23 -4
View File
@@ -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
+50
View File
@@ -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' };
}
+55 -3
View File
@@ -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}`)
);
+81 -3
View File
@@ -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();