feat: initial polymarket copy trade tool
- Watcher: polls Data API for trader activity - Executor: buy/sell with market orders + retry logic - Position manager: JSON-based state tracking - Auto-sell: limit orders at profit target - Redeemer: check & redeem winning positions on-chain - Config: env-based settings with validation - DRY_RUN mode for safe testing
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import config from '../config/index.js';
|
||||
import { getClient } from './client.js';
|
||||
import { updatePosition } from './position.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Place an auto-sell limit order at profit target above avg buy price
|
||||
* @param {string} conditionId - Market condition ID
|
||||
* @param {string} tokenId - CLOB token ID
|
||||
* @param {number} shares - Number of shares to sell
|
||||
* @param {number} avgBuyPrice - Average buy price
|
||||
* @param {Object} marketOpts - { tickSize, negRisk }
|
||||
*/
|
||||
export async function placeAutoSell(conditionId, tokenId, shares, avgBuyPrice, marketOpts) {
|
||||
try {
|
||||
const profitMultiplier = 1 + config.autoSellProfitPercent / 100;
|
||||
let sellPrice = avgBuyPrice * profitMultiplier;
|
||||
|
||||
// Round to tick size
|
||||
const tickSize = parseFloat(marketOpts.tickSize);
|
||||
sellPrice = Math.round(sellPrice / tickSize) * tickSize;
|
||||
|
||||
// Clamp between 0.01 and 0.99
|
||||
sellPrice = Math.max(0.01, Math.min(0.99, sellPrice));
|
||||
|
||||
// Format to proper decimal places
|
||||
const decimals = marketOpts.tickSize.split('.')[1]?.length || 2;
|
||||
sellPrice = parseFloat(sellPrice.toFixed(decimals));
|
||||
|
||||
logger.trade(`Auto-sell: ${shares} shares @ $${sellPrice} (${config.autoSellProfitPercent}% above avg buy $${avgBuyPrice.toFixed(4)})`);
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.info('[DRY RUN] Would place limit sell order');
|
||||
updatePosition(conditionId, { sellOrderId: 'dry-run-order' });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
const response = await client.createAndPostOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
price: sellPrice,
|
||||
size: shares,
|
||||
side: Side.SELL,
|
||||
},
|
||||
{
|
||||
tickSize: marketOpts.tickSize,
|
||||
negRisk: marketOpts.negRisk,
|
||||
},
|
||||
OrderType.GTC,
|
||||
);
|
||||
|
||||
if (response && response.success) {
|
||||
logger.success(`Auto-sell order placed: ${response.orderID} @ $${sellPrice}`);
|
||||
updatePosition(conditionId, { sellOrderId: response.orderID });
|
||||
} else {
|
||||
logger.warn(`Auto-sell failed: ${response?.errorMsg || 'Unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to place auto-sell:', err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ClobClient } from '@polymarket/clob-client';
|
||||
import { Wallet } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
let clobClient = null;
|
||||
let signer = null;
|
||||
|
||||
/**
|
||||
* Initialize the Polymarket CLOB client
|
||||
* Auto-derives API credentials if not provided in .env
|
||||
*/
|
||||
export async function initClient() {
|
||||
logger.info('Initializing Polymarket CLOB client...');
|
||||
|
||||
signer = new Wallet(config.privateKey);
|
||||
const walletAddress = signer.address;
|
||||
logger.info(`Wallet address: ${walletAddress}`);
|
||||
|
||||
// Step 1: Create temp client to derive API credentials
|
||||
let apiCreds;
|
||||
if (config.clobApiKey && config.clobApiSecret && config.clobApiPassphrase) {
|
||||
apiCreds = {
|
||||
key: config.clobApiKey,
|
||||
secret: config.clobApiSecret,
|
||||
passphrase: config.clobApiPassphrase,
|
||||
};
|
||||
logger.info('Using API credentials from .env');
|
||||
} else {
|
||||
const tempClient = new ClobClient(config.clobHost, config.chainId, signer);
|
||||
apiCreds = await tempClient.createOrDeriveApiKey();
|
||||
logger.info('API credentials derived successfully');
|
||||
}
|
||||
|
||||
// Step 2: Initialize full trading client
|
||||
clobClient = new ClobClient(
|
||||
config.clobHost,
|
||||
config.chainId,
|
||||
signer,
|
||||
apiCreds,
|
||||
0, // Signature type: 0 = EOA
|
||||
config.walletAddress || walletAddress, // Funder address
|
||||
);
|
||||
|
||||
logger.success('CLOB client initialized');
|
||||
return clobClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the initialized CLOB client
|
||||
*/
|
||||
export function getClient() {
|
||||
if (!clobClient) {
|
||||
throw new Error('CLOB client not initialized. Call initClient() first.');
|
||||
}
|
||||
return clobClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the signer wallet
|
||||
*/
|
||||
export function getSigner() {
|
||||
if (!signer) {
|
||||
throw new Error('Signer not initialized. Call initClient() first.');
|
||||
}
|
||||
return signer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get USDC.e balance on Polygon for the wallet
|
||||
*/
|
||||
export async function getUsdcBalance() {
|
||||
const { ethers } = await import('ethers');
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon
|
||||
const abi = ['function balanceOf(address) view returns (uint256)'];
|
||||
const usdc = new ethers.Contract(usdcAddress, abi, provider);
|
||||
const funderAddress = config.walletAddress || signer.address;
|
||||
const balance = await usdc.balanceOf(funderAddress);
|
||||
return parseFloat(ethers.utils.formatUnits(balance, 6));
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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 logger from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Calculate trade size based on settings
|
||||
* @param {number} traderSize - Trader's trade size in USDC
|
||||
* @returns {number} Our trade size in USDC
|
||||
*/
|
||||
async function calculateTradeSize(traderSize) {
|
||||
if (config.sizeMode === 'percentage') {
|
||||
// % of trader's trade size
|
||||
return traderSize * (config.sizePercent / 100);
|
||||
} else if (config.sizeMode === 'balance') {
|
||||
// % of our own 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;
|
||||
|
||||
// Check if already have position for this market
|
||||
if (hasPosition(conditionId)) {
|
||||
logger.warn(`Already have position for: ${market || conditionId}. Skipping buy.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate our trade size
|
||||
const tradeSize = await calculateTradeSize(size * price); // trader's USDC amount
|
||||
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;
|
||||
}
|
||||
|
||||
// Get market options
|
||||
const marketOpts = await getMarketOptions(tokenId);
|
||||
const effectiveConditionId = conditionId || marketOpts.conditionId;
|
||||
|
||||
// Double check no position exists
|
||||
if (effectiveConditionId && hasPosition(effectiveConditionId)) {
|
||||
logger.warn(`Already have position for: ${market || effectiveConditionId}. Skipping buy.`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.trade(`BUY ${market || tokenId} | Size: $${tradeSize.toFixed(2)} | Trader price: ${price}`);
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.info('[DRY RUN] Would place market buy order');
|
||||
// Still record position in dry run for testing
|
||||
addPosition({
|
||||
conditionId: effectiveConditionId,
|
||||
tokenId,
|
||||
market: market || marketOpts.question || tokenId,
|
||||
shares: tradeSize / price,
|
||||
avgBuyPrice: price,
|
||||
totalCost: tradeSize,
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
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
|
||||
const avgBuyPrice = totalSharesFilled > 0 ? totalCostFilled / totalSharesFilled : price;
|
||||
|
||||
// Record position
|
||||
addPosition({
|
||||
conditionId: effectiveConditionId,
|
||||
tokenId,
|
||||
market: market || marketOpts.question || tokenId,
|
||||
shares: totalSharesFilled,
|
||||
avgBuyPrice,
|
||||
totalCost: totalCostFilled,
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
|
||||
// Auto-sell if enabled
|
||||
if (config.autoSellEnabled) {
|
||||
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, avgBuyPrice, 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`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { readState, writeState } from '../utils/state.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const POSITIONS_FILE = 'positions.json';
|
||||
|
||||
/**
|
||||
* Get all current positions
|
||||
* @returns {Object} Map of conditionId -> position data
|
||||
*/
|
||||
export function getPositions() {
|
||||
return readState(POSITIONS_FILE, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we already have a position for this market (conditionId)
|
||||
* @param {string} conditionId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasPosition(conditionId) {
|
||||
const positions = getPositions();
|
||||
return !!positions[conditionId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new position after buy is filled
|
||||
* @param {Object} params
|
||||
* @param {string} params.conditionId - Market condition ID
|
||||
* @param {string} params.tokenId - CLOB token ID
|
||||
* @param {string} params.market - Market question/title
|
||||
* @param {number} params.shares - Number of shares bought
|
||||
* @param {number} params.avgBuyPrice - Average buy price
|
||||
* @param {number} params.totalCost - Total USDC spent
|
||||
* @param {string} params.outcome - YES/NO outcome
|
||||
* @param {string} [params.sellOrderId] - Auto-sell order ID if placed
|
||||
*/
|
||||
export function addPosition({
|
||||
conditionId,
|
||||
tokenId,
|
||||
market,
|
||||
shares,
|
||||
avgBuyPrice,
|
||||
totalCost,
|
||||
outcome,
|
||||
sellOrderId,
|
||||
}) {
|
||||
const positions = getPositions();
|
||||
positions[conditionId] = {
|
||||
conditionId,
|
||||
tokenId,
|
||||
market,
|
||||
shares,
|
||||
avgBuyPrice,
|
||||
totalCost,
|
||||
outcome: outcome || '',
|
||||
sellOrderId: sellOrderId || null,
|
||||
status: 'open', // open, selling, sold, redeemed
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeState(POSITIONS_FILE, positions);
|
||||
logger.success(`Position added: ${market} | ${shares} shares @ $${avgBuyPrice}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a position
|
||||
* @param {string} conditionId
|
||||
* @param {Object} updates - Fields to update
|
||||
*/
|
||||
export function updatePosition(conditionId, updates) {
|
||||
const positions = getPositions();
|
||||
if (positions[conditionId]) {
|
||||
positions[conditionId] = {
|
||||
...positions[conditionId],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
writeState(POSITIONS_FILE, positions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a position (after sell or redeem)
|
||||
* @param {string} conditionId
|
||||
*/
|
||||
export function removePosition(conditionId) {
|
||||
const positions = getPositions();
|
||||
if (positions[conditionId]) {
|
||||
const market = positions[conditionId].market;
|
||||
delete positions[conditionId];
|
||||
writeState(POSITIONS_FILE, positions);
|
||||
logger.info(`Position removed: ${market}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position by conditionId
|
||||
* @param {string} conditionId
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export function getPosition(conditionId) {
|
||||
const positions = getPositions();
|
||||
return positions[conditionId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all open positions as an array
|
||||
* @returns {Array}
|
||||
*/
|
||||
export function getOpenPositions() {
|
||||
const positions = getPositions();
|
||||
return Object.values(positions).filter((p) => p.status === 'open');
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getOpenPositions, updatePosition, removePosition } from './position.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Contract addresses on Polygon
|
||||
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
const NEG_RISK_CTF_ADDRESS = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
|
||||
const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
|
||||
|
||||
// CTF ABI (minimal for redeemPositions & balanceOf)
|
||||
const CTF_ABI = [
|
||||
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)',
|
||||
'function balanceOf(address owner, uint256 tokenId) view returns (uint256)',
|
||||
'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)',
|
||||
'function payoutDenominator(bytes32 conditionId) view returns (uint256)',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a market has been resolved and our position is a winner
|
||||
* @param {string} conditionId
|
||||
* @returns {Object|null} { resolved, won }
|
||||
*/
|
||||
async function checkMarketResolution(conditionId) {
|
||||
try {
|
||||
// Check via Gamma API
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const markets = await response.json();
|
||||
if (!markets || markets.length === 0) return null;
|
||||
|
||||
const market = markets[0];
|
||||
return {
|
||||
resolved: market.closed || market.resolved || false,
|
||||
active: market.active,
|
||||
endDate: market.end_date_iso,
|
||||
resolutionSource: market.resolution_source,
|
||||
question: market.question,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error('Failed to check market resolution:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check on-chain if a position (token) has value (payout available)
|
||||
*/
|
||||
async function checkOnChainPayout(conditionId) {
|
||||
try {
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (denominator.isZero()) return { resolved: false, payouts: [] };
|
||||
|
||||
// Check payouts for both outcomes (YES=0, NO=1)
|
||||
const payouts = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const numerator = await ctf.payoutNumerators(conditionId, i);
|
||||
payouts.push(numerator.toNumber() / denominator.toNumber());
|
||||
}
|
||||
|
||||
return { resolved: true, payouts };
|
||||
} catch (err) {
|
||||
// If payoutDenominator is 0 or reverts, market not resolved
|
||||
return { resolved: false, payouts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem winning position on-chain
|
||||
*/
|
||||
async function redeemPosition(conditionId, isNegRisk = false) {
|
||||
try {
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
const wallet = new ethers.Wallet(config.privateKey, provider);
|
||||
const ctfAddress = isNegRisk ? NEG_RISK_CTF_ADDRESS : CTF_ADDRESS;
|
||||
const ctf = new ethers.Contract(ctfAddress, CTF_ABI, wallet);
|
||||
|
||||
const parentCollectionId = ethers.constants.HashZero;
|
||||
const indexSets = [1, 2]; // Both outcomes
|
||||
|
||||
logger.info(`Redeeming position for conditionId: ${conditionId}`);
|
||||
|
||||
const tx = await ctf.redeemPositions(
|
||||
USDC_ADDRESS,
|
||||
parentCollectionId,
|
||||
conditionId,
|
||||
indexSets,
|
||||
{ gasLimit: 300000 },
|
||||
);
|
||||
|
||||
logger.info(`Redeem tx sent: ${tx.hash}`);
|
||||
const receipt = await tx.wait();
|
||||
logger.success(`Redeem confirmed in block ${receipt.blockNumber}`);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('Failed to redeem position:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all open positions for redeemable (resolved & won) markets
|
||||
*/
|
||||
export async function checkAndRedeemPositions() {
|
||||
const positions = getOpenPositions();
|
||||
if (positions.length === 0) return;
|
||||
|
||||
logger.info(`Checking ${positions.length} position(s) for redemption...`);
|
||||
|
||||
for (const position of positions) {
|
||||
try {
|
||||
// Check via API first
|
||||
const resolution = await checkMarketResolution(position.conditionId);
|
||||
|
||||
if (!resolution || !resolution.resolved) {
|
||||
// Try on-chain check as fallback
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
if (!onChain.resolved) continue;
|
||||
|
||||
// Check if our outcome won
|
||||
// Determine outcome index (0=YES, 1=NO based on token position)
|
||||
logger.info(`Market resolved on-chain: ${position.market} | Payouts: ${onChain.payouts}`);
|
||||
} else {
|
||||
logger.info(`Market resolved: ${position.market}`);
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.info(`[DRY RUN] Would redeem position: ${position.market}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Attempt to redeem
|
||||
const success = await redeemPosition(position.conditionId);
|
||||
if (success) {
|
||||
removePosition(position.conditionId);
|
||||
logger.money(`Redeemed: ${position.market}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error checking position ${position.market}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { readState, writeState } from '../utils/state.js';
|
||||
|
||||
const PROCESSED_FILE = 'processed_trades.json';
|
||||
|
||||
/**
|
||||
* Fetch trader's recent activity from Data API
|
||||
* @returns {Array} List of trade activities
|
||||
*/
|
||||
async function fetchTraderActivity() {
|
||||
const url = `${config.dataHost}/activity?user=${config.traderAddress}`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Data API returned ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch trader activity:', err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of already processed trade IDs
|
||||
*/
|
||||
function getProcessedTrades() {
|
||||
return readState(PROCESSED_FILE, { tradeIds: [] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a trade as processed
|
||||
*/
|
||||
function markTradeProcessed(tradeId) {
|
||||
const data = getProcessedTrades();
|
||||
data.tradeIds.push(tradeId);
|
||||
// Keep only last 500 trade IDs to prevent unbounded growth
|
||||
if (data.tradeIds.length > 500) {
|
||||
data.tradeIds = data.tradeIds.slice(-500);
|
||||
}
|
||||
writeState(PROCESSED_FILE, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for new trades from the watched trader
|
||||
* @returns {Array} New trades to process: { id, type, tokenId, conditionId, market, price, size, timestamp, side }
|
||||
*/
|
||||
export async function checkNewTrades() {
|
||||
const activities = await fetchTraderActivity();
|
||||
const processed = getProcessedTrades();
|
||||
|
||||
if (!Array.isArray(activities) || activities.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newTrades = [];
|
||||
|
||||
for (const activity of activities) {
|
||||
// Skip already processed
|
||||
const tradeId = activity.id || activity.transaction_hash || `${activity.timestamp}_${activity.asset}`;
|
||||
if (processed.tradeIds.includes(tradeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process filled trades (buys and sells)
|
||||
const type = activity.type?.toUpperCase();
|
||||
if (!['BUY', 'SELL'].includes(type)) {
|
||||
// Mark non-buy/sell as processed so we don't re-check
|
||||
markTradeProcessed(tradeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract trade info
|
||||
const trade = {
|
||||
id: tradeId,
|
||||
type, // BUY or SELL
|
||||
tokenId: activity.asset || activity.token_id || '',
|
||||
conditionId: activity.condition_id || activity.conditionId || '',
|
||||
market: activity.title || activity.question || activity.market || '',
|
||||
price: parseFloat(activity.price || '0'),
|
||||
size: parseFloat(activity.size || activity.amount || '0'),
|
||||
side: activity.side || type,
|
||||
timestamp: activity.timestamp || activity.created_at || new Date().toISOString(),
|
||||
outcome: activity.outcome || '',
|
||||
proxyWalletAddress: activity.proxyWalletAddress || '',
|
||||
};
|
||||
|
||||
// Need tokenId to trade
|
||||
if (!trade.tokenId) {
|
||||
logger.warn(`Skipping trade without tokenId: ${tradeId}`);
|
||||
markTradeProcessed(tradeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
newTrades.push(trade);
|
||||
}
|
||||
|
||||
return newTrades;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark trade as processed after handling
|
||||
*/
|
||||
export { markTradeProcessed };
|
||||
|
||||
/**
|
||||
* Fetch market info from Gamma API by condition ID
|
||||
*/
|
||||
export async function fetchMarketInfo(conditionId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const markets = await response.json();
|
||||
return markets && markets.length > 0 ? markets[0] : null;
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch market info:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch market info by token ID (CLOB token)
|
||||
*/
|
||||
export async function fetchMarketByTokenId(tokenId) {
|
||||
try {
|
||||
const url = `${config.gammaHost}/markets?clob_token_ids=${tokenId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const markets = await response.json();
|
||||
return markets && markets.length > 0 ? markets[0] : null;
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch market by tokenId:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user