fix: market expiry guard, FAK orders, 2% slippage, sanitize CLOB logs
Market expiry guard (executor.js): - getMarketOptions() now returns endDateIso, active, acceptingOrders from Gamma API response - executeBuy() skips immediately if market is closed / not accepting orders - executeBuy() skips if market closes within MIN_MARKET_TIME_LEFT seconds (default 300 s = 5 min), logging exact time remaining - Add MIN_MARKET_TIME_LEFT to config and .env.example FAK + 2% slippage (executor.js): - Replace OrderType.FOK with OrderType.FAK for both BUY and SELL market orders — eliminates "FOK fully filled or killed" failures - Reduce slippage from 5% to 2% (price * 1.02 / price * 0.98) - Fix zero-fill detection: FAK success with 0 shares logs "no liquidity" and retries instead of recording a phantom fill CLOB log sanitization (logger.js + index.js): - Add sanitizeClobMessage(): strips axios config object (auth headers) from [CLOB Client] dumps, keeps only HTTP status + error string - Add interceptConsole(): overrides console.error/warn globally - Call interceptConsole() at startup in index.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
526076fe6e
commit
69d8d1401d
@@ -64,6 +64,10 @@ SELL_MODE=market
|
|||||||
# How often (seconds) to check for resolved markets to redeem
|
# How often (seconds) to check for resolved markets to redeem
|
||||||
REDEEM_INTERVAL=60
|
REDEEM_INTERVAL=60
|
||||||
|
|
||||||
|
# Skip BUY if the market closes within this many seconds from now
|
||||||
|
# Default 300 = skip if market ends in less than 5 minutes
|
||||||
|
MIN_MARKET_TIME_LEFT=300
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# DRY RUN (set true to simulate without real trades)
|
# DRY RUN (set true to simulate without real trades)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ const config = {
|
|||||||
maxRetries: 5,
|
maxRetries: 5,
|
||||||
retryDelay: 3000,
|
retryDelay: 3000,
|
||||||
|
|
||||||
|
// Skip buy if market closes within this many seconds (default 5 minutes)
|
||||||
|
minMarketTimeLeft: parseInt(process.env.MIN_MARKET_TIME_LEFT || '300', 10),
|
||||||
|
|
||||||
// ── Market Maker ──────────────────────────────────────────────
|
// ── Market Maker ──────────────────────────────────────────────
|
||||||
mmAssets: (process.env.MM_ASSETS || 'btc')
|
mmAssets: (process.env.MM_ASSETS || 'btc')
|
||||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import logger from './utils/logger.js';
|
|||||||
// ── Dashboard init (before any log output) ────────────────────────────────────
|
// ── Dashboard init (before any log output) ────────────────────────────────────
|
||||||
initDashboard();
|
initDashboard();
|
||||||
logger.setOutput(appendLog);
|
logger.setOutput(appendLog);
|
||||||
|
logger.interceptConsole(); // strip auth headers from CLOB client axios error dumps
|
||||||
|
|
||||||
// ── Handle a trade event from WebSocket ───────────────────────────────────────
|
// ── Handle a trade event from WebSocket ───────────────────────────────────────
|
||||||
async function handleTrade(trade) {
|
async function handleTrade(trade) {
|
||||||
|
|||||||
+54
-33
@@ -36,10 +36,13 @@ async function getMarketOptions(tokenId) {
|
|||||||
const marketInfo = await fetchMarketByTokenId(tokenId);
|
const marketInfo = await fetchMarketByTokenId(tokenId);
|
||||||
if (marketInfo) {
|
if (marketInfo) {
|
||||||
return {
|
return {
|
||||||
tickSize: String(marketInfo.minimum_tick_size || '0.01'),
|
tickSize: String(marketInfo.minimum_tick_size || '0.01'),
|
||||||
negRisk: marketInfo.neg_risk || false,
|
negRisk: marketInfo.neg_risk || false,
|
||||||
conditionId: marketInfo.condition_id || '',
|
conditionId: marketInfo.condition_id || '',
|
||||||
question: marketInfo.question || '',
|
question: marketInfo.question || '',
|
||||||
|
endDateIso: marketInfo.end_date_iso || marketInfo.game_start_time || null,
|
||||||
|
active: marketInfo.active !== false,
|
||||||
|
acceptingOrders: marketInfo.accepting_orders !== false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -50,10 +53,10 @@ async function getMarketOptions(tokenId) {
|
|||||||
try {
|
try {
|
||||||
const tickSize = await client.getTickSize(tokenId);
|
const tickSize = await client.getTickSize(tokenId);
|
||||||
const negRisk = await client.getNegRisk(tokenId);
|
const negRisk = await client.getNegRisk(tokenId);
|
||||||
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '' };
|
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to get tick size from SDK, using default 0.01');
|
logger.warn('Failed to get tick size from SDK, using default 0.01');
|
||||||
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '' };
|
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '', endDateIso: null, active: true, acceptingOrders: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +67,29 @@ async function getMarketOptions(tokenId) {
|
|||||||
export async function executeBuy(trade) {
|
export async function executeBuy(trade) {
|
||||||
const { tokenId, conditionId, market, price, size } = trade;
|
const { tokenId, conditionId, market, price, size } = trade;
|
||||||
|
|
||||||
// Get market options first to resolve conditionId
|
// Get market options first to resolve conditionId + end time
|
||||||
const marketOpts = await getMarketOptions(tokenId);
|
const marketOpts = await getMarketOptions(tokenId);
|
||||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||||
|
|
||||||
|
// ── Market expiry guard ────────────────────────────────────────────────────
|
||||||
|
if (!marketOpts.active || !marketOpts.acceptingOrders) {
|
||||||
|
logger.warn(`Market closed/not accepting orders: ${market || effectiveConditionId} — skipping buy`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (marketOpts.endDateIso) {
|
||||||
|
const secsLeft = (new Date(marketOpts.endDateIso).getTime() - Date.now()) / 1000;
|
||||||
|
if (secsLeft < config.minMarketTimeLeft) {
|
||||||
|
const minsLeft = Math.max(0, Math.floor(secsLeft / 60));
|
||||||
|
const sLeft = Math.max(0, Math.floor(secsLeft % 60));
|
||||||
|
logger.warn(
|
||||||
|
`Market expires in ${minsLeft}m ${sLeft}s — below MIN_MARKET_TIME_LEFT ` +
|
||||||
|
`(${config.minMarketTimeLeft}s). Skipping buy: ${market || effectiveConditionId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Check existing position and max position size cap
|
// Check existing position and max position size cap
|
||||||
const existingPos = getPosition(effectiveConditionId);
|
const existingPos = getPosition(effectiveConditionId);
|
||||||
if (existingPos) {
|
if (existingPos) {
|
||||||
@@ -131,7 +153,7 @@ export async function executeBuy(trade) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Place market order with retries
|
// Place market order (FAK) with retries
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
let filled = false;
|
let filled = false;
|
||||||
let totalSharesFilled = 0;
|
let totalSharesFilled = 0;
|
||||||
@@ -144,47 +166,41 @@ export async function executeBuy(trade) {
|
|||||||
|
|
||||||
logger.info(`Buy attempt ${attempt}/${config.maxRetries} | Amount: $${remainingAmount.toFixed(2)}`);
|
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(
|
const response = await client.createAndPostMarketOrder(
|
||||||
{
|
{
|
||||||
tokenID: tokenId,
|
tokenID: tokenId,
|
||||||
side: Side.BUY,
|
side: Side.BUY,
|
||||||
amount: remainingAmount,
|
amount: remainingAmount,
|
||||||
price: Math.min(price * 1.05, 0.99), // 5% slippage allowance, max 0.99
|
price: Math.min(price * 1.02, 0.99), // 2% slippage, max 0.99
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tickSize: marketOpts.tickSize,
|
tickSize: marketOpts.tickSize,
|
||||||
negRisk: marketOpts.negRisk,
|
negRisk: marketOpts.negRisk,
|
||||||
},
|
},
|
||||||
OrderType.FOK,
|
OrderType.FAK, // Fill-and-Kill: takes what's available, no full-fill requirement
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response && response.success) {
|
if (response && response.success) {
|
||||||
logger.success(`Order placed: ${response.orderID} | Status: ${response.status}`);
|
const sharesFilled = parseFloat(response.takingAmount || '0');
|
||||||
|
const costFilled = parseFloat(response.makingAmount || '0');
|
||||||
|
|
||||||
// Check if fully filled by trying to get trade info
|
if (sharesFilled > 0) {
|
||||||
const takingAmount = parseFloat(response.takingAmount || '0');
|
logger.success(`Order filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares @ ~$${(costFilled / sharesFilled).toFixed(4)}`);
|
||||||
const makingAmount = parseFloat(response.makingAmount || '0');
|
totalSharesFilled += sharesFilled;
|
||||||
|
totalCostFilled += costFilled || (sharesFilled * price);
|
||||||
if (takingAmount > 0 || makingAmount > 0) {
|
|
||||||
totalSharesFilled += takingAmount || (remainingAmount / price);
|
|
||||||
totalCostFilled += makingAmount || remainingAmount;
|
|
||||||
filled = true;
|
filled = true;
|
||||||
break; // FOK either fills fully or cancels
|
// If remainder is below minimum, stop; otherwise loop for partial fill
|
||||||
|
if (tradeSize - totalCostFilled < config.minTradeSize) break;
|
||||||
} else {
|
} else {
|
||||||
filled = true;
|
logger.warn(`No liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
||||||
totalSharesFilled = tradeSize / price;
|
|
||||||
totalCostFilled = tradeSize;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Order not filled. Error: ${response?.errorMsg || 'Unknown'}`);
|
logger.warn(`Order rejected: ${response?.errorMsg || 'unknown'}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Buy attempt ${attempt} failed:`, err.message);
|
logger.error(`Buy attempt ${attempt} failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait before retry
|
|
||||||
if (attempt < config.maxRetries) {
|
if (attempt < config.maxRetries) {
|
||||||
await new Promise((r) => setTimeout(r, config.retryDelay));
|
await new Promise((r) => setTimeout(r, config.retryDelay));
|
||||||
}
|
}
|
||||||
@@ -286,7 +302,7 @@ export async function executeSell(trade) {
|
|||||||
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
|
||||||
try {
|
try {
|
||||||
if (config.sellMode === 'market') {
|
if (config.sellMode === 'market') {
|
||||||
// Market sell (FOK)
|
// Market sell (FAK) — takes what's available at 2% slippage
|
||||||
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`);
|
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`);
|
||||||
|
|
||||||
const response = await client.createAndPostMarketOrder(
|
const response = await client.createAndPostMarketOrder(
|
||||||
@@ -294,21 +310,26 @@ export async function executeSell(trade) {
|
|||||||
tokenID: tokenId,
|
tokenID: tokenId,
|
||||||
side: Side.SELL,
|
side: Side.SELL,
|
||||||
amount: position.shares,
|
amount: position.shares,
|
||||||
price: Math.max(price * 0.95, 0.01), // 5% slippage, min 0.01
|
price: Math.max(price * 0.98, 0.01), // 2% slippage, min 0.01
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tickSize: marketOpts.tickSize,
|
tickSize: marketOpts.tickSize,
|
||||||
negRisk: marketOpts.negRisk,
|
negRisk: marketOpts.negRisk,
|
||||||
},
|
},
|
||||||
OrderType.FOK,
|
OrderType.FAK, // Fill-and-Kill: takes what's available
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response && response.success) {
|
if (response && response.success) {
|
||||||
logger.success(`Sell order placed: ${response.orderID}`);
|
const sharesFilled = parseFloat(response.takingAmount || '0');
|
||||||
filled = true;
|
if (sharesFilled > 0) {
|
||||||
break;
|
logger.success(`Sell filled: ${response.orderID} | ${sharesFilled.toFixed(4)} shares`);
|
||||||
|
filled = true;
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
logger.warn(`No bid liquidity — FAK filled 0 shares (attempt ${attempt})`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`Sell not filled: ${response?.errorMsg || 'Unknown'}`);
|
logger.warn(`Sell rejected: ${response?.errorMsg || 'unknown'}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Limit sell at trader's sell price
|
// Limit sell at trader's sell price
|
||||||
|
|||||||
@@ -22,6 +22,26 @@ const B = {
|
|||||||
|
|
||||||
let outputFn = null; // When set, all log goes here (blessed dashboard mode)
|
let outputFn = null; // When set, all log goes here (blessed dashboard mode)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a CLOB client console message.
|
||||||
|
* Strips the full axios config (which may contain auth headers) and returns
|
||||||
|
* only the HTTP status code + API error message.
|
||||||
|
*/
|
||||||
|
function sanitizeClobMessage(raw) {
|
||||||
|
if (!raw.includes('[CLOB Client]')) return raw;
|
||||||
|
try {
|
||||||
|
const jsonStart = raw.indexOf('{');
|
||||||
|
if (jsonStart === -1) return raw;
|
||||||
|
const parsed = JSON.parse(raw.slice(jsonStart));
|
||||||
|
const status = parsed.status || '';
|
||||||
|
const errMsg = parsed.data?.error || parsed.statusText || 'unknown error';
|
||||||
|
const prefix = raw.slice(0, jsonStart).trim();
|
||||||
|
return `${prefix}: ${status} — ${errMsg}`;
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ts() {
|
function ts() {
|
||||||
return new Date().toISOString().replace('T', ' ').substring(0, 19);
|
return new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||||
}
|
}
|
||||||
@@ -55,6 +75,25 @@ const logger = {
|
|||||||
setOutput(fn) {
|
setOutput(fn) {
|
||||||
outputFn = fn;
|
outputFn = fn;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override console.error and console.log globally so that the CLOB client's
|
||||||
|
* internal axios error dumps are sanitized (no auth headers / full config).
|
||||||
|
* Call this once at startup, before any CLOB requests.
|
||||||
|
*/
|
||||||
|
interceptConsole() {
|
||||||
|
const handle = (originalFn, logFn) => (...args) => {
|
||||||
|
const raw = args.map((a) => (a && typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
|
||||||
|
const sanitized = sanitizeClobMessage(raw);
|
||||||
|
if (sanitized !== raw || raw.includes('[CLOB Client]')) {
|
||||||
|
logFn(sanitized);
|
||||||
|
} else {
|
||||||
|
originalFn(...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
console.error = handle(console.error.bind(console), logger.error);
|
||||||
|
console.warn = handle(console.warn.bind(console), logger.warn);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default logger;
|
export default logger;
|
||||||
|
|||||||
Reference in New Issue
Block a user