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>
This commit is contained in:
+17
-8
@@ -14,8 +14,8 @@ export async function initClient() {
|
||||
logger.info('Initializing Polymarket CLOB client...');
|
||||
|
||||
signer = new Wallet(config.privateKey);
|
||||
const walletAddress = signer.address;
|
||||
logger.info(`Wallet address: ${walletAddress}`);
|
||||
logger.info(`EOA (signer) : ${signer.address}`);
|
||||
logger.info(`Proxy wallet : ${config.proxyWallet}`);
|
||||
|
||||
// Step 1: Create temp client to derive API credentials
|
||||
let apiCreds;
|
||||
@@ -33,13 +33,14 @@ export async function initClient() {
|
||||
}
|
||||
|
||||
// Step 2: Initialize full trading client
|
||||
// proxyWallet = funder address (where USDC.e is held)
|
||||
clobClient = new ClobClient(
|
||||
config.clobHost,
|
||||
config.chainId,
|
||||
signer,
|
||||
apiCreds,
|
||||
0, // Signature type: 0 = EOA
|
||||
config.walletAddress || walletAddress, // Funder address
|
||||
2, // Signature type: 2 = POLY_PROXY (EOA signs on behalf of proxy wallet)
|
||||
config.proxyWallet, // Funder = proxy wallet (deposit USDC.e here)
|
||||
);
|
||||
|
||||
logger.success('CLOB client initialized');
|
||||
@@ -67,15 +68,23 @@ export function getSigner() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get USDC.e balance on Polygon for the wallet
|
||||
* Get a working Polygon provider using RPC from config
|
||||
*/
|
||||
export async function getPolygonProvider() {
|
||||
const { ethers } = await import('ethers');
|
||||
const provider = new ethers.providers.JsonRpcProvider(config.polygonRpcUrl);
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get USDC.e balance of the proxy wallet on Polygon
|
||||
*/
|
||||
export async function getUsdcBalance() {
|
||||
const { ethers } = await import('ethers');
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
const provider = await getPolygonProvider();
|
||||
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);
|
||||
const balance = await usdc.balanceOf(config.proxyWallet);
|
||||
return parseFloat(ethers.utils.formatUnits(balance, 6));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* ctf.js
|
||||
* Helpers for interacting with Polymarket's ConditionalTokens (CTF) contract
|
||||
* directly from the Gnosis Safe proxy wallet.
|
||||
*
|
||||
* Key operations:
|
||||
* splitPosition — deposit USDC → receive equal YES+NO tokens at $0.50 each
|
||||
* mergePositions — return equal YES+NO tokens → recover USDC (cut-loss with no slippage)
|
||||
*/
|
||||
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getSigner, getPolygonProvider } from './client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// ── Contract addresses (Polygon mainnet) ──────────────────────────────────────
|
||||
|
||||
export const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
export const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e
|
||||
export const CTF_EXCHANGE = '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E';
|
||||
export const NEG_RISK_EXCHANGE = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
|
||||
|
||||
// ── ABIs (minimal) ────────────────────────────────────────────────────────────
|
||||
|
||||
const SAFE_ABI = [
|
||||
'function nonce() view returns (uint256)',
|
||||
'function getTransactionHash(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, uint256 nonce) view returns (bytes32)',
|
||||
'function execTransaction(address to, uint256 value, bytes data, uint8 operation, uint256 safeTxGas, uint256 baseGas, uint256 gasPrice, address gasToken, address refundReceiver, bytes signatures) payable returns (bool)',
|
||||
];
|
||||
|
||||
// Minimum shares per side (Polymarket allows fractional; we enforce 2.5 as practical floor)
|
||||
export const MIN_SHARES_PER_SIDE = 2.5;
|
||||
|
||||
const CTF_ABI = [
|
||||
'function splitPosition(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] partition, uint256 amount) external',
|
||||
'function mergePositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] partition, uint256 amount) external',
|
||||
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets) external',
|
||||
'function balanceOf(address account, uint256 id) view returns (uint256)',
|
||||
'function payoutDenominator(bytes32 conditionId) view returns (uint256)',
|
||||
'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)',
|
||||
];
|
||||
|
||||
const ERC20_ABI = [
|
||||
'function approve(address spender, uint256 amount) returns (bool)',
|
||||
'function allowance(address owner, address spender) view returns (uint256)',
|
||||
];
|
||||
|
||||
const ERC1155_ABI = [
|
||||
'function isApprovedForAll(address account, address operator) view returns (bool)',
|
||||
'function setApprovalForAll(address operator, bool approved)',
|
||||
];
|
||||
|
||||
// ── Error helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Convert a raw ethers.js / RPC error into a short, human-readable message.
|
||||
* Strips the lengthy internal stack info that ethers appends.
|
||||
*/
|
||||
function parseOnchainError(err) {
|
||||
const msg = err?.message || String(err);
|
||||
const reason = err?.reason || err?.error?.reason || '';
|
||||
|
||||
if (msg.includes('insufficient funds') || msg.includes('insufficient balance'))
|
||||
return 'Insufficient MATIC balance for gas fees';
|
||||
if (msg.includes('nonce too low') || msg.includes('nonce has already been used'))
|
||||
return 'Transaction nonce conflict (nonce already used)';
|
||||
if (msg.includes('replacement transaction underpriced'))
|
||||
return 'Gas price too low to replace previous transaction';
|
||||
if (msg.includes('gas tip cap') && msg.includes('minimum needed'))
|
||||
return 'Priority fee below Polygon minimum (25 Gwei)';
|
||||
if (msg.includes('UNPREDICTABLE_GAS_LIMIT'))
|
||||
return 'Gas estimation failed — transaction will likely revert';
|
||||
if (msg.includes('execution reverted') || err?.code === 'CALL_EXCEPTION')
|
||||
return reason ? `Transaction reverted: ${reason}` : 'Transaction reverted by smart contract';
|
||||
if (msg.includes('timeout') || msg.includes('TIMEOUT'))
|
||||
return 'RPC request timed out';
|
||||
if (msg.includes('SERVER_ERROR') || msg.includes('Internal Server Error'))
|
||||
return 'RPC server error';
|
||||
if (msg.includes('NETWORK_ERROR') || msg.includes('network changed'))
|
||||
return 'Network connection lost';
|
||||
if (msg.includes('ECONNREFUSED') || msg.includes('connection refused'))
|
||||
return 'Cannot connect to Polygon RPC';
|
||||
if (msg.includes('header not found'))
|
||||
return 'RPC node not synced — please retry';
|
||||
|
||||
// Fallback: extract the first sentence before ethers noise
|
||||
const first = msg.split('\n')[0].split('(')[0].trim();
|
||||
return first.length > 120 ? first.slice(0, 120) + '…' : (first || 'Unknown error');
|
||||
}
|
||||
|
||||
// ── Safe transaction executor ─────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 3000; // ms
|
||||
|
||||
// Gnosis Safe nonces are sequential — concurrent calls would read the same nonce
|
||||
// and cause "nonce too low" for all but the first. This queue ensures every on-chain
|
||||
// tx waits for the previous one to fully confirm before starting.
|
||||
let _txQueue = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Execute an arbitrary call through the Gnosis Safe proxy wallet.
|
||||
* Calls are serialized via an internal queue so nonces never collide.
|
||||
* Retries up to MAX_RETRIES times on transient errors.
|
||||
*/
|
||||
function execSafeCall(to, data, description = '') {
|
||||
// Enqueue: this call will only start after the previous one resolves/rejects
|
||||
const result = _txQueue.then(() => _doExecSafeCall(to, data, description));
|
||||
// Don't let a failure poison the queue for subsequent calls
|
||||
_txQueue = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function _doExecSafeCall(to, data, description = '') {
|
||||
if (description) logger.info(`MM: exec safe tx — ${description}`);
|
||||
|
||||
let lastErr;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const wallet = getSigner().connect(provider);
|
||||
const safe = new ethers.Contract(config.proxyWallet, SAFE_ABI, wallet);
|
||||
|
||||
const nonce = await safe.nonce();
|
||||
|
||||
// Get the Safe's typed transaction hash
|
||||
const txHash = await safe.getTransactionHash(
|
||||
to,
|
||||
0, // value (ETH)
|
||||
data,
|
||||
0, // operation: CALL
|
||||
0, // safeTxGas
|
||||
0, // baseGas
|
||||
0, // gasPrice
|
||||
ethers.constants.AddressZero, // gasToken
|
||||
ethers.constants.AddressZero, // refundReceiver
|
||||
nonce,
|
||||
);
|
||||
|
||||
// Sign the raw hash with the EOA signing key (no EIP-191 prefix)
|
||||
// Gnosis Safe v1.3.0 treats plain ECDSA signatures (v=27/28) on the tx hash directly
|
||||
const signingKey = new ethers.utils.SigningKey(config.privateKey);
|
||||
const rawSig = signingKey.signDigest(txHash);
|
||||
const signature = ethers.utils.joinSignature(rawSig);
|
||||
|
||||
// Polygon requires maxPriorityFeePerGas ≥ 25 Gwei.
|
||||
// Some RPC nodes (e.g. lava.build) return a stale low estimate, so we enforce a floor.
|
||||
const feeData = await provider.getFeeData();
|
||||
const MIN_TIP = ethers.utils.parseUnits('30', 'gwei');
|
||||
const gasTip = feeData.maxPriorityFeePerGas?.gt(MIN_TIP) ? feeData.maxPriorityFeePerGas : MIN_TIP;
|
||||
const gasFeeCap = feeData.maxFeePerGas ?? ethers.utils.parseUnits('500', 'gwei');
|
||||
|
||||
const tx = await safe.execTransaction(
|
||||
to, 0, data, 0, 0, 0, 0,
|
||||
ethers.constants.AddressZero,
|
||||
ethers.constants.AddressZero,
|
||||
signature,
|
||||
{ maxPriorityFeePerGas: gasTip, maxFeePerGas: gasFeeCap },
|
||||
);
|
||||
|
||||
const receipt = await tx.wait();
|
||||
return receipt;
|
||||
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
const friendly = parseOnchainError(err);
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
logger.warn(`MM: transaction failed (attempt ${attempt}/${MAX_RETRIES}): ${friendly} — retrying in ${RETRY_DELAY / 1000}s...`);
|
||||
await sleep(RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted — throw a clean, human-readable error
|
||||
throw new Error(parseOnchainError(lastErr));
|
||||
}
|
||||
|
||||
// ── Approval helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure the CTF contract can spend USDC from the proxy wallet.
|
||||
*/
|
||||
async function ensureUsdcApproval(amountWei) {
|
||||
const provider = await getPolygonProvider();
|
||||
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
|
||||
const allowance = await usdc.allowance(config.proxyWallet, CTF_ADDRESS);
|
||||
if (allowance.gte(amountWei)) return;
|
||||
|
||||
const iface = new ethers.utils.Interface(ERC20_ABI);
|
||||
const data = iface.encodeFunctionData('approve', [CTF_ADDRESS, ethers.constants.MaxUint256]);
|
||||
await execSafeCall(USDC_ADDRESS, data, 'approve USDC → CTF');
|
||||
logger.success('MM: USDC approved to CTF contract');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the CTF exchange is an approved ERC1155 operator (needed for limit sell orders).
|
||||
* This is a one-time per-wallet setup.
|
||||
*/
|
||||
export async function ensureExchangeApproval(negRisk = false) {
|
||||
const exchange = negRisk ? NEG_RISK_EXCHANGE : CTF_EXCHANGE;
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, ERC1155_ABI, provider);
|
||||
|
||||
const approved = await ctf.isApprovedForAll(config.proxyWallet, exchange);
|
||||
if (approved) return;
|
||||
|
||||
const iface = new ethers.utils.Interface(ERC1155_ABI);
|
||||
const data = iface.encodeFunctionData('setApprovalForAll', [exchange, true]);
|
||||
await execSafeCall(CTF_ADDRESS, data, 'setApprovalForAll → CTF Exchange');
|
||||
logger.success(`MM: CTF exchange approved as ERC1155 operator`);
|
||||
}
|
||||
|
||||
// ── Core CTF operations ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Split `amountUsdc` USDC into equal YES+NO conditional tokens via the CTF contract.
|
||||
*
|
||||
* This gives a flat $0.50 entry on BOTH sides with zero slippage:
|
||||
* e.g. split $10 → 10 YES tokens + 10 NO tokens, each at $0.50 entry cost
|
||||
*
|
||||
* @param {string} conditionId - Market conditionId (bytes32 hex string)
|
||||
* @param {number} amountUsdc - Total USDC to split (both sides combined)
|
||||
* @param {boolean} negRisk - Whether the market uses negRisk exchange
|
||||
* @returns {number} shares - Number of tokens per side (= amountUsdc)
|
||||
*/
|
||||
export async function splitPosition(conditionId, amountUsdc, negRisk = false) {
|
||||
// shares per side = amountUsdc (each token entry price = $0.50, so $10 gives 10 shares each side)
|
||||
const shares = amountUsdc;
|
||||
|
||||
// Practical minimum: 2.5 shares per side → minimum $5 total (2 × $2.5)
|
||||
if (shares < MIN_SHARES_PER_SIDE) {
|
||||
throw new Error(
|
||||
`MM_TRADE_SIZE too small: ${shares} shares per side (minimum is ${MIN_SHARES_PER_SIDE}). ` +
|
||||
`Set MM_TRADE_SIZE ≥ ${MIN_SHARES_PER_SIDE} in your .env (current value: ${config.mmTradeSize}).`,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.info(`MM[SIM]: split $${amountUsdc} USDC → ${shares} YES + ${shares} NO @ $0.50 each`);
|
||||
return shares;
|
||||
}
|
||||
|
||||
const amountWei = ethers.utils.parseUnits(amountUsdc.toFixed(6), 6);
|
||||
|
||||
// 1. Ensure USDC is approved to CTF contract
|
||||
await ensureUsdcApproval(amountWei);
|
||||
|
||||
// 2. Ensure CTF exchange is approved to move tokens (needed for limit sells)
|
||||
await ensureExchangeApproval(negRisk);
|
||||
|
||||
// 3. Call splitPosition on CTF contract
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('splitPosition', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero, // parentCollectionId = bytes32(0) for root positions
|
||||
conditionId,
|
||||
[1, 2], // full binary partition: YES=indexSet(1), NO=indexSet(2)
|
||||
amountWei,
|
||||
]);
|
||||
|
||||
await execSafeCall(CTF_ADDRESS, data, `splitPosition conditionId=${conditionId.slice(0, 10)}...`);
|
||||
logger.success(`MM: split $${amountUsdc} USDC → ${shares} YES + ${shares} NO @ $0.50`);
|
||||
return shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge equal YES+NO tokens back into USDC via the CTF contract.
|
||||
* Used for cut-loss when neither limit sell has been filled — recovers entry cost with no slippage.
|
||||
*
|
||||
* @param {string} conditionId - Market conditionId
|
||||
* @param {number} sharesPerSide - How many tokens to merge (must be equal on both sides)
|
||||
* @returns {number} recoveredUsdc - USDC recovered (= sharesPerSide)
|
||||
*/
|
||||
export async function mergePositions(conditionId, sharesPerSide) {
|
||||
if (config.dryRun) {
|
||||
const recovered = sharesPerSide;
|
||||
logger.info(`MM[SIM]: merge ${sharesPerSide} YES+NO → $${recovered} USDC recovered`);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
const amountWei = ethers.utils.parseUnits(sharesPerSide.toFixed(6), 6);
|
||||
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
const data = ctfIface.encodeFunctionData('mergePositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
amountWei,
|
||||
]);
|
||||
|
||||
await execSafeCall(CTF_ADDRESS, data, `mergePositions conditionId=${conditionId.slice(0, 10)}...`);
|
||||
logger.success(`MM: merged — recovered $${sharesPerSide} USDC`);
|
||||
return sharesPerSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on startup: find any open CTF token positions in the proxy wallet
|
||||
* and merge them back to USDC so we start with a clean slate.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Query Data API for the proxy wallet's open positions
|
||||
* 2. For each conditionId found, check on-chain ERC1155 balances for YES and NO tokens
|
||||
* 3. If the market is NOT yet resolved (payoutDenominator == 0), merge equal YES+NO back to USDC
|
||||
* 4. Cancel any open CLOB orders via the CLOB client
|
||||
*
|
||||
* @param {import('@polymarket/clob-client').ClobClient} clobClient
|
||||
*/
|
||||
export async function cleanupOpenPositions(clobClient) {
|
||||
logger.info('MM: scanning for leftover positions to clean up...');
|
||||
|
||||
// ── 1. Cancel all open CLOB orders ──────────────────────────────────────────
|
||||
try {
|
||||
if (!config.dryRun) {
|
||||
const openOrders = await clobClient.getOpenOrders();
|
||||
if (Array.isArray(openOrders) && openOrders.length > 0) {
|
||||
logger.warn(`MM: cancelling ${openOrders.length} dangling open order(s)...`);
|
||||
for (const order of openOrders) {
|
||||
try { await clobClient.cancelOrder({ orderID: order.id ?? order.order_id }); } catch { /* ignore */ }
|
||||
}
|
||||
logger.success('MM: all open orders cancelled');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('MM: could not fetch open orders:', err.message);
|
||||
}
|
||||
|
||||
// ── 2. Query Data API for proxy wallet positions ─────────────────────────────
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const url = `https://data-api.polymarket.com/positions?user=${config.proxyWallet}`;
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch (err) {
|
||||
logger.warn('MM: could not fetch positions from Data API:', err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataPositions.length === 0) {
|
||||
logger.info('MM: no open positions found — starting clean ✅');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(`MM: found ${dataPositions.length} open position(s) — attempting to merge back to USDC...`);
|
||||
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
|
||||
// Group by conditionId
|
||||
const byCondition = new Map();
|
||||
for (const pos of dataPositions) {
|
||||
const cid = pos.conditionId || pos.condition_id;
|
||||
const tid = pos.asset || pos.tokenId || pos.token_id;
|
||||
if (!cid || !tid) continue;
|
||||
if (!byCondition.has(cid)) byCondition.set(cid, []);
|
||||
byCondition.get(cid).push({ tokenId: String(tid), size: parseFloat(pos.size || pos.currentValue || '0') });
|
||||
}
|
||||
|
||||
let mergedCount = 0;
|
||||
for (const [conditionId, tokens] of byCondition) {
|
||||
try {
|
||||
// Check if market is already resolved (skip if so — redeemer handles those)
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (!denominator.isZero()) {
|
||||
logger.info(`MM: conditionId ${conditionId.slice(0, 10)}... already resolved — skipping (redeemer will handle)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check on-chain ERC1155 token balances for each token
|
||||
const balances = await Promise.all(
|
||||
tokens.map(({ tokenId }) =>
|
||||
ctf.balanceOf(config.proxyWallet, tokenId).then((b) => ({
|
||||
tokenId,
|
||||
shares: parseFloat(ethers.utils.formatUnits(b, 6)),
|
||||
raw: b,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
const nonZero = balances.filter((b) => b.shares >= MIN_SHARES_PER_SIDE);
|
||||
if (nonZero.length < 2) {
|
||||
logger.info(`MM: conditionId ${conditionId.slice(0, 10)}... balance too low to merge — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use the minimum balance across both sides as the merge amount
|
||||
const minShares = Math.min(...nonZero.map((b) => b.shares));
|
||||
logger.warn(`MM: merging ${minShares.toFixed(3)} YES+NO → USDC for ${conditionId.slice(0, 10)}...`);
|
||||
|
||||
if (!config.dryRun) {
|
||||
await mergePositions(conditionId, minShares);
|
||||
mergedCount++;
|
||||
} else {
|
||||
logger.info(`MM[SIM]: would merge ${minShares.toFixed(3)} shares for ${conditionId.slice(0, 10)}...`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`MM: failed to clean up ${conditionId.slice(0, 10)}... — ${parseOnchainError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mergedCount > 0) {
|
||||
logger.success(`MM: cleanup complete — merged ${mergedCount} position(s) back to USDC ✅`);
|
||||
} else {
|
||||
logger.info('MM: cleanup done — nothing needed merging ✅');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Periodic redeemer ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check all positions held by the proxy wallet, find resolved markets,
|
||||
* and call redeemPositions via the Safe to collect USDC.
|
||||
*
|
||||
* Covers recovery buy positions, residual tokens from splits, and anything
|
||||
* else that resolved without being sold through the CLOB.
|
||||
*
|
||||
* Called automatically every redeemInterval seconds from mm.js.
|
||||
*/
|
||||
export async function redeemMMPositions() {
|
||||
// 1. Query Data API for all positions held by the proxy wallet
|
||||
let dataPositions = [];
|
||||
try {
|
||||
const resp = await fetch(`${config.dataHost}/positions?user=${config.proxyWallet}`);
|
||||
if (resp.ok) dataPositions = await resp.json();
|
||||
if (!Array.isArray(dataPositions)) dataPositions = [];
|
||||
} catch {
|
||||
return; // silent — will retry next interval
|
||||
}
|
||||
|
||||
if (dataPositions.length === 0) return;
|
||||
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
|
||||
const ctfIface = new ethers.utils.Interface(CTF_ABI);
|
||||
|
||||
// Group tokens by conditionId
|
||||
const byCondition = new Map();
|
||||
for (const pos of dataPositions) {
|
||||
const cid = pos.conditionId || pos.condition_id;
|
||||
const tid = pos.asset || pos.tokenId || pos.token_id;
|
||||
if (!cid || !tid) continue;
|
||||
if (!byCondition.has(cid)) byCondition.set(cid, []);
|
||||
byCondition.get(cid).push({
|
||||
tokenId: String(tid),
|
||||
size: parseFloat(pos.size || pos.currentValue || '0'),
|
||||
});
|
||||
}
|
||||
|
||||
let redeemed = 0;
|
||||
|
||||
for (const [conditionId, tokens] of byCondition) {
|
||||
try {
|
||||
// Skip unresolved markets
|
||||
const denominator = await ctf.payoutDenominator(conditionId);
|
||||
if (denominator.isZero()) continue;
|
||||
|
||||
// Check actual on-chain token balances (positions API can lag)
|
||||
const balances = await Promise.all(
|
||||
tokens.map(({ tokenId }) =>
|
||||
ctf.balanceOf(config.proxyWallet, tokenId)
|
||||
.then((b) => parseFloat(ethers.utils.formatUnits(b, 6)))
|
||||
)
|
||||
);
|
||||
const totalShares = balances.reduce((a, b) => a + b, 0);
|
||||
if (totalShares < 0.001) continue; // nothing on-chain to redeem
|
||||
|
||||
// Estimate payout from numerators (for logging only)
|
||||
const payoutFractions = await Promise.all(
|
||||
[0, 1].map((i) =>
|
||||
ctf.payoutNumerators(conditionId, i)
|
||||
.then((n) => n.toNumber() / denominator.toNumber())
|
||||
)
|
||||
);
|
||||
const expectedUsdc = balances.reduce(
|
||||
(sum, shares, i) => sum + shares * (payoutFractions[i] ?? 0), 0
|
||||
);
|
||||
|
||||
const label = conditionId.slice(0, 12) + '...';
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.money(`MM[SIM] redeem: ${label} — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`MM redeemer: ${label} resolved — ${totalShares.toFixed(3)} shares → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
|
||||
// Call redeemPositions through Safe (indexSets [1,2] covers both YES and NO)
|
||||
const data = ctfIface.encodeFunctionData('redeemPositions', [
|
||||
USDC_ADDRESS,
|
||||
ethers.constants.HashZero,
|
||||
conditionId,
|
||||
[1, 2],
|
||||
]);
|
||||
await execSafeCall(CTF_ADDRESS, data, `redeemPositions ${label}`);
|
||||
logger.money(`MM redeemer: redeemed ${label} → ~$${expectedUsdc.toFixed(2)} USDC`);
|
||||
redeemed++;
|
||||
} catch (err) {
|
||||
logger.error(`MM redeemer: failed to redeem ${conditionId.slice(0, 12)}... — ${parseOnchainError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (redeemed > 0) {
|
||||
logger.success(`MM redeemer: collected ${redeemed} resolved position(s)`);
|
||||
}
|
||||
}
|
||||
+85
-49
@@ -4,19 +4,22 @@ 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 based on settings
|
||||
* @param {number} traderSize - Trader's trade size in USDC
|
||||
* @returns {number} Our trade size in USDC
|
||||
* 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(traderSize) {
|
||||
async function calculateTradeSize() {
|
||||
if (config.sizeMode === 'percentage') {
|
||||
// % of trader's trade size
|
||||
return traderSize * (config.sizePercent / 100);
|
||||
return config.maxPositionSize * (config.sizePercent / 100);
|
||||
} else if (config.sizeMode === 'balance') {
|
||||
// % of our own balance
|
||||
const balance = await getUsdcBalance();
|
||||
return balance * (config.sizePercent / 100);
|
||||
}
|
||||
@@ -61,14 +64,32 @@ async function getMarketOptions(tokenId) {
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -81,30 +102,32 @@ export async function executeBuy(trade) {
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -172,23 +195,36 @@ export async function executeBuy(trade) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate avg buy price
|
||||
const avgBuyPrice = totalSharesFilled > 0 ? totalCostFilled / totalSharesFilled : price;
|
||||
// Calculate avg buy price for this fill
|
||||
const fillAvgPrice = totalSharesFilled > 0 ? totalCostFilled / totalSharesFilled : price;
|
||||
|
||||
// Record position
|
||||
addPosition({
|
||||
conditionId: effectiveConditionId,
|
||||
tokenId,
|
||||
market: market || marketOpts.question || tokenId,
|
||||
shares: totalSharesFilled,
|
||||
avgBuyPrice,
|
||||
totalCost: totalCostFilled,
|
||||
outcome: trade.outcome,
|
||||
});
|
||||
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 if enabled
|
||||
if (config.autoSellEnabled) {
|
||||
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, avgBuyPrice, marketOpts);
|
||||
// Auto-sell only on initial entry, not on accumulation
|
||||
if (config.autoSellEnabled) {
|
||||
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, fillAvgPrice, marketOpts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* mmDetector.js
|
||||
* Detects upcoming markets for configured assets (BTC, ETH, SOL, …)
|
||||
* using deterministic slug construction — supports 5-minute and 15-minute durations.
|
||||
*
|
||||
* Slug format: {asset}-updown-{duration}-{eventStartTimestamp}
|
||||
* e.g. btc-updown-5m-1771755000
|
||||
* eth-updown-15m-1771754100
|
||||
*
|
||||
* NEVER enters the currently active market — always targets the NEXT upcoming slot.
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Slot size in seconds (300 for 5m, 900 for 15m)
|
||||
const SLOT_SEC = config.mmDuration === '15m' ? 900 : 300;
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already scheduled
|
||||
|
||||
// ── Slot helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function currentSlot() {
|
||||
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
|
||||
}
|
||||
|
||||
function nextSlot() {
|
||||
return currentSlot() + SLOT_SEC;
|
||||
}
|
||||
|
||||
// ── Gamma API fetch ───────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-${config.mmDuration}-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market data extraction ────────────────────────────────────────────────────
|
||||
|
||||
function extractMarketData(market, asset) {
|
||||
const conditionId = market.conditionId || market.condition_id || '';
|
||||
if (!conditionId) return null;
|
||||
|
||||
// clobTokenIds may arrive as a JSON string or an actual array
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Schedule an asset slot ────────────────────────────────────────────────────
|
||||
|
||||
async function scheduleAsset(asset, slotTimestamp) {
|
||||
const key = `${asset}-${slotTimestamp}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
|
||||
const market = await fetchBySlug(asset, slotTimestamp);
|
||||
if (!market) return; // not in API yet — poll will retry
|
||||
|
||||
const data = extractMarketData(market, asset);
|
||||
if (!data) {
|
||||
logger.warn(`MM: skipping ${asset.toUpperCase()} slot ${slotTimestamp} — missing token IDs`);
|
||||
seenKeys.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
seenKeys.add(key);
|
||||
|
||||
// Refuse to enter a market already well into its window (e.g., bot restart mid-slot)
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const elapsedSec = Math.round((Date.now() - openAt) / 1000);
|
||||
if (elapsedSec > 15) {
|
||||
logger.info(`MM: ${asset.toUpperCase()} next slot already ${elapsedSec}s old — skipping, will catch next`);
|
||||
return;
|
||||
}
|
||||
|
||||
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||
if (secsUntilOpen > 0) {
|
||||
logger.success(`MM: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}" — splitting position now (${secsUntilOpen}s before open)`);
|
||||
} else {
|
||||
logger.success(`MM: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}" — splitting position now`);
|
||||
}
|
||||
|
||||
if (onMarketCb) onMarketCb(data);
|
||||
}
|
||||
|
||||
// ── Poll ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
// Schedule NEXT slot only — never the currently active market
|
||||
const next = nextSlot();
|
||||
await Promise.all(config.mmAssets.map((asset) => scheduleAsset(asset, next)));
|
||||
} catch (err) {
|
||||
logger.error('MM detector poll error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function startMMDetector(onNewMarket) {
|
||||
onMarketCb = onNewMarket;
|
||||
seenKeys.clear();
|
||||
|
||||
poll();
|
||||
pollTimer = setInterval(poll, config.mmPollInterval);
|
||||
|
||||
const ns = nextSlot();
|
||||
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
||||
logger.info(`MM detector started — assets: ${config.mmAssets.join(', ').toUpperCase()} | duration: ${config.mmDuration}`);
|
||||
logger.info(`Next slot: *-updown-${config.mmDuration}-${ns} (opens in ${secsUntil}s)`);
|
||||
logger.info(`Order: $${config.mmTradeSize}/side × 2 sides = $${config.mmTradeSize * 2} per market`);
|
||||
}
|
||||
|
||||
export function stopMMDetector() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
/**
|
||||
* mmExecutor.js
|
||||
* Executes the market-maker strategy for a single Bitcoin 5-minute market:
|
||||
* 1. Call CTF splitPosition — deposit USDC, receive equal YES+NO tokens at $0.50 flat
|
||||
* 2. Place GTC limit sells at mmSellPrice for both YES and NO
|
||||
* 3. Monitor until both fills or cut-loss time triggers
|
||||
* 4. On cut-loss:
|
||||
* - If NEITHER side filled → mergePositions (burn YES+NO, recover USDC, zero loss)
|
||||
* - If ONE side already sold → cancel the other, market-sell remaining tokens
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getClient, getUsdcBalance, getPolygonProvider } from './client.js';
|
||||
import { splitPosition, mergePositions } from './ctf.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// CTF contract for on-chain balance queries
|
||||
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
|
||||
const CTF_BALANCE_ABI = ['function balanceOf(address account, uint256 id) view returns (uint256)'];
|
||||
|
||||
/**
|
||||
* Get actual on-chain ERC1155 token balance for the proxy wallet.
|
||||
* Used before market-sell to avoid 'not enough balance' errors from partial fills.
|
||||
*/
|
||||
async function getTokenBalance(tokenId) {
|
||||
try {
|
||||
const provider = await getPolygonProvider();
|
||||
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_BALANCE_ABI, provider);
|
||||
const raw = await ctf.balanceOf(config.proxyWallet, tokenId);
|
||||
return parseFloat(ethers.utils.formatUnits(raw, 6));
|
||||
} catch {
|
||||
return null; // fallback: caller will use pos.shares
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// In-memory store of all active MM positions (conditionId → position)
|
||||
const activePositions = new Map();
|
||||
|
||||
export function getActiveMMPositions() {
|
||||
return Array.from(activePositions.values());
|
||||
}
|
||||
|
||||
// ── Order helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function placeLimitSell(tokenId, shares, price, tickSize, negRisk) {
|
||||
if (config.dryRun) {
|
||||
return { success: true, orderId: `sim-${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('MM limit sell error:', err.message);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelOrder(orderId) {
|
||||
if (config.dryRun || !orderId || orderId.startsWith('sim-')) return true;
|
||||
try {
|
||||
const client = getClient();
|
||||
await client.cancelOrder({ orderID: orderId }); // SDK expects { orderID } object
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.warn('MM cancel order error:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function marketSell(tokenId, shares, tickSize, negRisk) {
|
||||
if (config.dryRun) {
|
||||
try {
|
||||
const client = getClient();
|
||||
const mp = await client.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('MM market sell error:', err.message);
|
||||
return { success: false, fillPrice: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Order status check ────────────────────────────────────────────────────────
|
||||
|
||||
async function isOrderFilled(orderId, shares) {
|
||||
if (!orderId || orderId.startsWith('sim-')) return false;
|
||||
try {
|
||||
const client = getClient();
|
||||
const order = await client.getOrder(orderId);
|
||||
if (!order) return false;
|
||||
if (order.status === 'MATCHED') return true;
|
||||
const matched = parseFloat(order.size_matched || '0');
|
||||
return matched >= shares * 0.99;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For simulation: check if market price has reached the sell target
|
||||
async function simPriceHitTarget(tokenId) {
|
||||
try {
|
||||
const client = getClient();
|
||||
const mp = await client.getMidpoint(tokenId);
|
||||
const price = parseFloat(mp?.mid ?? mp ?? '0');
|
||||
return price >= config.mmSellPrice ? price : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core monitoring loop ──────────────────────────────────────────────────────
|
||||
|
||||
async function monitorAndManage(pos) {
|
||||
const label = pos.question.substring(0, 40);
|
||||
|
||||
while (true) {
|
||||
const msRemaining = new Date(pos.endTime).getTime() - Date.now();
|
||||
|
||||
if (msRemaining <= 0) {
|
||||
logger.warn(`MM: market expired — ${label}`);
|
||||
pos.status = 'expired';
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Check YES side ──────────────────────────────────────
|
||||
if (!pos.yes.filled) {
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(pos.yes.tokenId);
|
||||
if (hitPrice) { filled = true; pos.yes.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(pos.yes.orderId, pos.yes.shares);
|
||||
if (filled) pos.yes.fillPrice = config.mmSellPrice;
|
||||
}
|
||||
if (filled) {
|
||||
pos.yes.filled = true;
|
||||
const pnl = (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares;
|
||||
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: YES filled @ $${pos.yes.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check NO side ───────────────────────────────────────
|
||||
if (!pos.no.filled) {
|
||||
let filled = false;
|
||||
if (config.dryRun) {
|
||||
const hitPrice = await simPriceHitTarget(pos.no.tokenId);
|
||||
if (hitPrice) { filled = true; pos.no.fillPrice = hitPrice; }
|
||||
} else {
|
||||
filled = await isOrderFilled(pos.no.orderId, pos.no.shares);
|
||||
if (filled) pos.no.fillPrice = config.mmSellPrice;
|
||||
}
|
||||
if (filled) {
|
||||
pos.no.filled = true;
|
||||
const pnl = (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares;
|
||||
logger.money(`MM${config.dryRun ? '[SIM]' : ''}: NO filled @ $${pos.no.fillPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Both filled → done ──────────────────────────────────
|
||||
if (pos.yes.filled && pos.no.filled) {
|
||||
pos.status = 'done';
|
||||
const totalPnl = calcPnl(pos);
|
||||
logger.money(`MM: BOTH sides filled! Total P&L: $${totalPnl.toFixed(2)} | ${label}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Cut-loss time ───────────────────────────────────────
|
||||
if (msRemaining <= config.mmCutLossTime * 1000) {
|
||||
logger.warn(`MM: cut-loss triggered (${Math.round(msRemaining / 1000)}s left) — ${label}`);
|
||||
pos.status = 'cutting';
|
||||
await cutLoss(pos);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(10_000);
|
||||
}
|
||||
|
||||
// Final P&L log
|
||||
const totalPnl = calcPnl(pos);
|
||||
const sign = totalPnl >= 0 ? '+' : '';
|
||||
if (pos.status !== 'done') {
|
||||
logger.info(`MM: strategy ended (${pos.status}) | P&L: ${sign}$${totalPnl.toFixed(2)} | ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cutLoss(pos) {
|
||||
const { conditionId, tickSize, negRisk } = pos;
|
||||
const neitherFilled = !pos.yes.filled && !pos.no.filled;
|
||||
|
||||
if (neitherFilled) {
|
||||
// ── Best case: neither side sold → cancel both, merge back to USDC ──
|
||||
logger.warn('MM: neither side filled — cancelling orders and merging back to USDC...');
|
||||
await cancelOrder(pos.yes.orderId);
|
||||
await cancelOrder(pos.no.orderId);
|
||||
|
||||
// Read actual on-chain balances (may differ from original if partially consumed)
|
||||
const [yesActual, noActual] = await Promise.all([
|
||||
getTokenBalance(pos.yes.tokenId),
|
||||
getTokenBalance(pos.no.tokenId),
|
||||
]);
|
||||
|
||||
// mergePositions needs equal amounts — use the minimum actual balance
|
||||
const yesShares = yesActual ?? pos.yes.shares;
|
||||
const noShares = noActual ?? pos.no.shares;
|
||||
const mergeAmt = Math.min(yesShares, noShares);
|
||||
|
||||
if (mergeAmt < 0.001) {
|
||||
logger.warn('MM: balances too low to merge — nothing to recover');
|
||||
} else {
|
||||
const recovered = await mergePositions(conditionId, mergeAmt);
|
||||
logger.money(`MM: merge complete — recovered ~$${recovered.toFixed ? recovered.toFixed(2) : recovered} USDC (P&L ≈ $0)`);
|
||||
}
|
||||
|
||||
// Mark both sides closed at entry price
|
||||
pos.yes.fillPrice = pos.yes.entryPrice;
|
||||
pos.yes.filled = true;
|
||||
pos.no.fillPrice = pos.no.entryPrice;
|
||||
pos.no.filled = true;
|
||||
|
||||
} else {
|
||||
// ── One side already (partly) sold → market-sell the unfilled side ──
|
||||
for (const side of ['yes', 'no']) {
|
||||
const s = pos[side];
|
||||
if (s.filled) continue;
|
||||
|
||||
logger.warn(`MM: cancelling ${side.toUpperCase()} limit order and market-selling...`);
|
||||
await cancelOrder(s.orderId);
|
||||
|
||||
// Fetch actual on-chain balance — partial fills reduce this below s.shares
|
||||
const actualShares = await getTokenBalance(s.tokenId);
|
||||
const sellShares = actualShares !== null ? actualShares : s.shares;
|
||||
|
||||
if (sellShares < 0.001) {
|
||||
logger.warn(`MM: ${side.toUpperCase()} balance is 0 — already fully sold via partial fills`);
|
||||
s.fillPrice = config.mmSellPrice; // assume sold at target
|
||||
s.filled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.warn(`MM: ${side.toUpperCase()} actual balance: ${sellShares.toFixed(3)} shares (original: ${s.shares})`);
|
||||
|
||||
const result = await marketSell(s.tokenId, sellShares, tickSize, negRisk);
|
||||
s.fillPrice = result.fillPrice;
|
||||
s.filled = true;
|
||||
// PnL uses actual sold amount (not original pos.shares)
|
||||
const pnl = (s.fillPrice - s.entryPrice) * sellShares;
|
||||
logger.warn(`MM: ${side.toUpperCase()} cut @ $${s.fillPrice.toFixed(3)} | sold ${sellShares.toFixed(3)} sh | P&L $${pnl.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
pos.status = 'done';
|
||||
|
||||
// Optional recovery buy (enabled via MM_RECOVERY_BUY=true)
|
||||
await attemptRecoveryBuy(pos);
|
||||
}
|
||||
|
||||
// ── Recovery buy ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* After a cut-loss, optionally take a directional bet on the dominant side.
|
||||
*
|
||||
* Criteria (all must pass):
|
||||
* 1. MM_RECOVERY_BUY=true in .env
|
||||
* 2. One side's price is above MM_RECOVERY_THRESHOLD (default 70%)
|
||||
* 3. That price is stable or rising over a 10-second sample (1 fetch/second)
|
||||
* 4. Wallet balance is sufficient for the recovery size
|
||||
*/
|
||||
async function attemptRecoveryBuy(pos) {
|
||||
if (!config.mmRecoveryBuy) return;
|
||||
|
||||
const { tickSize, negRisk } = pos;
|
||||
const label = pos.question.substring(0, 40);
|
||||
const recoverySize = config.mmRecoverySize > 0 ? config.mmRecoverySize : config.mmTradeSize;
|
||||
const client = getClient();
|
||||
|
||||
logger.info(`MM recovery: monitoring prices for 10s | ${label}`);
|
||||
|
||||
// ── Sample both sides once per second for 10 seconds ─────────
|
||||
const samples = { yes: [], no: [] };
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (const [key, tokenId] of [['yes', pos.yes.tokenId], ['no', pos.no.tokenId]]) {
|
||||
try {
|
||||
const mp = await client.getMidpoint(tokenId);
|
||||
const price = parseFloat(mp?.mid ?? mp ?? '0') || 0;
|
||||
samples[key].push(price);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
if (i < 9) await sleep(1000);
|
||||
}
|
||||
|
||||
// ── Determine eligible side ───────────────────────────────────
|
||||
// Need: last price ≥ threshold AND last price ≥ first price (not declining)
|
||||
let candidate = null;
|
||||
for (const [key, tokenId] of [['yes', pos.yes.tokenId], ['no', pos.no.tokenId]]) {
|
||||
const arr = samples[key];
|
||||
if (arr.length < 2) continue;
|
||||
|
||||
const firstPrice = arr[0];
|
||||
const lastPrice = arr[arr.length - 1];
|
||||
|
||||
if (lastPrice >= config.mmRecoveryThreshold && lastPrice >= firstPrice) {
|
||||
candidate = { side: key.toUpperCase(), tokenId, price: lastPrice };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!candidate) {
|
||||
logger.info(`MM recovery: no eligible side — need price ≥ ${config.mmRecoveryThreshold} and rising/stable`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Balance check ─────────────────────────────────────────────
|
||||
if (!config.dryRun) {
|
||||
const balance = await getUsdcBalance();
|
||||
if (balance < recoverySize) {
|
||||
logger.warn(`MM recovery: insufficient balance $${balance.toFixed(2)} < $${recoverySize} needed`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.trade(`MM recovery${config.dryRun ? '[SIM]' : ''}: buying ${candidate.side} @ $${candidate.price.toFixed(3)} | size $${recoverySize}`);
|
||||
|
||||
// ── Market buy ────────────────────────────────────────────────
|
||||
let entryPrice = candidate.price;
|
||||
let filledShares = recoverySize / entryPrice; // default estimate
|
||||
|
||||
if (config.dryRun) {
|
||||
logger.money(`MM recovery[SIM]: bought ${filledShares.toFixed(3)} ${candidate.side} @ $${entryPrice.toFixed(3)}`);
|
||||
} else {
|
||||
try {
|
||||
const res = await client.createAndPostMarketOrder(
|
||||
{ tokenID: candidate.tokenId, side: Side.BUY, amount: recoverySize, price: 0.99 },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FOK,
|
||||
);
|
||||
if (!res?.success) {
|
||||
logger.warn(`MM recovery: order not filled — ${res?.errorMsg || 'no fill'}`);
|
||||
return;
|
||||
}
|
||||
entryPrice = parseFloat(res.price || String(candidate.price));
|
||||
filledShares = parseFloat(res.takingAmount || String(recoverySize / entryPrice));
|
||||
logger.money(`MM recovery: FILLED ${candidate.side} ${filledShares.toFixed(3)} sh @ $${entryPrice.toFixed(3)} | potential payout $${filledShares.toFixed(2)}`);
|
||||
} catch (err) {
|
||||
logger.error(`MM recovery: buy error — ${err.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monitor for 30s — cut loss if price worsens ───────────────
|
||||
logger.info(`MM recovery: holding ${candidate.side} — will cut if price < $${entryPrice.toFixed(3)} after 30s`);
|
||||
await sleep(30_000);
|
||||
|
||||
// Skip second CL if market is already closed or about to close (< 5s left)
|
||||
const msLeft = new Date(pos.endTime).getTime() - Date.now();
|
||||
if (msLeft < 5_000) {
|
||||
logger.info(`MM recovery: market closing — skipping 2nd CL, letting position resolve`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check current price
|
||||
let currentPrice = entryPrice;
|
||||
try {
|
||||
const mp = await client.getMidpoint(candidate.tokenId);
|
||||
currentPrice = parseFloat(mp?.mid ?? mp ?? String(entryPrice)) || entryPrice;
|
||||
} catch { /* use entryPrice as fallback */ }
|
||||
|
||||
if (currentPrice >= entryPrice) {
|
||||
logger.success(`MM recovery: price holding $${currentPrice.toFixed(3)} ≥ entry $${entryPrice.toFixed(3)} — keeping position`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Price has worsened — cut loss
|
||||
const priceDrop = ((entryPrice - currentPrice) / entryPrice * 100).toFixed(1);
|
||||
logger.warn(`MM recovery: price dropped $${entryPrice.toFixed(3)} → $${currentPrice.toFixed(3)} (-${priceDrop}%) — cutting loss`);
|
||||
|
||||
if (config.dryRun) {
|
||||
const simPnl = (currentPrice - entryPrice) * filledShares;
|
||||
logger.warn(`MM recovery[SIM]: 2nd CL @ $${currentPrice.toFixed(3)} | P&L $${simPnl.toFixed(2)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sellRes = await client.createAndPostMarketOrder(
|
||||
{ tokenID: candidate.tokenId, side: Side.SELL, amount: filledShares, price: 0.01 },
|
||||
{ tickSize, negRisk },
|
||||
OrderType.FOK,
|
||||
);
|
||||
if (sellRes?.success) {
|
||||
const sellPrice = parseFloat(sellRes.price || String(currentPrice));
|
||||
const pnl = (sellPrice - entryPrice) * filledShares;
|
||||
logger.warn(`MM recovery: 2nd CL sold @ $${sellPrice.toFixed(3)} | P&L $${pnl.toFixed(2)}`);
|
||||
} else {
|
||||
logger.warn(`MM recovery: 2nd CL sell failed — ${sellRes?.errorMsg || 'no fill'} — position will resolve at close`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`MM recovery: 2nd CL sell error — ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function calcPnl(pos) {
|
||||
const yesPnl = pos.yes.filled
|
||||
? (pos.yes.fillPrice - pos.yes.entryPrice) * pos.yes.shares
|
||||
: 0;
|
||||
const noPnl = pos.no.filled
|
||||
? (pos.no.fillPrice - pos.no.entryPrice) * pos.no.shares
|
||||
: 0;
|
||||
return yesPnl + noPnl;
|
||||
}
|
||||
|
||||
// ── Main entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function executeMMStrategy(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] ' : '';
|
||||
|
||||
logger.info(`MM${tag}: ${sim}entering — ${label}`);
|
||||
|
||||
// ── Balance check ───────────────────────────────────────────
|
||||
const totalNeeded = config.mmTradeSize * 2; // $10 total → 10 YES + 10 NO
|
||||
if (!config.dryRun) {
|
||||
const balance = await getUsdcBalance();
|
||||
if (balance < totalNeeded) {
|
||||
logger.error(`MM${tag}: insufficient balance $${balance.toFixed(2)} (need $${totalNeeded})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Split USDC into YES+NO via CTF splitPosition ────────────
|
||||
// Deposit mmTradeSize*2 USDC → get mmTradeSize*2 YES + mmTradeSize*2 NO tokens
|
||||
// Entry price is exactly $0.50 per token on both sides (no spread, no slippage)
|
||||
logger.trade(`MM${tag}: ${sim}splitPosition $${totalNeeded} USDC → YES + NO @ $0.50`);
|
||||
let shares;
|
||||
try {
|
||||
shares = await splitPosition(conditionId, totalNeeded, negRisk);
|
||||
} catch (err) {
|
||||
logger.error(`MM${tag}: splitPosition failed — ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPrice = 0.50;
|
||||
logger.info(`MM${tag}: split done — ${shares} YES + ${shares} NO @ $${entryPrice}`);
|
||||
|
||||
// ── Place limit sells ───────────────────────────────────────
|
||||
logger.info(`MM${tag}: ${sim}placing limit sells @ $${config.mmSellPrice}`);
|
||||
const yesSell = await placeLimitSell(yesTokenId, shares, config.mmSellPrice, tickSize, negRisk);
|
||||
const noSell = await placeLimitSell(noTokenId, shares, config.mmSellPrice, tickSize, negRisk);
|
||||
|
||||
if (!yesSell.success || !noSell.success) {
|
||||
logger.error(`MM${tag}: failed to place limit sells — cutting immediately`);
|
||||
}
|
||||
|
||||
// ── Build position object ───────────────────────────────────
|
||||
const pos = {
|
||||
asset: asset || 'btc',
|
||||
conditionId,
|
||||
question,
|
||||
endTime,
|
||||
tickSize,
|
||||
negRisk,
|
||||
status: 'monitoring',
|
||||
enteredAt: new Date().toISOString(),
|
||||
yes: {
|
||||
tokenId: yesTokenId,
|
||||
shares,
|
||||
entryPrice,
|
||||
entryCost: config.mmTradeSize, // $5 per side
|
||||
orderId: yesSell.orderId,
|
||||
filled: !yesSell.success, // mark as needing cut if sell failed
|
||||
fillPrice: null,
|
||||
},
|
||||
no: {
|
||||
tokenId: noTokenId,
|
||||
shares,
|
||||
entryPrice,
|
||||
entryCost: config.mmTradeSize,
|
||||
orderId: noSell.orderId,
|
||||
filled: !noSell.success,
|
||||
fillPrice: null,
|
||||
},
|
||||
};
|
||||
|
||||
activePositions.set(conditionId, pos);
|
||||
|
||||
// ── Monitor (runs until done/cut/expired) ───────────────────
|
||||
await monitorAndManage(pos);
|
||||
|
||||
activePositions.delete(conditionId);
|
||||
}
|
||||
+70
-40
@@ -1,6 +1,8 @@
|
||||
import { ethers } from 'ethers';
|
||||
import config from '../config/index.js';
|
||||
import { getOpenPositions, updatePosition, removePosition } from './position.js';
|
||||
import { getPolygonProvider } from './client.js';
|
||||
import { getOpenPositions, removePosition } from './position.js';
|
||||
import { recordSimResult } from '../utils/simStats.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// Contract addresses on Polygon
|
||||
@@ -17,13 +19,10 @@ const CTF_ABI = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a market has been resolved and our position is a winner
|
||||
* @param {string} conditionId
|
||||
* @returns {Object|null} { resolved, won }
|
||||
* Check if a market has been resolved via Gamma API
|
||||
*/
|
||||
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;
|
||||
@@ -35,8 +34,6 @@ async function checkMarketResolution(conditionId) {
|
||||
return {
|
||||
resolved: market.closed || market.resolved || false,
|
||||
active: market.active,
|
||||
endDate: market.end_date_iso,
|
||||
resolutionSource: market.resolution_source,
|
||||
question: market.question,
|
||||
};
|
||||
} catch (err) {
|
||||
@@ -46,17 +43,17 @@ async function checkMarketResolution(conditionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check on-chain if a position (token) has value (payout available)
|
||||
* Check on-chain payout fractions for a condition
|
||||
* Returns: { resolved: bool, payouts: [yes_fraction, no_fraction] }
|
||||
*/
|
||||
async function checkOnChainPayout(conditionId) {
|
||||
try {
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
const provider = await getPolygonProvider();
|
||||
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);
|
||||
@@ -64,27 +61,25 @@ async function checkOnChainPayout(conditionId) {
|
||||
}
|
||||
|
||||
return { resolved: true, payouts };
|
||||
} catch (err) {
|
||||
// If payoutDenominator is 0 or reverts, market not resolved
|
||||
} catch {
|
||||
return { resolved: false, payouts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem winning position on-chain
|
||||
* Redeem winning position on-chain (real mode only)
|
||||
*/
|
||||
async function redeemPosition(conditionId, isNegRisk = false) {
|
||||
try {
|
||||
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
|
||||
const provider = await getPolygonProvider();
|
||||
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 indexSets = [1, 2];
|
||||
|
||||
logger.info(`Redeeming position: ${conditionId}`);
|
||||
const tx = await ctf.redeemPositions(
|
||||
USDC_ADDRESS,
|
||||
parentCollectionId,
|
||||
@@ -93,56 +88,91 @@ async function redeemPosition(conditionId, isNegRisk = false) {
|
||||
{ gasLimit: 300000 },
|
||||
);
|
||||
|
||||
logger.info(`Redeem tx sent: ${tx.hash}`);
|
||||
logger.info(`Redeem tx: ${tx.hash}`);
|
||||
const receipt = await tx.wait();
|
||||
logger.success(`Redeem confirmed in block ${receipt.blockNumber}`);
|
||||
|
||||
logger.success(`Redeemed in block ${receipt.blockNumber}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('Failed to redeem position:', err.message);
|
||||
logger.error('Failed to redeem:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all open positions for redeemable (resolved & won) markets
|
||||
* Simulate redemption: determine win/loss and record stats
|
||||
*/
|
||||
async function simulateRedeem(position) {
|
||||
// Need on-chain payout to know who actually won
|
||||
const onChain = await checkOnChainPayout(position.conditionId);
|
||||
|
||||
if (!onChain.resolved) {
|
||||
logger.info(`[SIM] Market resolved via API but payout not on-chain yet: ${position.market}`);
|
||||
return false; // check again next interval
|
||||
}
|
||||
|
||||
// outcome index: YES = 0, NO = 1
|
||||
const outcomeStr = (position.outcome || 'yes').toLowerCase();
|
||||
const outcomeIdx = outcomeStr === 'yes' ? 0 : 1;
|
||||
const payoutFraction = onChain.payouts[outcomeIdx] ?? 0;
|
||||
|
||||
// In Polymarket, winning shares redeem at $1 each
|
||||
const returned = payoutFraction * position.shares;
|
||||
const pnl = returned - position.totalCost;
|
||||
|
||||
if (payoutFraction > 0) {
|
||||
logger.money(
|
||||
`[SIM] WIN! "${position.market}" | ${position.outcome} won` +
|
||||
` | +$${pnl.toFixed(2)} (+${((pnl / position.totalCost) * 100).toFixed(1)}%)`,
|
||||
);
|
||||
recordSimResult(position, 'WIN', pnl, returned);
|
||||
} else {
|
||||
logger.error(
|
||||
`[SIM] LOSS: "${position.market}" | ${position.outcome} lost` +
|
||||
` | -$${position.totalCost.toFixed(2)} (-100%)`,
|
||||
);
|
||||
recordSimResult(position, 'LOSS', pnl, returned);
|
||||
}
|
||||
|
||||
removePosition(position.conditionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all open positions for resolved markets and redeem/simulate
|
||||
*/
|
||||
export async function checkAndRedeemPositions() {
|
||||
const positions = getOpenPositions();
|
||||
if (positions.length === 0) return;
|
||||
|
||||
logger.info(`Checking ${positions.length} position(s) for redemption...`);
|
||||
logger.info(`Checking ${positions.length} position(s) for resolution...`);
|
||||
|
||||
for (const position of positions) {
|
||||
try {
|
||||
// Check via API first
|
||||
// 1. Check via Gamma API
|
||||
const resolution = await checkMarketResolution(position.conditionId);
|
||||
const isResolved = resolution?.resolved;
|
||||
|
||||
if (!resolution || !resolution.resolved) {
|
||||
// Try on-chain check as fallback
|
||||
if (!isResolved) {
|
||||
// 2. Fallback: on-chain check
|
||||
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}`);
|
||||
logger.info(`Market resolved on-chain: ${position.market}`);
|
||||
} else {
|
||||
logger.info(`Market resolved: ${position.market}`);
|
||||
}
|
||||
|
||||
// 3. Simulate or execute real redeem
|
||||
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}`);
|
||||
await simulateRedeem(position);
|
||||
} else {
|
||||
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);
|
||||
logger.error(`Error checking ${position.market}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* sniperDetector.js
|
||||
* Detects upcoming 5-minute markets for configured assets (ETH, SOL, XRP, …)
|
||||
* using deterministic slug construction — same logic as mmDetector but for
|
||||
* multiple assets simultaneously.
|
||||
*
|
||||
* Slug format: {asset}-updown-5m-{eventStartTimestamp}
|
||||
* e.g. eth-updown-5m-1771790700
|
||||
* sol-updown-5m-1771790700
|
||||
* xrp-updown-5m-1771790700
|
||||
*
|
||||
* NEVER enters the currently active market — always the NEXT upcoming slot.
|
||||
*/
|
||||
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
const SLOT_SEC = 5 * 60; // 300 seconds
|
||||
|
||||
let pollTimer = null;
|
||||
let onMarketCb = null;
|
||||
const seenKeys = new Set(); // `${asset}-${slotTimestamp}` already handled
|
||||
|
||||
// ── Slot helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function currentSlot() {
|
||||
return Math.floor(Date.now() / 1000 / SLOT_SEC) * SLOT_SEC;
|
||||
}
|
||||
|
||||
function nextSlot() {
|
||||
return currentSlot() + SLOT_SEC;
|
||||
}
|
||||
|
||||
// ── Gamma API fetch ───────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchBySlug(asset, slotTimestamp) {
|
||||
const slug = `${asset}-updown-5m-${slotTimestamp}`;
|
||||
try {
|
||||
const resp = await fetch(`${config.gammaHost}/markets/slug/${slug}`);
|
||||
if (!resp.ok) return null;
|
||||
const data = await resp.json();
|
||||
return data?.conditionId ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Market data extraction ────────────────────────────────────────────────────
|
||||
|
||||
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 ?? '0.01'),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Schedule an asset slot ────────────────────────────────────────────────────
|
||||
|
||||
async function scheduleAsset(asset, slotTimestamp, isCurrent = false) {
|
||||
const key = `${asset}-${slotTimestamp}`;
|
||||
if (seenKeys.has(key)) return;
|
||||
|
||||
const market = await fetchBySlug(asset, slotTimestamp);
|
||||
if (!market) return; // not in API yet, poll will retry
|
||||
|
||||
const data = extractMarketData(market, asset);
|
||||
if (!data) {
|
||||
logger.warn(`SNIPER: skipping ${asset} slot ${slotTimestamp} — missing token IDs`);
|
||||
seenKeys.add(key);
|
||||
return;
|
||||
}
|
||||
|
||||
seenKeys.add(key);
|
||||
|
||||
if (isCurrent) {
|
||||
// Current slot: only place orders if there's at least 30 seconds of market left
|
||||
const endAt = data.endTime ? new Date(data.endTime).getTime() : (slotTimestamp + SLOT_SEC) * 1000;
|
||||
const secsLeft = Math.round((endAt - Date.now()) / 1000);
|
||||
if (secsLeft < 30) {
|
||||
logger.info(`SNIPER: ${asset.toUpperCase()} current market closing soon (${secsLeft}s) — skipping`);
|
||||
return;
|
||||
}
|
||||
logger.success(`SNIPER: ${asset.toUpperCase()} current market active (${secsLeft}s left) — placing orders now`);
|
||||
} else {
|
||||
// Next slot: market hasn't opened yet
|
||||
const openAt = data.eventStartTime ? new Date(data.eventStartTime).getTime() : slotTimestamp * 1000;
|
||||
const secsUntilOpen = Math.round((openAt - Date.now()) / 1000);
|
||||
logger.success(`SNIPER: ${asset.toUpperCase()} found "${data.question.slice(0, 40)}"${secsUntilOpen > 0 ? ` — ${secsUntilOpen}s before open` : ''}`);
|
||||
}
|
||||
|
||||
if (onMarketCb) onMarketCb(data);
|
||||
}
|
||||
|
||||
// ── Poll ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const curr = currentSlot();
|
||||
const next = nextSlot();
|
||||
// Check current active market AND the upcoming next one, in parallel for each asset
|
||||
await Promise.all(config.sniperAssets.flatMap((asset) => [
|
||||
scheduleAsset(asset, curr, true), // current market (if still has time left)
|
||||
scheduleAsset(asset, next, false), // next upcoming market
|
||||
]));
|
||||
} catch (err) {
|
||||
logger.error('SNIPER detector poll error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function startSniperDetector(onNewMarket) {
|
||||
onMarketCb = onNewMarket;
|
||||
seenKeys.clear();
|
||||
|
||||
poll();
|
||||
pollTimer = setInterval(poll, config.mmPollInterval);
|
||||
|
||||
const ns = nextSlot();
|
||||
const secsUntil = ns - Math.floor(Date.now() / 1000);
|
||||
logger.info(`SNIPER detector started — assets: ${config.sniperAssets.join(', ').toUpperCase()}`);
|
||||
logger.info(`Next slot: *-updown-5m-${ns} (opens in ${secsUntil}s)`);
|
||||
logger.info(`Order: $${config.sniperPrice} × ${config.sniperShares} shares per side`);
|
||||
}
|
||||
|
||||
export function stopSniperDetector() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* sniperExecutor.js
|
||||
* Places GTC limit BUY orders at a very low price on both sides of a market.
|
||||
*
|
||||
* Strategy:
|
||||
* - For each market detected by sniperDetector, place two GTC BUY orders:
|
||||
* UP token at $SNIPER_PRICE × SNIPER_SHARES shares
|
||||
* DOWN token at $SNIPER_PRICE × SNIPER_SHARES shares
|
||||
* - Orders sit in the orderbook. If someone panic-dumps below the price,
|
||||
* the order fills and becomes redeemable if that side wins.
|
||||
* - GTC orders expire automatically when the market closes — no cleanup needed.
|
||||
*
|
||||
* Cost per market: SNIPER_PRICE × SNIPER_SHARES × 2 sides
|
||||
* e.g. $0.01 × 5 × 2 = $0.10 per market, $0.30 for 3 assets per 5-min slot
|
||||
*/
|
||||
|
||||
import { Side, OrderType } from '@polymarket/clob-client';
|
||||
import config from '../config/index.js';
|
||||
import { getClient } from './client.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
// In-memory tracking of placed snipe orders (for TUI status panel)
|
||||
const activeSnipes = []; // { asset, side, question, orderId, price, shares, cost, potentialPayout }
|
||||
|
||||
export function getActiveSnipes() {
|
||||
return [...activeSnipes];
|
||||
}
|
||||
|
||||
export async function executeSnipe(market) {
|
||||
const { asset, conditionId, question, yesTokenId, noTokenId, tickSize, negRisk } = market;
|
||||
const label = question.slice(0, 40);
|
||||
const sim = config.dryRun ? '[SIM] ' : '';
|
||||
|
||||
const sides = [
|
||||
{ name: 'UP', tokenId: yesTokenId },
|
||||
{ name: 'DOWN', tokenId: noTokenId },
|
||||
];
|
||||
|
||||
logger.info(`SNIPER: ${sim}${asset.toUpperCase()} — "${label}" | $${config.sniperPrice} × ${config.sniperShares}sh each side`);
|
||||
|
||||
for (const { name, tokenId } of sides) {
|
||||
if (config.dryRun) {
|
||||
const cost = config.sniperPrice * config.sniperShares;
|
||||
logger.trade(`SNIPER[SIM]: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | payout $${config.sniperShares} if wins`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
question: label,
|
||||
orderId: `sim-${Date.now()}-${tokenId.slice(-6)}`,
|
||||
price: config.sniperPrice,
|
||||
shares: config.sniperShares,
|
||||
cost,
|
||||
potentialPayout: config.sniperShares,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const client = getClient();
|
||||
try {
|
||||
const res = await client.createAndPostOrder(
|
||||
{
|
||||
tokenID: tokenId,
|
||||
side: Side.BUY,
|
||||
price: config.sniperPrice,
|
||||
size: config.sniperShares,
|
||||
},
|
||||
{ tickSize, negRisk },
|
||||
OrderType.GTC,
|
||||
);
|
||||
|
||||
if (res?.success) {
|
||||
const cost = config.sniperPrice * config.sniperShares;
|
||||
logger.trade(`SNIPER: ${asset.toUpperCase()} ${name} @ $${config.sniperPrice} × ${config.sniperShares}sh | cost $${cost.toFixed(3)} | order ${res.orderID}`);
|
||||
activeSnipes.push({
|
||||
asset: asset.toUpperCase(),
|
||||
side: name,
|
||||
question: label,
|
||||
orderId: res.orderID,
|
||||
price: config.sniperPrice,
|
||||
shares: config.sniperShares,
|
||||
cost,
|
||||
potentialPayout: config.sniperShares,
|
||||
});
|
||||
} else {
|
||||
logger.warn(`SNIPER: ${asset.toUpperCase()} ${name} order failed — ${res?.errorMsg || 'unknown'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`SNIPER: ${asset.toUpperCase()} ${name} error — ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-16
@@ -57,36 +57,41 @@ export async function checkNewTrades() {
|
||||
const newTrades = [];
|
||||
|
||||
for (const activity of activities) {
|
||||
// Skip already processed
|
||||
const tradeId = activity.id || activity.transaction_hash || `${activity.timestamp}_${activity.asset}`;
|
||||
// Data API: type = "TRADE" always, direction is in "side" (BUY / SELL)
|
||||
// Unique dedup key: txHash + asset + side (one tx can have multiple token trades)
|
||||
const tradeId = activity.transactionHash
|
||||
? `${activity.transactionHash}_${activity.asset}_${activity.side}`
|
||||
: `${activity.timestamp}_${activity.asset}_${activity.side}`;
|
||||
|
||||
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
|
||||
// Only process TRADE type with BUY or SELL side
|
||||
const actType = (activity.type || '').toUpperCase();
|
||||
const side = (activity.side || '').toUpperCase();
|
||||
|
||||
if (actType !== 'TRADE' || !['BUY', 'SELL'].includes(side)) {
|
||||
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 || '',
|
||||
type: side, // BUY or SELL
|
||||
tokenId: activity.asset || '',
|
||||
conditionId: activity.conditionId || '',
|
||||
market: activity.title || activity.question || '',
|
||||
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(),
|
||||
size: parseFloat(activity.usdcSize || '0'), // USDC value
|
||||
shares: parseFloat(activity.size || '0'), // token shares
|
||||
side,
|
||||
outcome: activity.outcome || '',
|
||||
proxyWalletAddress: activity.proxyWalletAddress || '',
|
||||
outcomeIndex: activity.outcomeIndex ?? null,
|
||||
timestamp: activity.timestamp || Date.now() / 1000,
|
||||
txHash: activity.transactionHash || '',
|
||||
};
|
||||
|
||||
// Need tokenId to trade
|
||||
if (!trade.tokenId) {
|
||||
logger.warn(`Skipping trade without tokenId: ${tradeId}`);
|
||||
markTradeProcessed(tradeId);
|
||||
@@ -99,6 +104,7 @@ export async function checkNewTrades() {
|
||||
return newTrades;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mark trade as processed after handling
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import WebSocket from 'ws';
|
||||
import config from '../config/index.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { readState, writeState } from '../utils/state.js';
|
||||
|
||||
const RTDS_WS_URL = 'wss://ws-live-data.polymarket.com';
|
||||
const PING_INTERVAL_MS = 5000;
|
||||
const INITIAL_RECONNECT_DELAY = 2000;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
const PROCESSED_FILE = 'processed_trades.json';
|
||||
|
||||
let ws = null;
|
||||
let pingTimer = null;
|
||||
let reconnectTimer = null;
|
||||
let reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
let tradeHandler = null;
|
||||
let isShuttingDown = false;
|
||||
|
||||
function getProcessedIds() {
|
||||
return readState(PROCESSED_FILE, { tradeIds: [] });
|
||||
}
|
||||
|
||||
function markProcessed(tradeId) {
|
||||
const data = getProcessedIds();
|
||||
if (data.tradeIds.includes(tradeId)) return false;
|
||||
data.tradeIds.push(tradeId);
|
||||
if (data.tradeIds.length > 500) {
|
||||
data.tradeIds = data.tradeIds.slice(-500);
|
||||
}
|
||||
writeState(PROCESSED_FILE, data);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleMessage(rawData) {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(rawData.toString());
|
||||
} catch {
|
||||
const text = rawData.toString().trim();
|
||||
if (text === 'ping') {
|
||||
ws?.send('pong');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle ping/heartbeat
|
||||
if (msg.type === 'ping' || msg === 'ping') {
|
||||
ws?.send('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process activity trade events
|
||||
if (msg.topic !== 'activity') return;
|
||||
|
||||
const payload = msg.payload;
|
||||
if (!payload) return;
|
||||
|
||||
// Filter by target trader's address (case-insensitive)
|
||||
const traderAddr = config.traderAddress.toLowerCase();
|
||||
const proxyWallet = (payload.proxyWallet || payload.proxy_wallet || '').toLowerCase();
|
||||
|
||||
if (!proxyWallet || proxyWallet !== traderAddr) return;
|
||||
|
||||
// Build trade ID
|
||||
const tradeId = payload.transactionHash || payload.transaction_hash ||
|
||||
`${payload.timestamp}_${payload.asset}`;
|
||||
|
||||
// Deduplication
|
||||
if (!markProcessed(tradeId)) {
|
||||
logger.watch(`Duplicate trade skipped: ${tradeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse trade type
|
||||
const type = (payload.side || '').toUpperCase();
|
||||
if (!['BUY', 'SELL'].includes(type)) {
|
||||
logger.warn(`Unknown trade side: ${payload.side}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenId = payload.asset || '';
|
||||
if (!tokenId) {
|
||||
logger.warn(`Trade missing asset/tokenId: ${tradeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const trade = {
|
||||
id: tradeId,
|
||||
type,
|
||||
tokenId,
|
||||
conditionId: payload.conditionId || payload.condition_id || '',
|
||||
market: payload.title || payload.name || '',
|
||||
price: parseFloat(payload.price || '0'),
|
||||
size: parseFloat(payload.size || '0'),
|
||||
side: type,
|
||||
timestamp: payload.timestamp || new Date().toISOString(),
|
||||
outcome: payload.outcome || '',
|
||||
proxyWalletAddress: payload.proxyWallet || '',
|
||||
};
|
||||
|
||||
logger.watch(`Trade detected! ${type} - ${trade.market || trade.tokenId}`);
|
||||
logger.watch(` Size: ${trade.size} shares @ $${trade.price}`);
|
||||
|
||||
if (tradeHandler) {
|
||||
tradeHandler(trade).catch((err) => {
|
||||
logger.error(`Error handling trade: ${err.message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startPing() {
|
||||
stopPing();
|
||||
pingTimer = setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send('ping');
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopPing() {
|
||||
if (pingTimer) {
|
||||
clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup(reconnect = true) {
|
||||
stopPing();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.removeAllListeners();
|
||||
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||
ws.terminate();
|
||||
}
|
||||
ws = null;
|
||||
}
|
||||
if (reconnect && !isShuttingDown) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
logger.info(`Reconnecting in ${reconnectDelay / 1000}s...`);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (isShuttingDown) return;
|
||||
|
||||
logger.info('Connecting to Polymarket RTDS WebSocket...');
|
||||
ws = new WebSocket(RTDS_WS_URL);
|
||||
|
||||
ws.on('open', () => {
|
||||
logger.success('WebSocket connected! Subscribing to activity feed...');
|
||||
logger.watch(`Watching trader: ${config.traderAddress}`);
|
||||
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
action: 'subscribe',
|
||||
subscriptions: [{
|
||||
topic: 'activity',
|
||||
type: 'trades',
|
||||
}],
|
||||
}));
|
||||
|
||||
startPing();
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
handleMessage(data);
|
||||
});
|
||||
|
||||
ws.on('ping', () => {
|
||||
ws?.pong();
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
const reasonStr = reason ? reason.toString() : 'no reason';
|
||||
logger.warn(`WebSocket closed (${code}): ${reasonStr}`);
|
||||
cleanup(true);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logger.error(`WebSocket error: ${err.message}`);
|
||||
cleanup(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the real-time WebSocket watcher
|
||||
* @param {Function} onTrade - async function called when trader makes a trade
|
||||
*/
|
||||
export function startWsWatcher(onTrade) {
|
||||
tradeHandler = onTrade;
|
||||
isShuttingDown = false;
|
||||
reconnectDelay = INITIAL_RECONNECT_DELAY;
|
||||
connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the WebSocket watcher
|
||||
*/
|
||||
export function stopWsWatcher() {
|
||||
isShuttingDown = true;
|
||||
cleanup(false);
|
||||
logger.info('WebSocket watcher stopped');
|
||||
}
|
||||
Reference in New Issue
Block a user