feat: implement buy-low-sell-high market maker (no splitPosition)
New market maker strategy that places limit BUY on UP+DOWN at low price (default 2c), then sells at target price (default 3c) when filled. Key features: - Concurrent limit BUY on both sides (multi-thread style via Promise.all) - Immediate partial sell placement on partial fill detection - Cancel opposite side when one fills completely - WebSocket orderbook client for real-time simulation visualization - Both TUI (maker.js) and bot (maker-bot.js) entry points Files: - services/makerDetector.js: market detection (reuses slug pattern) - services/makerExecutor.js: core buy-low-sell-high strategy - services/makerWs.js: WebSocket orderbook for live data - maker.js: TUI version with blessed dashboard - maker-bot.js: PM2/VPS plain-text version 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
991fd6caa4
@@ -160,6 +160,36 @@ SNIPER_SCHEDULE_ETH=11:40-15:40,16:40-19:40
|
||||
SNIPER_SCHEDULE_SOL=09:40-12:40,21:40-23:40
|
||||
SNIPER_SCHEDULE_XRP=18:40-20:40,08:40-09:50
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# MARKET MAKER v2 (maker.js / npm run maker-sim)
|
||||
# Buy Low, Sell High — no splitPosition.
|
||||
# Places limit BUY on UP+DOWN at low price, sells at target when filled.
|
||||
# Only one side will fill — the other gets cancelled.
|
||||
# ─────────────────────────────────────────────
|
||||
# Comma-separated assets
|
||||
MAKER_ASSETS=btc
|
||||
|
||||
# Market duration: "5m" or "15m"
|
||||
MAKER_DURATION=5m
|
||||
|
||||
# Limit BUY price (e.g. 0.02 = 2 cents per share)
|
||||
MAKER_BUY_PRICE=0.02
|
||||
|
||||
# Limit SELL price (e.g. 0.03 = 3 cents per share)
|
||||
MAKER_SELL_PRICE=0.03
|
||||
|
||||
# Shares per side (e.g. 50 shares × 0.02 = $1.00 cost per side)
|
||||
MAKER_TRADE_SIZE=50
|
||||
|
||||
# Seconds before market close to trigger cut-loss
|
||||
MAKER_CUT_LOSS_TIME=60
|
||||
|
||||
# How often to poll for new markets (seconds)
|
||||
MAKER_POLL_INTERVAL=10
|
||||
|
||||
# How fast to check order fill status (milliseconds)
|
||||
MAKER_MONITOR_MS=2000
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PROXY (Polymarket API only, NOT Polygon RPC)
|
||||
# Supports HTTP/HTTPS/SOCKS5 proxies
|
||||
|
||||
+7
-1
@@ -21,7 +21,13 @@
|
||||
"sniper-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper.js",
|
||||
"sniper-tui": "DRY_RUN=false node src/sniper-tui.js",
|
||||
"sniper-tui-sim": "DRY_RUN=true node src/sniper-tui.js",
|
||||
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js"
|
||||
"sniper-tui-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/sniper-tui.js",
|
||||
"maker": "DRY_RUN=false node src/maker.js",
|
||||
"maker-sim": "DRY_RUN=true node src/maker.js",
|
||||
"maker-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker.js",
|
||||
"maker-bot": "node src/maker-bot.js",
|
||||
"maker-bot-sim": "DRY_RUN=true node src/maker-bot.js",
|
||||
"maker-bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/maker-bot.js"
|
||||
},
|
||||
"keywords": [
|
||||
"polymarket",
|
||||
|
||||
@@ -103,6 +103,19 @@ const config = {
|
||||
return schedule;
|
||||
})(),
|
||||
|
||||
// ── Market Maker v2 (Buy Low, Sell High) ──────────────────────
|
||||
// Places limit BUY on UP+DOWN at low price, sells at higher price when filled.
|
||||
// No splitPosition — pure orderbook strategy.
|
||||
makerAssets: (process.env.MAKER_ASSETS || 'btc')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||
makerDuration: process.env.MAKER_DURATION || '5m',
|
||||
makerBuyPrice: parseFloat(process.env.MAKER_BUY_PRICE || '0.02'),
|
||||
makerSellPrice: parseFloat(process.env.MAKER_SELL_PRICE || '0.03'),
|
||||
makerTradeSize: parseFloat(process.env.MAKER_TRADE_SIZE || '50'), // shares per side
|
||||
makerCutLossTime: parseInt(process.env.MAKER_CUT_LOSS_TIME || '60', 10),
|
||||
makerPollInterval: parseInt(process.env.MAKER_POLL_INTERVAL || '10', 10) * 1000,
|
||||
makerMonitorMs: parseInt(process.env.MAKER_MONITOR_MS || '2000', 10), // how fast to poll order status
|
||||
|
||||
// ── Proxy (Polymarket API only, NOT Polygon RPC) ──────────────
|
||||
// Supports HTTP/HTTPS. Example: http://user:pass@host:port
|
||||
proxyUrl: process.env.PROXY_URL || '',
|
||||
@@ -135,4 +148,19 @@ export function validateMMConfig() {
|
||||
throw new Error('MM_SELL_PRICE must be between 0 and 1');
|
||||
}
|
||||
|
||||
// Validation for maker bot (buy low, sell high)
|
||||
export function validateMakerConfig() {
|
||||
const required = ['privateKey', 'proxyWallet'];
|
||||
const missing = required.filter((key) => !config[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required config: ${missing.join(', ')}. Check your .env file.`);
|
||||
}
|
||||
if (config.makerBuyPrice <= 0 || config.makerBuyPrice >= 1)
|
||||
throw new Error('MAKER_BUY_PRICE must be between 0 and 1');
|
||||
if (config.makerSellPrice <= config.makerBuyPrice)
|
||||
throw new Error('MAKER_SELL_PRICE must be greater than MAKER_BUY_PRICE');
|
||||
if (config.makerTradeSize <= 0)
|
||||
throw new Error('MAKER_TRADE_SIZE must be > 0');
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* maker-bot.js — Buy Low, Sell High Market Maker, PM2 / VPS entry point (no TUI)
|
||||
*
|
||||
* Plain-text stdout output, compatible with:
|
||||
* pm2 start ecosystem.config.cjs --only polymarket-maker
|
||||
* pm2 logs polymarket-maker
|
||||
*/
|
||||
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMakerConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getUsdcBalance } from './services/client.js';
|
||||
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
|
||||
import { executeMakerStrategy, getActiveMakerPositions } from './services/makerExecutor.js';
|
||||
import { OrderbookWs } from './services/makerWs.js';
|
||||
|
||||
logger.interceptConsole();
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMakerConfig();
|
||||
} catch (err) {
|
||||
logger.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── WebSocket orderbook (for sim mode) ───────────────────────────────────────
|
||||
|
||||
const orderbookWs = new OrderbookWs();
|
||||
|
||||
// ── Periodic status log ──────────────────────────────────────────────────────
|
||||
|
||||
async function printStatus() {
|
||||
try {
|
||||
let balanceStr = 'SIM';
|
||||
if (!config.dryRun) {
|
||||
try { balanceStr = `$${(await getUsdcBalance()).toFixed(2)} USDC`; } catch { balanceStr = 'N/A'; }
|
||||
}
|
||||
|
||||
const positions = getActiveMakerPositions();
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
|
||||
logger.info(`--- MAKER Status [${mode}] | Balance: ${balanceStr} | Active: ${positions.length} ---`);
|
||||
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||
const label = pos.question.substring(0, 50);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60 ? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s` : `${secsLeft}s`;
|
||||
const pnl = pos.totalRevenue - pos.totalCost;
|
||||
const sign = pnl >= 0 ? '+' : '';
|
||||
|
||||
logger.info(
|
||||
` ${assetTag}${label} | ${pos.status} | ${timeStr} left` +
|
||||
` | UP: ${pos.up.buyFilled.toFixed(1)}sh bought, ${pos.up.totalSellFilled.toFixed(1)}sh sold` +
|
||||
` | DOWN: ${pos.down.buyFilled.toFixed(1)}sh bought, ${pos.down.totalSellFilled.toFixed(1)}sh sold` +
|
||||
` | P&L: ${sign}$${pnl.toFixed(4)}`,
|
||||
);
|
||||
|
||||
// Orderbook snapshot in sim mode
|
||||
if (config.dryRun) {
|
||||
for (const [label, tokenId] of [['UP', pos.up.tokenId], ['DOWN', pos.down.tokenId]]) {
|
||||
const book = orderbookWs.getBook(tokenId);
|
||||
const bestBid = book.bids[0];
|
||||
const bestAsk = book.asks[0];
|
||||
if (bestBid || bestAsk) {
|
||||
logger.info(
|
||||
` ${label} book: bid $${bestBid?.price.toFixed(3) || '-'} × ${bestBid?.size.toFixed(0) || '-'}` +
|
||||
` | ask $${bestAsk?.price.toFixed(3) || '-'} × ${bestAsk?.size.toFixed(0) || '-'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Status check error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||
|
||||
const pendingByAsset = new Map();
|
||||
|
||||
async function runStrategy(market) {
|
||||
if (config.dryRun) {
|
||||
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
|
||||
}
|
||||
|
||||
try {
|
||||
await executeMakerStrategy(market);
|
||||
} catch (err) {
|
||||
logger.error(`MAKER strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
|
||||
}
|
||||
|
||||
const queued = pendingByAsset.get(market.asset);
|
||||
if (queued) {
|
||||
pendingByAsset.delete(market.asset);
|
||||
const secsLeft = Math.round((new Date(queued.endTime).getTime() - Date.now()) / 1000);
|
||||
|
||||
if (secsLeft > config.makerCutLossTime) {
|
||||
logger.success(`MAKER[${market.asset?.toUpperCase()}]: executing queued market (${secsLeft}s left)`);
|
||||
runStrategy(queued);
|
||||
} else {
|
||||
logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued market expired (${secsLeft}s left)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const active = getActiveMakerPositions();
|
||||
const isAssetBusy = active.some((p) => p.asset === market.asset);
|
||||
|
||||
if (isAssetBusy) {
|
||||
pendingByAsset.set(market.asset, market);
|
||||
logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued — will enter after current position clears`);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market);
|
||||
}
|
||||
|
||||
// ── Timers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const statusTimer = setInterval(printStatus, 60_000);
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MAKER: shutting down...');
|
||||
stopMakerDetector();
|
||||
orderbookWs.shutdown();
|
||||
clearInterval(statusTimer);
|
||||
setTimeout(() => process.exit(0), 300);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = config.dryRun ? 'SIMULATION' : 'LIVE';
|
||||
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
|
||||
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
|
||||
|
||||
logger.info(`=== Market Maker v2 [${mode}] ===`);
|
||||
logger.info(`Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
|
||||
logger.info(`Duration : ${config.makerDuration}`);
|
||||
logger.info(`Buy @ : $${config.makerBuyPrice} per share`);
|
||||
logger.info(`Sell @ : $${config.makerSellPrice} per share`);
|
||||
logger.info(`Size : ${config.makerTradeSize} shares/side`);
|
||||
logger.info(`Cost/side : $${costPerSide.toFixed(2)}`);
|
||||
logger.info(`Profit : $${profitPerCycle.toFixed(2)} per cycle`);
|
||||
logger.info(`Cut loss : ${config.makerCutLossTime}s before close`);
|
||||
logger.info('==========================================');
|
||||
|
||||
startMakerDetector(handleNewMarket);
|
||||
logger.success(`MAKER bot started — watching for ${config.makerDuration} ${config.makerAssets.join('/')} markets...`);
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* maker.js
|
||||
* TUI version — Buy Low, Sell High Market Maker (blessed dashboard).
|
||||
*
|
||||
* Strategy: Place limit BUY on UP+DOWN at low price, sell at target when filled.
|
||||
* No splitPosition — pure orderbook-based market making.
|
||||
*
|
||||
* Run with: npm run maker (live)
|
||||
* npm run maker-sim (simulation with real orderbook via WebSocket)
|
||||
*/
|
||||
|
||||
import './utils/proxy-patch.cjs';
|
||||
|
||||
import { validateMakerConfig } from './config/index.js';
|
||||
import config from './config/index.js';
|
||||
import logger from './utils/logger.js';
|
||||
import { initClient, getUsdcBalance } from './services/client.js';
|
||||
import { initDashboard, appendLog, updateStatus, isDashboardActive } from './ui/dashboard.js';
|
||||
import { startMakerDetector, stopMakerDetector } from './services/makerDetector.js';
|
||||
import { executeMakerStrategy, getActiveMakerPositions } from './services/makerExecutor.js';
|
||||
import { OrderbookWs } from './services/makerWs.js';
|
||||
|
||||
// ── Validate config ────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
validateMakerConfig();
|
||||
} catch (err) {
|
||||
console.error(`Config error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── Init TUI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
initDashboard();
|
||||
logger.setOutput(appendLog);
|
||||
|
||||
// ── Init CLOB client ──────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await initClient();
|
||||
} catch (err) {
|
||||
logger.error(`Client init error: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── WebSocket orderbook (for sim visualization) ──────────────────────────────
|
||||
|
||||
const orderbookWs = new OrderbookWs();
|
||||
let activeWsTokens = { up: null, down: null };
|
||||
|
||||
// ── Status panel refresh ──────────────────────────────────────────────────────
|
||||
|
||||
async function buildStatusContent() {
|
||||
const lines = [];
|
||||
|
||||
// Balance
|
||||
let balance = '?';
|
||||
if (!config.dryRun) {
|
||||
try { balance = (await getUsdcBalance()).toFixed(2); } catch { /* ignore */ }
|
||||
} else {
|
||||
balance = '{yellow-fg}SIM{/yellow-fg}';
|
||||
}
|
||||
lines.push('{bold}BALANCE{/bold}');
|
||||
lines.push(` USDC.e: {green-fg}$${balance}{/green-fg}`);
|
||||
lines.push('');
|
||||
|
||||
// Mode
|
||||
lines.push('{bold}MODE{/bold}');
|
||||
lines.push(` ${config.dryRun ? '{yellow-fg}SIMULATION{/yellow-fg}' : '{green-fg}LIVE{/green-fg}'}`);
|
||||
lines.push('');
|
||||
|
||||
// Maker Config
|
||||
lines.push('{bold}MAKER CONFIG{/bold}');
|
||||
lines.push(` Assets : ${config.makerAssets.join(', ').toUpperCase()}`);
|
||||
lines.push(` Duration : ${config.makerDuration}`);
|
||||
lines.push(` Buy @ : $${config.makerBuyPrice} per share`);
|
||||
lines.push(` Sell @ : $${config.makerSellPrice} per share`);
|
||||
lines.push(` Size : ${config.makerTradeSize} shares/side`);
|
||||
lines.push(` Cost/side: $${(config.makerTradeSize * config.makerBuyPrice).toFixed(2)}`);
|
||||
lines.push(` Profit : $${((config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize).toFixed(2)}/cycle`);
|
||||
lines.push(` Cut loss : ${config.makerCutLossTime}s before close`);
|
||||
lines.push('');
|
||||
|
||||
// Active positions
|
||||
const positions = getActiveMakerPositions();
|
||||
lines.push(`{bold}ACTIVE POSITIONS (${positions.length}){/bold}`);
|
||||
|
||||
if (positions.length === 0) {
|
||||
lines.push(' {gray-fg}Waiting for market...{/gray-fg}');
|
||||
} else {
|
||||
for (const pos of positions) {
|
||||
const assetTag = pos.asset ? `[${pos.asset.toUpperCase()}] ` : '';
|
||||
const label = pos.question.substring(0, 32);
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
const secsLeft = Math.max(0, Math.round(msLeft / 1000));
|
||||
const timeStr = secsLeft > 60
|
||||
? `${Math.floor(secsLeft / 60)}m${secsLeft % 60}s`
|
||||
: `{red-fg}${secsLeft}s{/red-fg}`;
|
||||
|
||||
lines.push(` {cyan-fg}${assetTag}${label}{/cyan-fg}`);
|
||||
lines.push(` Status : ${pos.status} | Time left: ${timeStr}`);
|
||||
|
||||
// UP side
|
||||
const upFill = pos.up.buyFilled > 0
|
||||
? `{green-fg}BOUGHT ${pos.up.buyFilled.toFixed(1)}sh{/green-fg}`
|
||||
: '{gray-fg}waiting...{/gray-fg}';
|
||||
const upSold = pos.up.totalSellFilled > 0
|
||||
? ` → {green-fg}SOLD ${pos.up.totalSellFilled.toFixed(1)}sh{/green-fg}`
|
||||
: pos.up.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
|
||||
lines.push(` UP ${upFill}${upSold}`);
|
||||
|
||||
// DOWN side
|
||||
const downFill = pos.down.buyFilled > 0
|
||||
? `{green-fg}BOUGHT ${pos.down.buyFilled.toFixed(1)}sh{/green-fg}`
|
||||
: '{gray-fg}waiting...{/gray-fg}';
|
||||
const downSold = pos.down.totalSellFilled > 0
|
||||
? ` → {green-fg}SOLD ${pos.down.totalSellFilled.toFixed(1)}sh{/green-fg}`
|
||||
: pos.down.sellOrders.length > 0 ? ' → {yellow-fg}selling...{/yellow-fg}' : '';
|
||||
lines.push(` DOWN ${downFill}${downSold}`);
|
||||
|
||||
// P&L
|
||||
const pnl = pos.totalRevenue - pos.totalCost;
|
||||
const pnlColor = pnl >= 0 ? 'green' : 'red';
|
||||
lines.push(` P&L: {${pnlColor}-fg}$${pnl.toFixed(4)}{/${pnlColor}-fg}`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Orderbook display (simulation mode)
|
||||
if (config.dryRun && activeWsTokens.up) {
|
||||
lines.push('{bold}LIVE ORDERBOOK{/bold}');
|
||||
|
||||
for (const [label, tokenId] of [['UP', activeWsTokens.up], ['DOWN', activeWsTokens.down]]) {
|
||||
if (!tokenId) continue;
|
||||
const book = orderbookWs.getBook(tokenId);
|
||||
const lastPrice = orderbookWs.getLastPrice(tokenId);
|
||||
|
||||
lines.push(` {cyan-fg}${label}{/cyan-fg} (last: $${lastPrice.toFixed(3)})`);
|
||||
|
||||
// Top 3 asks (reversed so lowest is closest to spread)
|
||||
const topAsks = book.asks.slice(0, 3).reverse();
|
||||
for (const ask of topAsks) {
|
||||
lines.push(` {red-fg}ASK $${ask.price.toFixed(3)} × ${ask.size.toFixed(0)}{/red-fg}`);
|
||||
}
|
||||
|
||||
// Spread
|
||||
const bestBid = book.bids[0]?.price || 0;
|
||||
const bestAsk = book.asks[0]?.price || 0;
|
||||
if (bestBid && bestAsk) {
|
||||
lines.push(` {gray-fg}--- spread: $${(bestAsk - bestBid).toFixed(3)} ---{/gray-fg}`);
|
||||
}
|
||||
|
||||
// Top 3 bids
|
||||
const topBids = book.bids.slice(0, 3);
|
||||
for (const bid of topBids) {
|
||||
lines.push(` {green-fg}BID $${bid.price.toFixed(3)} × ${bid.size.toFixed(0)}{/green-fg}`);
|
||||
}
|
||||
|
||||
// Recent trades
|
||||
const trades = orderbookWs.getRecentTrades(tokenId, 3);
|
||||
if (trades.length > 0) {
|
||||
lines.push(` Trades:`);
|
||||
for (const t of trades.reverse()) {
|
||||
const color = t.side === 'BUY' ? 'green' : 'red';
|
||||
lines.push(` {${color}-fg}${t.side} $${t.price.toFixed(3)} × ${t.size.toFixed(0)}{/${color}-fg}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
|
||||
let refreshTimer = null;
|
||||
|
||||
function startRefresh() {
|
||||
refreshTimer = setInterval(async () => {
|
||||
if (!isDashboardActive()) return;
|
||||
updateStatus(await buildStatusContent());
|
||||
}, 2000);
|
||||
buildStatusContent().then(updateStatus);
|
||||
}
|
||||
|
||||
// ── Market handler with per-asset queue ──────────────────────────────────────
|
||||
|
||||
const pendingByAsset = new Map();
|
||||
|
||||
async function runStrategy(market) {
|
||||
// Connect WebSocket for orderbook visualization in sim mode
|
||||
if (config.dryRun) {
|
||||
activeWsTokens = { up: market.yesTokenId, down: market.noTokenId };
|
||||
orderbookWs.subscribe(market.conditionId, [market.yesTokenId, market.noTokenId]);
|
||||
}
|
||||
|
||||
try {
|
||||
await executeMakerStrategy(market);
|
||||
} catch (err) {
|
||||
logger.error(`MAKER strategy error (${market.asset?.toUpperCase()}): ${err.message}`);
|
||||
}
|
||||
|
||||
// Disconnect WS after strategy ends
|
||||
if (config.dryRun) {
|
||||
activeWsTokens = { up: null, down: null };
|
||||
}
|
||||
|
||||
// Process queued market
|
||||
const queued = pendingByAsset.get(market.asset);
|
||||
if (queued) {
|
||||
pendingByAsset.delete(market.asset);
|
||||
const endMs = new Date(queued.endTime).getTime();
|
||||
const secsLeft = Math.round((endMs - Date.now()) / 1000);
|
||||
|
||||
if (secsLeft > config.makerCutLossTime) {
|
||||
logger.success(`MAKER[${market.asset?.toUpperCase()}]: executing queued market (${secsLeft}s left)`);
|
||||
runStrategy(queued);
|
||||
} else {
|
||||
logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued market expired (${secsLeft}s left)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewMarket(market) {
|
||||
const active = getActiveMakerPositions();
|
||||
const isAssetBusy = active.some((p) => p.asset === market.asset);
|
||||
|
||||
if (isAssetBusy) {
|
||||
pendingByAsset.set(market.asset, market);
|
||||
logger.warn(`MAKER[${market.asset?.toUpperCase()}]: queued — will enter after current position clears`);
|
||||
return;
|
||||
}
|
||||
|
||||
runStrategy(market);
|
||||
}
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────────
|
||||
|
||||
function shutdown() {
|
||||
logger.warn('MAKER: shutting down...');
|
||||
stopMakerDetector();
|
||||
orderbookWs.shutdown();
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const costPerSide = config.makerTradeSize * config.makerBuyPrice;
|
||||
const profitPerCycle = (config.makerSellPrice - config.makerBuyPrice) * config.makerTradeSize;
|
||||
logger.info(`MAKER starting — ${config.dryRun ? 'SIMULATION' : 'LIVE'}`);
|
||||
logger.info(`Assets: ${config.makerAssets.join(', ').toUpperCase()} | BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice}`);
|
||||
logger.info(`Size: ${config.makerTradeSize} sh/side | Cost: $${costPerSide.toFixed(2)}/side | Profit: $${profitPerCycle.toFixed(2)}/cycle`);
|
||||
|
||||
startRefresh();
|
||||
startMakerDetector(handleNewMarket);
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* makerDetector.js
|
||||
* Detects upcoming markets for the Maker strategy (buy low, sell high).
|
||||
* Same slug-based detection as mmDetector but reads from MAKER_* config.
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { proxyFetch } from '../utils/proxy.js';
|
||||
|
||||
const SLOT_SEC = config.makerDuration === '15m' ? 900 : 300;
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set();
|
||||
|
||||
function currentSlot() {
|
||||
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
|
||||
}
|
||||
|
||||
function nextSlot() {
|
||||
return currentSlot() + SLOT_SEC;
|
||||
}
|
||||
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-${config.makerDuration}-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await proxyFetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? market.minimumTickSize ?? '0.01'),
|
||||
};
|
||||
}
|
||||
|
||||
async function scheduleAsset(asset, slotTimestamp) {
|
||||
const key = `${asset}-${slotTimestamp}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
|
||||
const market = await fetchBySlug(asset, slotTimestamp);
|
||||
if (!market) return;
|
||||
|
||||
const data = extractMarketData(market, asset);
|
||||
if (!data) {
|
||||
logger.warn(`MAKER: skipping ${asset.toUpperCase()} slot ${slotTimestamp} — missing token IDs`);
|
||||
seenKeys.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
seenKeys.add(key);
|
||||
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
|
||||
if (elapsedSec > 15) {
|
||||
logger.info(`MAKER: ${asset.toUpperCase()} next slot already ${elapsedSec}s old — skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||
if (secsUntilOpen > 0) {
|
||||
logger.success(`MAKER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}" — placing orders (${secsUntilOpen}s before open)`);
|
||||
} else {
|
||||
logger.success(`MAKER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}" — placing orders now`);
|
||||
}
|
||||
|
||||
if (onMarketCb) onMarketCb(data);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const next = nextSlot();
|
||||
await Promise.all(config.makerAssets.map((asset) => scheduleAsset(asset, next)));
|
||||
} catch (err) {
|
||||
logger.error('MAKER detector poll error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
export function startMakerDetector(onNewMarket) {
|
||||
onMarketCb = onNewMarket;
|
||||
seenKeys.clear();
|
||||
|
||||
poll();
|
||||
pollTimer = setInterval(poll, config.makerPollInterval);
|
||||
|
||||
const ns = nextSlot();
|
||||
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
||||
logger.info(`MAKER detector started — assets: ${config.makerAssets.join(', ').toUpperCase()} | duration: ${config.makerDuration}`);
|
||||
logger.info(`Next slot: *-updown-${config.makerDuration}-${ns} (opens in ${secsUntil}s)`);
|
||||
logger.info(`Strategy: BUY @ $${config.makerBuyPrice} → SELL @ $${config.makerSellPrice} | ${config.makerTradeSize} shares/side`);
|
||||
}
|
||||
|
||||
export function stopMakerDetector() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/**
|
||||
* makerExecutor.js
|
||||
* Buy Low, Sell High Market Maker — no splitPosition.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Place concurrent limit BUY on UP + DOWN at makerBuyPrice (e.g. 2c)
|
||||
* 2. Monitor both orders in parallel (multi-thread style)
|
||||
* 3. When one side fills (even partial):
|
||||
* a. Immediately place limit SELL for filled shares at makerSellPrice (e.g. 3c)
|
||||
* b. Cancel the other side's buy order
|
||||
* 4. Partial fills → partial sells placed immediately
|
||||
* 5. Monitor sell orders until all filled or cut-loss
|
||||
* 6. Cut-loss: market sell remaining tokens
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import config from '../config/index.js';
|
||||
import { getClient } from './client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// In-memory store of active maker positions
|
||||
const activePositions = new Map();
|
||||
|
||||
export function getActiveMakerPositions() {
|
||||
return Array.from(activePositions.values());
|
||||
}
|
||||
|
||||
// ── Order helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function placeLimitBuy(tokenId, shares, price, tickSize, negRisk) {
|
||||
if (config.dryRun) {
|
||||
return { success: true, orderId: `sim-buy-${Date.now()}-${tokenId.slice(-6)}` };
|
||||
}
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{ tokenID: tokenId, side: Side.BUY, price, size: shares },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
if (!res?.success) return { success: false };
|
||||
return { success: true, orderId: res.orderID };
|
||||
} catch (err) {
|
||||
logger.error('MAKER limit buy error:', err.message);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function placeLimitSell(tokenId, shares, price, tickSize, negRisk) {
|
||||
if (config.dryRun) {
|
||||
return { success: true, orderId: `sim-sell-${Date.now()}-${tokenId.slice(-6)}` };
|
||||
}
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{ tokenID: tokenId, side: Side.SELL, price, size: shares },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
if (!res?.success) return { success: false };
|
||||
return { success: true, orderId: res.orderID };
|
||||
} catch (err) {
|
||||
logger.error('MAKER limit sell error:', err.message);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelOrder(orderId) {
|
||||
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
|
||||
try {
|
||||
await getClient().cancelOrder({ orderID: orderId });
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn('MAKER cancel order error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function marketSell(tokenId, shares, tickSize, negRisk) {
|
||||
if (config.dryRun) {
|
||||
try {
|
||||
const mp = await getClient().getMidpoint(tokenId);
|
||||
const price = parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||
return { success: true, fillPrice: price };
|
||||
} catch {
|
||||
return { success: true, fillPrice: 0 };
|
||||
}
|
||||
}
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostMarketOrder(
|
||||
{ tokenID: tokenId, side: Side.SELL, amount: shares, price: 0.01 },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FOK,
|
||||
);
|
||||
if (!res?.success) return { success: false, fillPrice: 0 };
|
||||
return { success: true, fillPrice: parseFloat(res.price || '0') };
|
||||
} catch (err) {
|
||||
logger.error('MAKER market sell error:', err.message);
|
||||
return { success: false, fillPrice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Order status ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function getOrderFill(orderId) {
|
||||
if (!orderId || orderId.startsWith('sim-')) {
|
||||
return { matched: 0, status: 'LIVE', fullyFilled: false };
|
||||
}
|
||||
try {
|
||||
const order = await getClient().getOrder(orderId);
|
||||
if (!order) return { matched: 0, status: 'UNKNOWN', fullyFilled: false };
|
||||
const matched = parseFloat(order.size_matched || '0');
|
||||
return {
|
||||
matched,
|
||||
status: order.status,
|
||||
fullyFilled: order.status === 'MATCHED',
|
||||
};
|
||||
} catch {
|
||||
return { matched: 0, status: 'ERROR', fullyFilled: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Simulation: check if market price would fill our order
|
||||
async function simCheckFill(tokenId, side, price) {
|
||||
try {
|
||||
const mp = await getClient().getMidpoint(tokenId);
|
||||
const midPrice = parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||
if (side === 'buy' && midPrice <= price) return midPrice;
|
||||
if (side === 'sell' && midPrice >= price) return midPrice;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core strategy ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function executeMakerStrategy(market) {
|
||||
const { asset, conditionId, question, endTime, yesTokenId, noTokenId, negRisk, tickSize } = market;
|
||||
const tag = asset ? `[${asset.toUpperCase()}]` : '';
|
||||
const label = question.substring(0, 40);
|
||||
const sim = config.dryRun ? '[SIM] ' : '';
|
||||
const { makerBuyPrice, makerSellPrice, makerTradeSize, makerMonitorMs } = config;
|
||||
|
||||
logger.info(`MAKER${tag}: ${sim}entering — ${label}`);
|
||||
logger.info(`MAKER${tag}: BUY @ $${makerBuyPrice} → SELL @ $${makerSellPrice} | ${makerTradeSize} shares/side`);
|
||||
|
||||
// ── 1. Place BUY UP + DOWN concurrently ──────────────────────
|
||||
logger.trade(`MAKER${tag}: ${sim}placing BUY UP + DOWN @ $${makerBuyPrice}`);
|
||||
|
||||
const [upBuy, downBuy] = await Promise.all([
|
||||
placeLimitBuy(yesTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
|
||||
placeLimitBuy(noTokenId, makerTradeSize, makerBuyPrice, tickSize, negRisk),
|
||||
]);
|
||||
|
||||
if (!upBuy.success && !downBuy.success) {
|
||||
logger.error(`MAKER${tag}: both buy orders failed — aborting`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (upBuy.success) logger.trade(`MAKER${tag}: ${sim}UP BUY placed | order ${upBuy.orderId}`);
|
||||
if (downBuy.success) logger.trade(`MAKER${tag}: ${sim}DOWN BUY placed | order ${downBuy.orderId}`);
|
||||
|
||||
// ── 2. Build position state ──────────────────────────────────
|
||||
const pos = {
|
||||
asset: asset || 'btc',
|
||||
conditionId,
|
||||
question,
|
||||
endTime,
|
||||
tickSize,
|
||||
negRisk,
|
||||
status: 'buying',
|
||||
enteredAt: new Date().toISOString(),
|
||||
up: {
|
||||
tokenId: yesTokenId,
|
||||
buyOrderId: upBuy.success ? upBuy.orderId : null,
|
||||
buyFilled: 0,
|
||||
sellOrders: [], // { orderId, shares, filled, fillPrice }
|
||||
totalSellFilled: 0,
|
||||
cancelled: !upBuy.success,
|
||||
},
|
||||
down: {
|
||||
tokenId: noTokenId,
|
||||
buyOrderId: downBuy.success ? downBuy.orderId : null,
|
||||
buyFilled: 0,
|
||||
sellOrders: [],
|
||||
totalSellFilled: 0,
|
||||
cancelled: !downBuy.success,
|
||||
},
|
||||
winner: null, // 'up' or 'down'
|
||||
totalCost: 0,
|
||||
totalRevenue: 0,
|
||||
};
|
||||
|
||||
activePositions.set(conditionId, pos);
|
||||
|
||||
// ── 3. Monitor buy orders (concurrent multi-thread style) ────
|
||||
try {
|
||||
await monitorBuyPhase(pos, tag, sim);
|
||||
await monitorSellPhase(pos, tag, sim);
|
||||
} catch (err) {
|
||||
logger.error(`MAKER${tag}: strategy error — ${err.message}`);
|
||||
}
|
||||
|
||||
// ── Final P&L ────────────────────────────────────────────────
|
||||
const pnl = pos.totalRevenue - pos.totalCost;
|
||||
const sign = pnl >= 0 ? '+' : '';
|
||||
logger.money(`MAKER${tag}: ${sim}strategy complete | cost $${pos.totalCost.toFixed(4)} | revenue $${pos.totalRevenue.toFixed(4)} | P&L ${sign}$${pnl.toFixed(4)}`);
|
||||
|
||||
activePositions.delete(conditionId);
|
||||
}
|
||||
|
||||
// ── Buy phase: monitor both sides concurrently ───────────────────────────────
|
||||
|
||||
async function monitorBuyPhase(pos, tag, sim) {
|
||||
const { makerBuyPrice, makerSellPrice, makerMonitorMs, makerCutLossTime } = config;
|
||||
|
||||
// Run two concurrent monitors — first full fill wins
|
||||
const monitorSide = async (sideKey) => {
|
||||
const side = pos[sideKey];
|
||||
const otherKey = sideKey === 'up' ? 'down' : 'up';
|
||||
const sideName = sideKey.toUpperCase();
|
||||
|
||||
if (!side.buyOrderId) return; // order failed at placement
|
||||
|
||||
while (!pos.winner) {
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
// Cut-loss check
|
||||
if (msLeft <= makerCutLossTime * 1000) {
|
||||
logger.warn(`MAKER${tag}: buy phase cut-loss (${Math.round(msLeft / 1000)}s left) — cancelling buy orders`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check fill
|
||||
let fill;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simCheckFill(side.tokenId, 'buy', makerBuyPrice);
|
||||
if (hitPrice !== null) {
|
||||
fill = { matched: config.makerTradeSize, fullyFilled: true };
|
||||
} else {
|
||||
fill = { matched: 0, fullyFilled: false };
|
||||
}
|
||||
} else {
|
||||
fill = await getOrderFill(side.buyOrderId);
|
||||
}
|
||||
|
||||
// New fills detected → place sell immediately
|
||||
const newFill = fill.matched - side.buyFilled;
|
||||
if (newFill > 0) {
|
||||
side.buyFilled = fill.matched;
|
||||
pos.totalCost += newFill * makerBuyPrice;
|
||||
|
||||
logger.money(`MAKER${tag}: ${sim}${sideName} BUY filled ${newFill.toFixed(2)} shares @ $${makerBuyPrice} (total: ${side.buyFilled.toFixed(2)}/${config.makerTradeSize})`);
|
||||
|
||||
// Place sell immediately for the newly filled amount
|
||||
const sellResult = await placeLimitSell(side.tokenId, newFill, makerSellPrice, pos.tickSize, pos.negRisk);
|
||||
if (sellResult.success) {
|
||||
side.sellOrders.push({
|
||||
orderId: sellResult.orderId,
|
||||
shares: newFill,
|
||||
filled: false,
|
||||
fillPrice: null,
|
||||
});
|
||||
logger.trade(`MAKER${tag}: ${sim}${sideName} SELL placed ${newFill.toFixed(2)} shares @ $${makerSellPrice}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fully filled → we have a winner
|
||||
if (fill.fullyFilled) {
|
||||
pos.winner = sideKey;
|
||||
pos.status = 'selling';
|
||||
logger.success(`MAKER${tag}: ${sim}${sideName} fully filled! Cancelling ${otherKey.toUpperCase()} buy...`);
|
||||
|
||||
// Cancel the other side's buy
|
||||
const other = pos[otherKey];
|
||||
if (other.buyOrderId && !other.cancelled) {
|
||||
await cancelOrder(other.buyOrderId);
|
||||
other.cancelled = true;
|
||||
logger.info(`MAKER${tag}: ${otherKey.toUpperCase()} buy cancelled`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(makerMonitorMs);
|
||||
}
|
||||
};
|
||||
|
||||
// Run both side monitors concurrently — race to first full fill
|
||||
await Promise.race([
|
||||
monitorSide('up'),
|
||||
monitorSide('down'),
|
||||
]);
|
||||
|
||||
// If no winner (cut-loss), cancel all remaining buy orders
|
||||
if (!pos.winner) {
|
||||
pos.status = 'cut-buy';
|
||||
for (const key of ['up', 'down']) {
|
||||
const side = pos[key];
|
||||
if (side.buyOrderId && !side.cancelled) {
|
||||
await cancelOrder(side.buyOrderId);
|
||||
side.cancelled = true;
|
||||
}
|
||||
}
|
||||
// If any partial fills exist, still process sells
|
||||
const anyFill = pos.up.buyFilled > 0 || pos.down.buyFilled > 0;
|
||||
if (anyFill) {
|
||||
pos.winner = pos.up.buyFilled >= pos.down.buyFilled ? 'up' : 'down';
|
||||
pos.status = 'selling';
|
||||
logger.warn(`MAKER${tag}: partial fill — selling ${pos.winner.toUpperCase()} ${pos[pos.winner].buyFilled.toFixed(2)} shares`);
|
||||
} else {
|
||||
pos.status = 'done';
|
||||
logger.warn(`MAKER${tag}: no fills during buy phase — exiting with $0 loss`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sell phase: monitor all sell orders ───────────────────────────────────────
|
||||
|
||||
async function monitorSellPhase(pos, tag, sim) {
|
||||
if (pos.status === 'done') return; // nothing to sell
|
||||
|
||||
const { makerSellPrice, makerMonitorMs, makerCutLossTime } = config;
|
||||
const winnerKey = pos.winner;
|
||||
if (!winnerKey) return;
|
||||
|
||||
const side = pos[winnerKey];
|
||||
const sideName = winnerKey.toUpperCase();
|
||||
|
||||
logger.info(`MAKER${tag}: monitoring ${side.sellOrders.length} sell order(s) for ${sideName}`);
|
||||
|
||||
while (true) {
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
// Cut-loss: market sell remaining
|
||||
if (msLeft <= makerCutLossTime * 1000) {
|
||||
logger.warn(`MAKER${tag}: sell phase cut-loss (${Math.round(msLeft / 1000)}s left)`);
|
||||
await cutLossSells(pos, side, sideName, tag, sim);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check all sell orders concurrently
|
||||
const checks = await Promise.all(
|
||||
side.sellOrders.map(async (so) => {
|
||||
if (so.filled) return true;
|
||||
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simCheckFill(side.tokenId, 'sell', makerSellPrice);
|
||||
if (hitPrice !== null) {
|
||||
filled = true;
|
||||
so.fillPrice = hitPrice;
|
||||
}
|
||||
} else {
|
||||
const fill = await getOrderFill(so.orderId);
|
||||
if (fill.fullyFilled || fill.matched >= so.shares * 0.99) {
|
||||
filled = true;
|
||||
so.fillPrice = makerSellPrice;
|
||||
}
|
||||
}
|
||||
|
||||
if (filled) {
|
||||
so.filled = true;
|
||||
side.totalSellFilled += so.shares;
|
||||
pos.totalRevenue += so.shares * (so.fillPrice || makerSellPrice);
|
||||
logger.money(`MAKER${tag}: ${sim}${sideName} SELL filled ${so.shares.toFixed(2)} shares @ $${(so.fillPrice || makerSellPrice).toFixed(3)}`);
|
||||
}
|
||||
|
||||
return so.filled;
|
||||
})
|
||||
);
|
||||
|
||||
// All sells filled → done
|
||||
if (checks.every(Boolean) && side.sellOrders.length > 0) {
|
||||
pos.status = 'done';
|
||||
logger.success(`MAKER${tag}: ${sim}all sells filled!`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Market expired
|
||||
if (msLeft <= 0) {
|
||||
pos.status = 'expired';
|
||||
logger.warn(`MAKER${tag}: market expired`);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(makerMonitorMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function cutLossSells(pos, side, sideName, tag, sim) {
|
||||
const { makerSellPrice } = config;
|
||||
|
||||
// Cancel unfilled sell orders and market sell
|
||||
let remainingShares = 0;
|
||||
for (const so of side.sellOrders) {
|
||||
if (!so.filled) {
|
||||
await cancelOrder(so.orderId);
|
||||
remainingShares += so.shares;
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingShares > 0) {
|
||||
logger.warn(`MAKER${tag}: ${sim}market-selling ${remainingShares.toFixed(2)} ${sideName} shares`);
|
||||
const result = await marketSell(side.tokenId, remainingShares, pos.tickSize, pos.negRisk);
|
||||
if (result.success) {
|
||||
pos.totalRevenue += remainingShares * result.fillPrice;
|
||||
logger.warn(`MAKER${tag}: ${sim}${sideName} CL sold @ $${result.fillPrice.toFixed(3)}`);
|
||||
}
|
||||
}
|
||||
|
||||
pos.status = 'done';
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* makerWs.js
|
||||
* WebSocket client for Polymarket CLOB orderbook + trade data.
|
||||
* Used by the simulation to display real-time orderbook and simulate fills.
|
||||
*
|
||||
* Endpoints:
|
||||
* - Book updates (bids/asks)
|
||||
* - Last trade price
|
||||
* - Trade history
|
||||
*/
|
||||
|
||||
import WebSocket from 'ws';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const WS_URL = 'wss://ws-subscriptions-clob.polymarket.com/ws/market';
|
||||
const PING_INTERVAL = 10_000;
|
||||
const RECONNECT_DELAY = 3000;
|
||||
const MAX_RECONNECT_DELAY = 30_000;
|
||||
|
||||
export class OrderbookWs {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.pingTimer = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectDelay = RECONNECT_DELAY;
|
||||
this.isShutdown = false;
|
||||
|
||||
// Subscribed assets
|
||||
this.assetIds = [];
|
||||
this.conditionId = null;
|
||||
|
||||
// Orderbook state per asset
|
||||
this.books = new Map(); // assetId → { bids: [], asks: [] }
|
||||
|
||||
// Recent trades per asset
|
||||
this.trades = new Map(); // assetId → [{ price, side, size, timestamp }]
|
||||
|
||||
// Last trade price per asset
|
||||
this.lastPrice = new Map(); // assetId → number
|
||||
|
||||
// Callbacks
|
||||
this.onBookUpdate = null; // (assetId, book) => void
|
||||
this.onTradeUpdate = null; // (assetId, trade) => void
|
||||
this.onPriceUpdate = null; // (assetId, price) => void
|
||||
}
|
||||
|
||||
subscribe(conditionId, assetIds) {
|
||||
this.conditionId = conditionId;
|
||||
this.assetIds = assetIds;
|
||||
|
||||
for (const id of assetIds) {
|
||||
this.books.set(id, { bids: [], asks: [] });
|
||||
this.trades.set(id, []);
|
||||
}
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.isShutdown) return;
|
||||
|
||||
this.ws = new WebSocket(WS_URL);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
logger.info('MAKER WS: connected to orderbook feed');
|
||||
this.reconnectDelay = RECONNECT_DELAY;
|
||||
|
||||
// Subscribe to book + trades for all assets
|
||||
for (const assetId of this.assetIds) {
|
||||
this.ws.send(JSON.stringify({
|
||||
auth: {},
|
||||
type: 'subscribe',
|
||||
markets: [this.conditionId],
|
||||
assets_ids: [assetId],
|
||||
channels: ['book', 'trades'],
|
||||
}));
|
||||
}
|
||||
|
||||
this.startPing();
|
||||
});
|
||||
|
||||
this.ws.on('message', (raw) => {
|
||||
this.handleMessage(raw);
|
||||
});
|
||||
|
||||
this.ws.on('ping', () => {
|
||||
this.ws?.pong();
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this.cleanup(true);
|
||||
});
|
||||
|
||||
this.ws.on('error', (err) => {
|
||||
logger.warn(`MAKER WS error: ${err.message}`);
|
||||
this.cleanup(true);
|
||||
});
|
||||
}
|
||||
|
||||
handleMessage(raw) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
const text = raw.toString().trim();
|
||||
if (text === 'ping') this.ws?.send('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'ping' || msg === 'ping') {
|
||||
this.ws?.send('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle different event types from the CLOB WS
|
||||
const events = Array.isArray(msg) ? msg : [msg];
|
||||
|
||||
for (const evt of events) {
|
||||
const assetId = evt.asset_id;
|
||||
if (!assetId || !this.assetIds.includes(assetId)) continue;
|
||||
|
||||
switch (evt.event_type) {
|
||||
case 'book':
|
||||
this.handleBook(assetId, evt);
|
||||
break;
|
||||
case 'last_trade_price':
|
||||
this.handleLastPrice(assetId, evt);
|
||||
break;
|
||||
case 'tick_size_change':
|
||||
break; // ignore
|
||||
default:
|
||||
// Could be trade data
|
||||
if (evt.price && evt.side) {
|
||||
this.handleTrade(assetId, evt);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleBook(assetId, evt) {
|
||||
const bids = (evt.bids || []).map((b) => ({
|
||||
price: parseFloat(b.price),
|
||||
size: parseFloat(b.size),
|
||||
})).sort((a, b) => b.price - a.price);
|
||||
|
||||
const asks = (evt.asks || []).map((a) => ({
|
||||
price: parseFloat(a.price),
|
||||
size: parseFloat(a.size),
|
||||
})).sort((a, b) => a.price - b.price);
|
||||
|
||||
this.books.set(assetId, { bids, asks, timestamp: evt.timestamp });
|
||||
|
||||
if (this.onBookUpdate) {
|
||||
this.onBookUpdate(assetId, { bids, asks });
|
||||
}
|
||||
}
|
||||
|
||||
handleLastPrice(assetId, evt) {
|
||||
const price = parseFloat(evt.price || '0');
|
||||
this.lastPrice.set(assetId, price);
|
||||
|
||||
if (this.onPriceUpdate) {
|
||||
this.onPriceUpdate(assetId, price);
|
||||
}
|
||||
}
|
||||
|
||||
handleTrade(assetId, evt) {
|
||||
const trade = {
|
||||
price: parseFloat(evt.price || '0'),
|
||||
side: evt.side || '',
|
||||
size: parseFloat(evt.size || evt.amount || '0'),
|
||||
timestamp: evt.timestamp || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const trades = this.trades.get(assetId) || [];
|
||||
trades.push(trade);
|
||||
if (trades.length > 50) trades.splice(0, trades.length - 50);
|
||||
this.trades.set(assetId, trades);
|
||||
|
||||
if (this.onTradeUpdate) {
|
||||
this.onTradeUpdate(assetId, trade);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a simulated order would fill based on current orderbook.
|
||||
* @param {string} assetId - token ID
|
||||
* @param {'buy'|'sell'} side - order side
|
||||
* @param {number} price - limit price
|
||||
* @param {number} size - order size
|
||||
* @returns {{ filled: number, avgPrice: number } | null}
|
||||
*/
|
||||
checkSimFill(assetId, side, price, size) {
|
||||
const book = this.books.get(assetId);
|
||||
if (!book) return null;
|
||||
|
||||
if (side === 'buy') {
|
||||
// Buy order fills against asks at or below our price
|
||||
const eligible = book.asks.filter((a) => a.price <= price);
|
||||
if (eligible.length === 0) return null;
|
||||
|
||||
let filled = 0;
|
||||
let totalCost = 0;
|
||||
for (const ask of eligible) {
|
||||
const take = Math.min(ask.size, size - filled);
|
||||
filled += take;
|
||||
totalCost += take * ask.price;
|
||||
if (filled >= size) break;
|
||||
}
|
||||
|
||||
if (filled > 0) {
|
||||
return { filled: Math.min(filled, size), avgPrice: totalCost / filled };
|
||||
}
|
||||
} else {
|
||||
// Sell order fills against bids at or above our price
|
||||
const eligible = book.bids.filter((b) => b.price >= price);
|
||||
if (eligible.length === 0) return null;
|
||||
|
||||
let filled = 0;
|
||||
let totalRevenue = 0;
|
||||
for (const bid of eligible) {
|
||||
const take = Math.min(bid.size, size - filled);
|
||||
filled += take;
|
||||
totalRevenue += take * bid.price;
|
||||
if (filled >= size) break;
|
||||
}
|
||||
|
||||
if (filled > 0) {
|
||||
return { filled: Math.min(filled, size), avgPrice: totalRevenue / filled };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getBook(assetId) {
|
||||
return this.books.get(assetId) || { bids: [], asks: [] };
|
||||
}
|
||||
|
||||
getLastPrice(assetId) {
|
||||
return this.lastPrice.get(assetId) || 0;
|
||||
}
|
||||
|
||||
getRecentTrades(assetId, limit = 10) {
|
||||
const trades = this.trades.get(assetId) || [];
|
||||
return trades.slice(-limit);
|
||||
}
|
||||
|
||||
startPing() {
|
||||
this.stopPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send('ping');
|
||||
}
|
||||
}, PING_INTERVAL);
|
||||
}
|
||||
|
||||
stopPing() {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(reconnect = true) {
|
||||
this.stopPing();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners();
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
this.ws.terminate();
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
if (reconnect && !this.isShutdown) {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
this.connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.isShutdown = true;
|
||||
this.cleanup(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user