Files
polymarket-terminal/src/services/executor.js
T
direkturcrypto 526076fe6e feat: full project overhaul — market maker, sniper, WebSocket watcher, terminal UI
- Rename project to polymarket-terminal
- Add Market Maker bot (src/mm.js) with on-chain CTF split/merge/redeem via Gnosis Safe
- Add Orderbook Sniper bot (src/sniper.js) with multi-asset GTC low-price orders
- Add WebSocket watcher (src/services/wsWatcher.js) for real-time RTDS trade events
- Add terminal dashboard UI (src/ui/dashboard.js) using blessed
- Add CTF contract helpers (src/services/ctf.js) for splitPosition, mergePositions, redeemPositions
- Add mmDetector, mmExecutor, sniperDetector, sniperExecutor services
- Add simStats utility for dry-run P&L tracking
- Translate all Indonesian-language strings to professional English across all files
- Rewrite README.md in English with full setup guide, configuration reference, and architecture overview
- Rewrite AGENT.MD in English as comprehensive AI agent and developer reference
- Update package.json name, description, scripts, and keywords

Co-Authored-By: direkturcrypto <direkturcrypto.x@mail3.me>
2026-02-23 23:03:06 +07:00

356 lines
13 KiB
JavaScript

import { Side, OrderType } from '@polymarket/clob-client';
import config from '../config/index.js';
import { getClient, getUsdcBalance } from './client.js';
import { hasPosition, addPosition, getPosition, updatePosition, removePosition } from './position.js';
import { fetchMarketByTokenId } from './watcher.js';
import { placeAutoSell } from './autoSell.js';
import { recordSimBuy } from '../utils/simStats.js';
import logger from '../utils/logger.js';
/**
* Calculate trade size for our entry — independent of the individual fill event.
*
* Limit orders can be filled in many small chunks; using the event's fill size
* would give inconsistent (often sub-minimum) results.
*
* SIZE_MODE=percentage → SIZE_PERCENT% of MAX_POSITION_SIZE per market
* SIZE_MODE=balance → SIZE_PERCENT% of our current USDC.e balance
*/
async function calculateTradeSize() {
if (config.sizeMode === 'percentage') {
return config.maxPositionSize * (config.sizePercent / 100);
} else if (config.sizeMode === 'balance') {
const balance = await getUsdcBalance();
return balance * (config.sizePercent / 100);
}
return 0;
}
/**
* Get market options (tick size and neg risk) for a token
*/
async function getMarketOptions(tokenId) {
const client = getClient();
try {
// Try to get from market info
const marketInfo = await fetchMarketByTokenId(tokenId);
if (marketInfo) {
return {
tickSize: String(marketInfo.minimum_tick_size || '0.01'),
negRisk: marketInfo.neg_risk || false,
conditionId: marketInfo.condition_id || '',
question: marketInfo.question || '',
};
}
} catch (err) {
logger.warn('Failed to get market info, using defaults:', err.message);
}
// Fallback: try SDK methods
try {
const tickSize = await client.getTickSize(tokenId);
const negRisk = await client.getNegRisk(tokenId);
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '' };
} catch (err) {
logger.warn('Failed to get tick size from SDK, using default 0.01');
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '' };
}
}
/**
* Execute a BUY trade (copy trader's buy)
* @param {Object} trade - Trade info from watcher
*/
export async function executeBuy(trade) {
const { tokenId, conditionId, market, price, size } = trade;
// Get market options first to resolve conditionId
const marketOpts = await getMarketOptions(tokenId);
const effectiveConditionId = conditionId || marketOpts.conditionId;
// Check existing position and max position size cap
const existingPos = getPosition(effectiveConditionId);
if (existingPos) {
const spent = existingPos.totalCost || 0;
if (spent >= config.maxPositionSize) {
logger.warn(`Max position $${config.maxPositionSize} reached for: ${market || effectiveConditionId} (spent $${spent.toFixed(2)}). Skipping.`);
return;
}
logger.info(`Adding to existing position (spent $${spent.toFixed(2)} / $${config.maxPositionSize})`);
}
// Calculate our trade size (independent of individual fill event)
let tradeSize = await calculateTradeSize();
// Cap so we don't exceed maxPositionSize
if (existingPos) {
const remaining = config.maxPositionSize - (existingPos.totalCost || 0);
tradeSize = Math.min(tradeSize, remaining);
} else {
tradeSize = Math.min(tradeSize, config.maxPositionSize);
}
if (tradeSize < config.minTradeSize) {
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
return;
}
// Check balance
const balance = await getUsdcBalance();
if (balance < tradeSize) {
logger.error(`Insufficient balance: $${balance.toFixed(2)} < $${tradeSize.toFixed(2)} needed`);
return;
}
logger.trade(`BUY ${market || tokenId} | Size: $${tradeSize.toFixed(2)} | Trader price: ${price}`);
if (config.dryRun) {
logger.trade(`[SIM] BUY ${market || tokenId} | $${tradeSize.toFixed(2)} @ $${price} | outcome: ${trade.outcome || '?'}`);
const dryShares = tradeSize / price;
if (existingPos) {
const newShares = existingPos.shares + dryShares;
const newTotalCost = existingPos.totalCost + tradeSize;
updatePosition(effectiveConditionId, {
shares: newShares,
avgBuyPrice: newTotalCost / newShares,
totalCost: newTotalCost,
});
logger.info(`[SIM] Position accumulated: $${newTotalCost.toFixed(2)} / $${config.maxPositionSize}`);
} else {
addPosition({
conditionId: effectiveConditionId,
tokenId,
market: market || marketOpts.question || tokenId,
shares: dryShares,
avgBuyPrice: price,
totalCost: tradeSize,
outcome: trade.outcome,
});
}
recordSimBuy();
return;
}
// Place market order with retries
const client = getClient();
let filled = false;
let totalSharesFilled = 0;
let totalCostFilled = 0;
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
const remainingAmount = tradeSize - totalCostFilled;
if (remainingAmount < config.minTradeSize) break;
logger.info(`Buy attempt ${attempt}/${config.maxRetries} | Amount: $${remainingAmount.toFixed(2)}`);
// Use FAK (fill-and-kill) to get what's available, then retry remainder
const response = await client.createAndPostMarketOrder(
{
tokenID: tokenId,
side: Side.BUY,
amount: remainingAmount,
price: Math.min(price * 1.05, 0.99), // 5% slippage allowance, max 0.99
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.FOK,
);
if (response && response.success) {
logger.success(`Order placed: ${response.orderID} | Status: ${response.status}`);
// Check if fully filled by trying to get trade info
const takingAmount = parseFloat(response.takingAmount || '0');
const makingAmount = parseFloat(response.makingAmount || '0');
if (takingAmount > 0 || makingAmount > 0) {
totalSharesFilled += takingAmount || (remainingAmount / price);
totalCostFilled += makingAmount || remainingAmount;
filled = true;
break; // FOK either fills fully or cancels
} else {
filled = true;
totalSharesFilled = tradeSize / price;
totalCostFilled = tradeSize;
break;
}
} else {
logger.warn(`Order not filled. Error: ${response?.errorMsg || 'Unknown'}`);
}
} catch (err) {
logger.error(`Buy attempt ${attempt} failed:`, err.message);
}
// Wait before retry
if (attempt < config.maxRetries) {
await new Promise((r) => setTimeout(r, config.retryDelay));
}
}
if (!filled || totalCostFilled === 0) {
logger.error(`Failed to fill buy order for ${market || tokenId} after ${config.maxRetries} attempts`);
return;
}
// Calculate avg buy price for this fill
const fillAvgPrice = totalSharesFilled > 0 ? totalCostFilled / totalSharesFilled : price;
if (existingPos) {
// Accumulate into existing position (weighted avg price)
const newShares = existingPos.shares + totalSharesFilled;
const newTotalCost = existingPos.totalCost + totalCostFilled;
const newAvgBuyPrice = newTotalCost / newShares;
updatePosition(effectiveConditionId, {
shares: newShares,
avgBuyPrice: newAvgBuyPrice,
totalCost: newTotalCost,
});
logger.success(`Position updated: ${existingPos.market} | total $${newTotalCost.toFixed(2)} / $${config.maxPositionSize}`);
} else {
// New position
addPosition({
conditionId: effectiveConditionId,
tokenId,
market: market || marketOpts.question || tokenId,
shares: totalSharesFilled,
avgBuyPrice: fillAvgPrice,
totalCost: totalCostFilled,
outcome: trade.outcome,
});
// Auto-sell only on initial entry, not on accumulation
if (config.autoSellEnabled) {
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, fillAvgPrice, marketOpts);
}
}
}
/**
* Execute a SELL trade (copy trader's sell)
* @param {Object} trade - Trade info from watcher
*/
export async function executeSell(trade) {
const { tokenId, conditionId, market, price } = trade;
// Get market options to resolve conditionId
let effectiveConditionId = conditionId;
let marketOpts;
if (!effectiveConditionId) {
marketOpts = await getMarketOptions(tokenId);
effectiveConditionId = marketOpts.conditionId;
}
// Check if we have a position
const position = getPosition(effectiveConditionId);
if (!position) {
logger.warn(`No position found for: ${market || effectiveConditionId}. Skipping sell.`);
return;
}
if (position.status === 'selling' || position.status === 'sold') {
logger.warn(`Position already ${position.status}: ${market || effectiveConditionId}. Skipping.`);
return;
}
logger.trade(`SELL ${position.market} | Shares: ${position.shares} | Trader price: ${price}`);
if (config.dryRun) {
logger.info('[DRY RUN] Would place sell order');
removePosition(effectiveConditionId);
return;
}
// Cancel existing auto-sell order if any
if (position.sellOrderId) {
try {
const client = getClient();
await client.cancelOrder(position.sellOrderId);
logger.info(`Cancelled auto-sell order: ${position.sellOrderId}`);
} catch (err) {
logger.warn(`Failed to cancel auto-sell: ${err.message}`);
}
}
updatePosition(effectiveConditionId, { status: 'selling' });
if (!marketOpts) {
marketOpts = await getMarketOptions(tokenId);
}
const client = getClient();
let filled = false;
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
if (config.sellMode === 'market') {
// Market sell (FOK)
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`);
const response = await client.createAndPostMarketOrder(
{
tokenID: tokenId,
side: Side.SELL,
amount: position.shares,
price: Math.max(price * 0.95, 0.01), // 5% slippage, min 0.01
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.FOK,
);
if (response && response.success) {
logger.success(`Sell order placed: ${response.orderID}`);
filled = true;
break;
} else {
logger.warn(`Sell not filled: ${response?.errorMsg || 'Unknown'}`);
}
} else {
// Limit sell at trader's sell price
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (limit) | Price: ${price}`);
const response = await client.createAndPostOrder(
{
tokenID: tokenId,
price: price,
size: position.shares,
side: Side.SELL,
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.GTC,
);
if (response && response.success) {
logger.success(`Limit sell placed: ${response.orderID} @ $${price}`);
filled = true;
break;
} else {
logger.warn(`Limit sell failed: ${response?.errorMsg || 'Unknown'}`);
}
}
} catch (err) {
logger.error(`Sell attempt ${attempt} failed:`, err.message);
}
if (attempt < config.maxRetries) {
await new Promise((r) => setTimeout(r, config.retryDelay));
}
}
if (filled) {
removePosition(effectiveConditionId);
logger.money(`Position sold: ${position.market}`);
} else {
updatePosition(effectiveConditionId, { status: 'open' });
logger.error(`Failed to sell ${position.market} after ${config.maxRetries} attempts`);
}
}