diff --git a/.gitignore b/.gitignore index bf8f74e..460b439 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .env data/*.json .DS_Store +logs/*.log diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..d4d8b65 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,47 @@ +/** + * PM2 ecosystem config + * + * Usage: + * pm2 start ecosystem.config.cjs # live trading + * pm2 start ecosystem.config.cjs --env sim # simulation / dry-run + * + * pm2 logs polymarket-copy # tail logs + * pm2 logs polymarket-copy --lines 200 + * pm2 stop / restart / delete polymarket-copy + */ +module.exports = { + apps: [ + { + name: 'polymarket-copy', + script: 'src/bot.js', + interpreter: 'node', + + // Live trading (default) + env: { + NODE_ENV: 'production', + DRY_RUN: 'false', + }, + + // Simulation: pm2 start ecosystem.config.cjs --env sim + env_sim: { + NODE_ENV: 'production', + DRY_RUN: 'true', + }, + + // Log files (relative to project root) + out_file: 'logs/out.log', + error_file: 'logs/error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + + // Restart policy + restart_delay: 5000, // wait 5s before restarting + max_restarts: 10, // give up after 10 crashes in a row + min_uptime: '10s', // must stay up ≥10s to count as "stable" + max_memory_restart: '256M', + + // Treat non-zero exit as a crash (don't restart on clean SIGTERM) + stop_exit_codes: [0], + }, + ], +}; diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/package.json b/package.json index 67274c3..ce09326 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "scripts": { "start": "node src/index.js", "dev": "nodemon --ignore 'data/*.json' src/index.js", + "bot": "node src/bot.js", + "bot-sim": "DRY_RUN=true node src/bot.js", + "bot-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/bot.js", "mm": "DRY_RUN=false node src/mm.js", "mm-sim": "DRY_RUN=true node src/mm.js", "mm-dev": "DRY_RUN=true nodemon --ignore 'data/*.json' src/mm.js", diff --git a/src/bot.js b/src/bot.js new file mode 100644 index 0000000..45bafcc --- /dev/null +++ b/src/bot.js @@ -0,0 +1,149 @@ +/** + * bot.js — PM2 / VPS entry point (no TUI) + * + * Plain-text stdout output, compatible with: + * pm2 start ecosystem.config.cjs + * pm2 logs polymarket-copy + */ +import config, { validateConfig } from './config/index.js'; +import { initClient, getUsdcBalance, getClient } from './services/client.js'; +import { executeBuy, executeSell } from './services/executor.js'; +import { checkAndRedeemPositions } from './services/redeemer.js'; +import { getOpenPositions } from './services/position.js'; +import { startWsWatcher, stopWsWatcher } from './services/wsWatcher.js'; +import { getSimStats } from './utils/simStats.js'; +import logger from './utils/logger.js'; + +logger.interceptConsole(); // strip auth headers from CLOB axios error dumps + +// ── Handle a trade event from WebSocket ─────────────────────────────────────── +async function handleTrade(trade) { + try { + if (trade.type === 'BUY') await executeBuy(trade); + if (trade.type === 'SELL') await executeSell(trade); + } catch (err) { + logger.error(`Error processing trade ${trade.id}: ${err.message}`); + } +} + +// ── Periodic status log (replaces TUI right panel) ──────────────────────────── +async function printStatus() { + try { + const balance = await getUsdcBalance(); + const positions = getOpenPositions(); + + logger.info(`--- Status | Balance: $${balance.toFixed(2)} USDC | Open positions: ${positions.length} ---`); + + for (const pos of positions) { + let pnlStr = ''; + try { + const client = getClient(); + const mp = await client.getMidpoint(pos.tokenId); + const mid = parseFloat(mp?.mid ?? mp ?? '0'); + if (mid > 0) { + const pnl = (mid - pos.avgBuyPrice) * pos.shares; + const sign = pnl >= 0 ? '+' : ''; + const pct = pos.totalCost > 0 ? ((pnl / pos.totalCost) * 100).toFixed(1) : '0.0'; + pnlStr = ` | unrealized ${sign}$${pnl.toFixed(2)} (${sign}${pct}%)`; + } + } catch { /* price unavailable */ } + + const name = (pos.market || pos.tokenId || '').substring(0, 50); + logger.info( + ` [${pos.outcome || '?'}] ${name}` + + ` | ${pos.shares.toFixed(4)} sh @ $${pos.avgBuyPrice.toFixed(4)}` + + ` | spent $${(pos.totalCost || 0).toFixed(2)}${pnlStr}`, + ); + } + + if (config.dryRun) { + const s = getSimStats(); + if (s.totalBuys > 0 || s.totalResolved > 0) { + const rate = s.totalResolved > 0 + ? `${((s.wins / s.totalResolved) * 100).toFixed(0)}% win` + : 'no resolved yet'; + logger.info( + ` [SIM] ${s.totalBuys} buys tracked | ${s.wins}W/${s.losses}L (${rate})` + + ` | realized P&L: $${(s.closedPnl || 0).toFixed(2)}`, + ); + } + } + } catch (err) { + logger.warn(`Status check error: ${err.message}`); + } +} + +// ── Redeemer loop ───────────────────────────────────────────────────────────── +async function redeemerLoop() { + try { + await checkAndRedeemPositions(); + } catch (err) { + logger.error('Redeemer loop error:', err.message); + } +} + +// ── Main ────────────────────────────────────────────────────────────────────── +async function main() { + try { + validateConfig(); + } catch (err) { + logger.error(err.message); + process.exit(1); + } + + const mode = config.dryRun ? 'SIMULATION' : 'LIVE TRADING'; + logger.info(`=== Polymarket Copy Trade [${mode}] ===`); + logger.info(`Trader : ${config.traderAddress}`); + logger.info(`Proxy wallet : ${config.proxyWallet}`); + logger.info(`Size mode : ${config.sizeMode} (${config.sizePercent}%)`); + logger.info(`Min trade : $${config.minTradeSize}`); + logger.info(`Max position : $${config.maxPositionSize} per market`); + logger.info(`Auto sell : ${config.autoSellEnabled ? `ON (+${config.autoSellProfitPercent}%)` : 'OFF'}`); + logger.info(`Sell mode : ${config.sellMode}`); + logger.info(`Min time left: ${config.minMarketTimeLeft}s`); + logger.info('=========================================='); + + try { + await initClient(); + } catch (err) { + logger.error('Failed to initialize CLOB client:', err.message); + process.exit(1); + } + + try { + const balance = await getUsdcBalance(); + logger.money(`USDC.e Balance: $${balance.toFixed(2)}`); + } catch (err) { + logger.warn('Could not fetch balance:', err.message); + } + + logger.success( + config.dryRun + ? 'Simulation started — watching trader in real-time...' + : 'Bot started — watching trader in real-time...', + ); + + startWsWatcher(handleTrade); + + await redeemerLoop(); + const redeemerInterval = setInterval(redeemerLoop, config.redeemInterval); + + // Print status every 60 seconds + const statusInterval = setInterval(printStatus, 60_000); + + const shutdown = () => { + logger.info('Shutting down...'); + stopWsWatcher(); + clearInterval(redeemerInterval); + clearInterval(statusInterval); + setTimeout(() => process.exit(0), 300); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((err) => { + logger.error('Fatal error:', err.message); + process.exit(1); +});