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:
co-authored by
Claude Opus 4.6
parent
9e78fb1bfe
commit
a8f28c961b
@@ -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.
|
||||
|
||||
@@ -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
@@ -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', [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user