feat: initial polymarket copy trade tool

- Watcher: polls Data API for trader activity
- Executor: buy/sell with market orders + retry logic
- Position manager: JSON-based state tracking
- Auto-sell: limit orders at profit target
- Redeemer: check & redeem winning positions on-chain
- Config: env-based settings with validation
- DRY_RUN mode for safe testing
This commit is contained in:
direkturcrypto
2026-02-22 15:38:13 +07:00
commit 7c7fad45f3
16 changed files with 3211 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# Wallet
PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE
WALLET_ADDRESS=0xYOUR_WALLET_ADDRESS_HERE
# Polymarket API Credentials (optional, auto-derived if not set)
CLOB_API_KEY=
CLOB_API_SECRET=
CLOB_API_PASSPHRASE=
# Trader to Copy
TRADER_ADDRESS=0xTRADER_ADDRESS_TO_COPY
# Trade Sizing
# "percentage" = % of trader's trade size, "balance" = % of own balance
SIZE_MODE=percentage
SIZE_PERCENT=50
MIN_TRADE_SIZE=1
# Auto Sell
AUTO_SELL_ENABLED=true
AUTO_SELL_PROFIT_PERCENT=10
# Sell Mode when copying trader's sell
# "market" = sell at market price, "limit" = sell at trader's avg sell price
SELL_MODE=market
# Polling Intervals (in seconds)
POLL_INTERVAL=15
REDEEM_INTERVAL=60
# Dry Run (set to true to simulate without executing trades)
DRY_RUN=true
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.env
data/*.json
.DS_Store
+64
View File
@@ -0,0 +1,64 @@
# AGENT.MD — Polymarket Copy Trade Tool
Dokumentasi untuk AI Agent yang akan melanjutkan atau memodifikasi project ini.
## Overview
Tool untuk copy trade otomatis dari trader di Polymarket. Dibangun dengan Node.js (ESM), menggunakan Polymarket CLOB SDK.
## Architecture
- **Config** (`src/config/index.js`): Semua settings dari `.env`, validasi required fields
- **Client** (`src/services/client.js`): Inisialisasi `ClobClient` dari `@polymarket/clob-client`, auto-derive API creds, cek balance USDC.e on-chain
- **Watcher** (`src/services/watcher.js`): Polling `data-api.polymarket.com/activity?user={address}` untuk deteksi trade baru. Dedup via `processed_trades.json`
- **Executor** (`src/services/executor.js`): Execute buy (market FOK order + retry) dan sell (market/limit). Sizing mode: percentage of trader size atau percentage of own balance
- **Position** (`src/services/position.js`): CRUD posisi di `positions.json`. Prevent duplicate buy per conditionId (1 market = 1 buy only)
- **AutoSell** (`src/services/autoSell.js`): Setelah buy filled, place GTC limit sell di `avgBuyPrice * (1 + profitPercent/100)`. Rounded ke tick size yang valid
- **Redeemer** (`src/services/redeemer.js`): Check resolved markets via Gamma API + on-chain CTF `payoutDenominator`. Redeem via CTF `redeemPositions`
- **State** (`src/utils/state.js`): Atomic JSON file writes (tmp file + rename) ke folder `data/`
- **Logger** (`src/utils/logger.js`): Timestamped, color-coded, emoji-prefixed console logging
## Key APIs Used
| API | Base URL | Auth | Purpose |
|-----|----------|------|---------|
| Gamma API | `gamma-api.polymarket.com` | No | Market info, resolution status |
| Data API | `data-api.polymarket.com` | No | Trader activity, positions |
| CLOB API | `clob.polymarket.com` | Yes (L2) | Place/cancel orders |
| Polygon RPC | `polygon-rpc.com` | No | USDC balance, CTF redeem |
## Dependencies
- `@polymarket/clob-client` — Official Polymarket CLOB SDK (uses ethers v5 internally)
- `ethers@5` — Wallet signing, contract interaction
- `dotenv` — Environment variable loading
- `nodemon` (dev) — Auto-reload on file changes (ignores `data/*.json`)
## State Files (data/)
- `positions.json` — Active positions: `{ [conditionId]: { tokenId, shares, avgBuyPrice, ... } }`
- `processed_trades.json` — Already-handled trade IDs (max 500): `{ tradeIds: [...] }`
## Trade Flow
1. Watcher detects new BUY → check no existing position → calculate size → check balance → market buy (FOK + retry) → save position → auto-sell if enabled
2. Watcher detects new SELL → check position exists → cancel auto-sell order → market/limit sell → remove position
3. Redeemer loop → check resolved markets → redeem CTF on-chain → remove position
## Important Notes
- Module type: ESM (`"type": "module"` in package.json)
- USDC on Polygon = USDC.e (`0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`)
- CTF Contract = `0x4D97DCd97eC945f40cF65F87097ACe5EA0476045`
- Neg Risk CTF = `0xC5d563A36AE78145C45a50134d48A1215220f80a`
- Signature type 0 = EOA wallet
- Tick sizes: 0.1, 0.01, 0.001, 0.0001
- Market orders use FOK (fill-or-kill), limit orders use GTC (good-til-cancelled)
- Data API activity response fields vary — code handles multiple field name conventions
## Known Limitations
- No WebSocket support yet (polling only)
- No partial fill tracking for market orders
- Redeem requires MATIC for gas
- No proxy wallet support (EOA only, signature type 0)
+111
View File
@@ -0,0 +1,111 @@
# 🎯 Polymarket Copy Trade Tool
Auto-copy trades dari trader manapun di Polymarket.
## Features
- 👀 **Watch Trader** — Monitor aktivitas trading dari address wallet tertentu
- 📊 **Copy Buy** — Otomatis buy ketika trader buy, dengan sizing yang bisa di-setting
- 📉 **Copy Sell** — Otomatis sell ketika trader sell (market / limit)
- 💰 **Auto Sell** — Pasang limit sell otomatis setelah buy filled (sesuai target profit %)
- 🏆 **Auto Redeem** — Cek dan redeem posisi yang sudah WIN secara berkala
- 🔄 **Smart Position** — 1 market hanya buy 1x, tidak duplikat
-**Balance Check** — Cek saldo sebelum trade
- 🧪 **Dry Run Mode** — Test tanpa eksekusi trade sungguhan
## Setup
### 1. Clone & Install
```bash
git clone <repo-url>
cd polymarket-copy
npm install
```
### 2. Configure Environment
```bash
cp .env.example .env
```
Edit `.env` dengan setting Anda:
| Variable | Description | Default |
|---|---|---|
| `PRIVATE_KEY` | Private key wallet Polygon | (required) |
| `WALLET_ADDRESS` | Address wallet Anda | (required) |
| `TRADER_ADDRESS` | Address trader yang mau di-copy | (required) |
| `SIZE_MODE` | `percentage` (dari size trader) atau `balance` (dari balance sendiri) | `percentage` |
| `SIZE_PERCENT` | Persentase sizing | `50` |
| `MIN_TRADE_SIZE` | Minimum trade dalam USDC | `1` |
| `AUTO_SELL_ENABLED` | Aktifkan auto-sell | `true` |
| `AUTO_SELL_PROFIT_PERCENT` | Target profit % untuk auto-sell | `10` |
| `SELL_MODE` | `market` atau `limit` saat copy sell | `market` |
| `POLL_INTERVAL` | Interval polling (detik) | `15` |
| `REDEEM_INTERVAL` | Interval cek redeem (detik) | `60` |
| `DRY_RUN` | Mode simulasi tanpa real trade | `true` |
### 3. Run
```bash
# Development (auto-reload)
npm run dev
# Production
npm start
```
## How It Works
```
┌─────────────────────────────────────────────┐
│ WATCHER LOOP │
│ Poll Data API setiap N detik │
│ → Cek trade baru dari trader │
├─────────────────┬───────────────────────────┤
│ NEW BUY │ NEW SELL │
│ │ │
│ ✓ Cek posisi │ ✓ Cek ada posisi? │
│ ✓ Cek balance │ ✓ Cancel auto-sell │
│ ✓ Market order │ ✓ Market/Limit sell │
│ ✓ Retry loop │ ✓ Retry loop │
│ ✓ Auto-sell │ ✓ Remove position │
│ ✓ Save posisi │ │
├─────────────────┴───────────────────────────┤
│ REDEEMER LOOP │
│ Cek berkala posisi yang sudah WIN │
│ → Redeem on-chain via CTF contract │
└─────────────────────────────────────────────┘
```
## Folder Structure
```
polymarket-copy/
├── src/
│ ├── config/index.js — Environment vars & settings
│ ├── services/
│ │ ├── client.js — CLOB client init & balance check
│ │ ├── watcher.js — Poll trader activity
│ │ ├── executor.js — Buy & sell logic
│ │ ├── position.js — Position management
│ │ ├── autoSell.js — Auto limit sell
│ │ └── redeemer.js — Redeem winning positions
│ ├── utils/
│ │ ├── logger.js — Color-coded logging
│ │ └── state.js — JSON state management
│ └── index.js — Main entry point
├── data/ — Runtime state (gitignored)
├── .env.example
├── .gitignore
└── package.json
```
## Important Notes
- ⚠️ **Test dengan DRY_RUN=true** terlebih dahulu
- ⚠️ **Gunakan SIZE_PERCENT kecil** untuk percobaan awal
- ⚠️ **Private key jangan di-commit** — sudah ada di .gitignore
- Butuh USDC.e di Polygon untuk trading
- Butuh sedikit MATIC untuk gas fee (redeem positions)
+1832
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "polymarket-copy",
"version": "1.0.0",
"description": "Polymarket Copy Trade Tool - Auto copy trades from any trader",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon --ignore 'data/*.json' src/index.js"
},
"keywords": ["polymarket", "copy-trade", "crypto"],
"author": "direkturcrypto",
"license": "ISC",
"dependencies": {
"@polymarket/clob-client": "^4.7.3",
"dotenv": "^16.4.7",
"ethers": "^5.8.0"
},
"devDependencies": {
"nodemon": "^3.1.9"
}
}
+62
View File
@@ -0,0 +1,62 @@
import dotenv from 'dotenv';
dotenv.config();
const config = {
// Wallet
privateKey: process.env.PRIVATE_KEY,
walletAddress: process.env.WALLET_ADDRESS,
// Polymarket API (optional, auto-derived if empty)
clobApiKey: process.env.CLOB_API_KEY || '',
clobApiSecret: process.env.CLOB_API_SECRET || '',
clobApiPassphrase: process.env.CLOB_API_PASSPHRASE || '',
// Polymarket endpoints
clobHost: 'https://clob.polymarket.com',
gammaHost: 'https://gamma-api.polymarket.com',
dataHost: 'https://data-api.polymarket.com',
chainId: 137,
// Trader to copy
traderAddress: process.env.TRADER_ADDRESS,
// Trade sizing
sizeMode: process.env.SIZE_MODE || 'percentage', // "percentage" | "balance"
sizePercent: parseFloat(process.env.SIZE_PERCENT || '50'),
minTradeSize: parseFloat(process.env.MIN_TRADE_SIZE || '1'),
// Auto sell
autoSellEnabled: process.env.AUTO_SELL_ENABLED === 'true',
autoSellProfitPercent: parseFloat(process.env.AUTO_SELL_PROFIT_PERCENT || '10'),
// Sell mode when copying sell
sellMode: process.env.SELL_MODE || 'market', // "market" | "limit"
// Polling intervals (seconds)
pollInterval: parseInt(process.env.POLL_INTERVAL || '15', 10) * 1000,
redeemInterval: parseInt(process.env.REDEEM_INTERVAL || '60', 10) * 1000,
// Dry run
dryRun: process.env.DRY_RUN === 'true',
// Retry settings
maxRetries: 5,
retryDelay: 3000,
};
// Validation
export function validateConfig() {
const required = ['privateKey', 'walletAddress', 'traderAddress'];
const missing = required.filter((key) => !config[key]);
if (missing.length > 0) {
throw new Error(`Missing required config: ${missing.join(', ')}. Check your .env file.`);
}
if (!['percentage', 'balance'].includes(config.sizeMode)) {
throw new Error(`Invalid SIZE_MODE: ${config.sizeMode}. Use "percentage" or "balance".`);
}
if (!['market', 'limit'].includes(config.sellMode)) {
throw new Error(`Invalid SELL_MODE: ${config.sellMode}. Use "market" or "limit".`);
}
}
export default config;
+142
View File
@@ -0,0 +1,142 @@
import config, { validateConfig } from './config/index.js';
import { initClient, getUsdcBalance } from './services/client.js';
import { checkNewTrades, markTradeProcessed } from './services/watcher.js';
import { executeBuy, executeSell } from './services/executor.js';
import { checkAndRedeemPositions } from './services/redeemer.js';
import { getOpenPositions } from './services/position.js';
import logger from './utils/logger.js';
// ASCII Art Banner
function showBanner() {
console.log(`
\x1b[36m╔══════════════════════════════════════════════════════╗
║ 🎯 POLYMARKET COPY TRADE TOOL 🎯 ║
║ Auto-copy trades from any trader ║
╚══════════════════════════════════════════════════════╝\x1b[0m
`);
}
// Show current settings
function showSettings() {
logger.info('=== Settings ===');
logger.info(`Trader: ${config.traderAddress}`);
logger.info(`Size Mode: ${config.sizeMode} (${config.sizePercent}%)`);
logger.info(`Min Trade Size: $${config.minTradeSize}`);
logger.info(`Auto Sell: ${config.autoSellEnabled ? `ON (${config.autoSellProfitPercent}% profit)` : 'OFF'}`);
logger.info(`Sell Mode: ${config.sellMode}`);
logger.info(`Poll Interval: ${config.pollInterval / 1000}s`);
logger.info(`Redeem Interval: ${config.redeemInterval / 1000}s`);
logger.info(`Dry Run: ${config.dryRun ? 'YES (no real trades)' : 'NO (live trading!)'}`);
logger.info('================');
}
// Main watcher loop
async function watcherLoop() {
try {
logger.watch('Checking trader activity...');
const newTrades = await checkNewTrades();
if (newTrades.length === 0) {
logger.watch('No new trades from trader');
return;
}
logger.watch(`Found ${newTrades.length} new trade(s) from trader`);
for (const trade of newTrades) {
try {
if (trade.type === 'BUY') {
await executeBuy(trade);
} else if (trade.type === 'SELL') {
await executeSell(trade);
}
} catch (err) {
logger.error(`Error processing trade ${trade.id}:`, err.message);
}
// Mark as processed regardless of success/failure
markTradeProcessed(trade.id);
}
} catch (err) {
logger.error('Watcher loop error:', err.message);
}
}
// Redeemer loop
async function redeemerLoop() {
try {
await checkAndRedeemPositions();
} catch (err) {
logger.error('Redeemer loop error:', err.message);
}
}
// Main
async function main() {
showBanner();
// Validate config
try {
validateConfig();
} catch (err) {
logger.error(err.message);
process.exit(1);
}
showSettings();
// Initialize client
try {
await initClient();
} catch (err) {
logger.error('Failed to initialize client:', err.message);
process.exit(1);
}
// Show balance
try {
const balance = await getUsdcBalance();
logger.money(`USDC.e Balance: $${balance.toFixed(2)}`);
} catch (err) {
logger.warn('Could not fetch balance:', err.message);
}
// Show existing positions
const positions = getOpenPositions();
if (positions.length > 0) {
logger.info(`Existing positions: ${positions.length}`);
positions.forEach((p) => {
logger.info(` - ${p.market} | ${p.shares} shares @ $${p.avgBuyPrice}`);
});
}
logger.success('Bot started! Watching trader activity...');
logger.info('Press Ctrl+C to stop');
// Start loops
// Initial run
await watcherLoop();
await redeemerLoop();
// Interval loops
const watcherInterval = setInterval(watcherLoop, config.pollInterval);
const redeemerInterval = setInterval(redeemerLoop, config.redeemInterval);
// Graceful shutdown
const shutdown = () => {
logger.info('Shutting down...');
clearInterval(watcherInterval);
clearInterval(redeemerInterval);
logger.info('Goodbye! 👋');
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
logger.error('Fatal error:', err.message);
console.error(err);
process.exit(1);
});
+63
View File
@@ -0,0 +1,63 @@
import { Side, OrderType } from '@polymarket/clob-client';
import config from '../config/index.js';
import { getClient } from './client.js';
import { updatePosition } from './position.js';
import logger from '../utils/logger.js';
/**
* Place an auto-sell limit order at profit target above avg buy price
* @param {string} conditionId - Market condition ID
* @param {string} tokenId - CLOB token ID
* @param {number} shares - Number of shares to sell
* @param {number} avgBuyPrice - Average buy price
* @param {Object} marketOpts - { tickSize, negRisk }
*/
export async function placeAutoSell(conditionId, tokenId, shares, avgBuyPrice, marketOpts) {
try {
const profitMultiplier = 1 + config.autoSellProfitPercent / 100;
let sellPrice = avgBuyPrice * profitMultiplier;
// Round to tick size
const tickSize = parseFloat(marketOpts.tickSize);
sellPrice = Math.round(sellPrice / tickSize) * tickSize;
// Clamp between 0.01 and 0.99
sellPrice = Math.max(0.01, Math.min(0.99, sellPrice));
// Format to proper decimal places
const decimals = marketOpts.tickSize.split('.')[1]?.length || 2;
sellPrice = parseFloat(sellPrice.toFixed(decimals));
logger.trade(`Auto-sell: ${shares} shares @ $${sellPrice} (${config.autoSellProfitPercent}% above avg buy $${avgBuyPrice.toFixed(4)})`);
if (config.dryRun) {
logger.info('[DRY RUN] Would place limit sell order');
updatePosition(conditionId, { sellOrderId: 'dry-run-order' });
return;
}
const client = getClient();
const response = await client.createAndPostOrder(
{
tokenID: tokenId,
price: sellPrice,
size: shares,
side: Side.SELL,
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.GTC,
);
if (response && response.success) {
logger.success(`Auto-sell order placed: ${response.orderID} @ $${sellPrice}`);
updatePosition(conditionId, { sellOrderId: response.orderID });
} else {
logger.warn(`Auto-sell failed: ${response?.errorMsg || 'Unknown'}`);
}
} catch (err) {
logger.error('Failed to place auto-sell:', err.message);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { ClobClient } from '@polymarket/clob-client';
import { Wallet } from 'ethers';
import config from '../config/index.js';
import logger from '../utils/logger.js';
let clobClient = null;
let signer = null;
/**
* Initialize the Polymarket CLOB client
* Auto-derives API credentials if not provided in .env
*/
export async function initClient() {
logger.info('Initializing Polymarket CLOB client...');
signer = new Wallet(config.privateKey);
const walletAddress = signer.address;
logger.info(`Wallet address: ${walletAddress}`);
// Step 1: Create temp client to derive API credentials
let apiCreds;
if (config.clobApiKey && config.clobApiSecret && config.clobApiPassphrase) {
apiCreds = {
key: config.clobApiKey,
secret: config.clobApiSecret,
passphrase: config.clobApiPassphrase,
};
logger.info('Using API credentials from .env');
} else {
const tempClient = new ClobClient(config.clobHost, config.chainId, signer);
apiCreds = await tempClient.createOrDeriveApiKey();
logger.info('API credentials derived successfully');
}
// Step 2: Initialize full trading client
clobClient = new ClobClient(
config.clobHost,
config.chainId,
signer,
apiCreds,
0, // Signature type: 0 = EOA
config.walletAddress || walletAddress, // Funder address
);
logger.success('CLOB client initialized');
return clobClient;
}
/**
* Get the initialized CLOB client
*/
export function getClient() {
if (!clobClient) {
throw new Error('CLOB client not initialized. Call initClient() first.');
}
return clobClient;
}
/**
* Get the signer wallet
*/
export function getSigner() {
if (!signer) {
throw new Error('Signer not initialized. Call initClient() first.');
}
return signer;
}
/**
* Get USDC.e balance on Polygon for the wallet
*/
export async function getUsdcBalance() {
const { ethers } = await import('ethers');
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
const usdcAddress = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174'; // USDC.e on Polygon
const abi = ['function balanceOf(address) view returns (uint256)'];
const usdc = new ethers.Contract(usdcAddress, abi, provider);
const funderAddress = config.walletAddress || signer.address;
const balance = await usdc.balanceOf(funderAddress);
return parseFloat(ethers.utils.formatUnits(balance, 6));
}
+319
View File
@@ -0,0 +1,319 @@
import { Side, OrderType } from '@polymarket/clob-client';
import config from '../config/index.js';
import { getClient, getUsdcBalance } from './client.js';
import { hasPosition, addPosition, getPosition, updatePosition, removePosition } from './position.js';
import { fetchMarketByTokenId } from './watcher.js';
import { placeAutoSell } from './autoSell.js';
import logger from '../utils/logger.js';
/**
* Calculate trade size based on settings
* @param {number} traderSize - Trader's trade size in USDC
* @returns {number} Our trade size in USDC
*/
async function calculateTradeSize(traderSize) {
if (config.sizeMode === 'percentage') {
// % of trader's trade size
return traderSize * (config.sizePercent / 100);
} else if (config.sizeMode === 'balance') {
// % of our own balance
const balance = await getUsdcBalance();
return balance * (config.sizePercent / 100);
}
return 0;
}
/**
* Get market options (tick size and neg risk) for a token
*/
async function getMarketOptions(tokenId) {
const client = getClient();
try {
// Try to get from market info
const marketInfo = await fetchMarketByTokenId(tokenId);
if (marketInfo) {
return {
tickSize: String(marketInfo.minimum_tick_size || '0.01'),
negRisk: marketInfo.neg_risk || false,
conditionId: marketInfo.condition_id || '',
question: marketInfo.question || '',
};
}
} catch (err) {
logger.warn('Failed to get market info, using defaults:', err.message);
}
// Fallback: try SDK methods
try {
const tickSize = await client.getTickSize(tokenId);
const negRisk = await client.getNegRisk(tokenId);
return { tickSize: String(tickSize), negRisk, conditionId: '', question: '' };
} catch (err) {
logger.warn('Failed to get tick size from SDK, using default 0.01');
return { tickSize: '0.01', negRisk: false, conditionId: '', question: '' };
}
}
/**
* Execute a BUY trade (copy trader's buy)
* @param {Object} trade - Trade info from watcher
*/
export async function executeBuy(trade) {
const { tokenId, conditionId, market, price, size } = trade;
// Check if already have position for this market
if (hasPosition(conditionId)) {
logger.warn(`Already have position for: ${market || conditionId}. Skipping buy.`);
return;
}
// Calculate our trade size
const tradeSize = await calculateTradeSize(size * price); // trader's USDC amount
if (tradeSize < config.minTradeSize) {
logger.warn(`Trade size $${tradeSize.toFixed(2)} below minimum $${config.minTradeSize}. Skipping.`);
return;
}
// Check balance
const balance = await getUsdcBalance();
if (balance < tradeSize) {
logger.error(`Insufficient balance: $${balance.toFixed(2)} < $${tradeSize.toFixed(2)} needed`);
return;
}
// Get market options
const marketOpts = await getMarketOptions(tokenId);
const effectiveConditionId = conditionId || marketOpts.conditionId;
// Double check no position exists
if (effectiveConditionId && hasPosition(effectiveConditionId)) {
logger.warn(`Already have position for: ${market || effectiveConditionId}. Skipping buy.`);
return;
}
logger.trade(`BUY ${market || tokenId} | Size: $${tradeSize.toFixed(2)} | Trader price: ${price}`);
if (config.dryRun) {
logger.info('[DRY RUN] Would place market buy order');
// Still record position in dry run for testing
addPosition({
conditionId: effectiveConditionId,
tokenId,
market: market || marketOpts.question || tokenId,
shares: tradeSize / price,
avgBuyPrice: price,
totalCost: tradeSize,
outcome: trade.outcome,
});
return;
}
// Place market order with retries
const client = getClient();
let filled = false;
let totalSharesFilled = 0;
let totalCostFilled = 0;
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
const remainingAmount = tradeSize - totalCostFilled;
if (remainingAmount < config.minTradeSize) break;
logger.info(`Buy attempt ${attempt}/${config.maxRetries} | Amount: $${remainingAmount.toFixed(2)}`);
// Use FAK (fill-and-kill) to get what's available, then retry remainder
const response = await client.createAndPostMarketOrder(
{
tokenID: tokenId,
side: Side.BUY,
amount: remainingAmount,
price: Math.min(price * 1.05, 0.99), // 5% slippage allowance, max 0.99
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.FOK,
);
if (response && response.success) {
logger.success(`Order placed: ${response.orderID} | Status: ${response.status}`);
// Check if fully filled by trying to get trade info
const takingAmount = parseFloat(response.takingAmount || '0');
const makingAmount = parseFloat(response.makingAmount || '0');
if (takingAmount > 0 || makingAmount > 0) {
totalSharesFilled += takingAmount || (remainingAmount / price);
totalCostFilled += makingAmount || remainingAmount;
filled = true;
break; // FOK either fills fully or cancels
} else {
filled = true;
totalSharesFilled = tradeSize / price;
totalCostFilled = tradeSize;
break;
}
} else {
logger.warn(`Order not filled. Error: ${response?.errorMsg || 'Unknown'}`);
}
} catch (err) {
logger.error(`Buy attempt ${attempt} failed:`, err.message);
}
// Wait before retry
if (attempt < config.maxRetries) {
await new Promise((r) => setTimeout(r, config.retryDelay));
}
}
if (!filled || totalCostFilled === 0) {
logger.error(`Failed to fill buy order for ${market || tokenId} after ${config.maxRetries} attempts`);
return;
}
// Calculate avg buy price
const avgBuyPrice = totalSharesFilled > 0 ? totalCostFilled / totalSharesFilled : price;
// Record position
addPosition({
conditionId: effectiveConditionId,
tokenId,
market: market || marketOpts.question || tokenId,
shares: totalSharesFilled,
avgBuyPrice,
totalCost: totalCostFilled,
outcome: trade.outcome,
});
// Auto-sell if enabled
if (config.autoSellEnabled) {
await placeAutoSell(effectiveConditionId, tokenId, totalSharesFilled, avgBuyPrice, marketOpts);
}
}
/**
* Execute a SELL trade (copy trader's sell)
* @param {Object} trade - Trade info from watcher
*/
export async function executeSell(trade) {
const { tokenId, conditionId, market, price } = trade;
// Get market options to resolve conditionId
let effectiveConditionId = conditionId;
let marketOpts;
if (!effectiveConditionId) {
marketOpts = await getMarketOptions(tokenId);
effectiveConditionId = marketOpts.conditionId;
}
// Check if we have a position
const position = getPosition(effectiveConditionId);
if (!position) {
logger.warn(`No position found for: ${market || effectiveConditionId}. Skipping sell.`);
return;
}
if (position.status === 'selling' || position.status === 'sold') {
logger.warn(`Position already ${position.status}: ${market || effectiveConditionId}. Skipping.`);
return;
}
logger.trade(`SELL ${position.market} | Shares: ${position.shares} | Trader price: ${price}`);
if (config.dryRun) {
logger.info('[DRY RUN] Would place sell order');
removePosition(effectiveConditionId);
return;
}
// Cancel existing auto-sell order if any
if (position.sellOrderId) {
try {
const client = getClient();
await client.cancelOrder(position.sellOrderId);
logger.info(`Cancelled auto-sell order: ${position.sellOrderId}`);
} catch (err) {
logger.warn(`Failed to cancel auto-sell: ${err.message}`);
}
}
updatePosition(effectiveConditionId, { status: 'selling' });
if (!marketOpts) {
marketOpts = await getMarketOptions(tokenId);
}
const client = getClient();
let filled = false;
for (let attempt = 1; attempt <= config.maxRetries; attempt++) {
try {
if (config.sellMode === 'market') {
// Market sell (FOK)
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (market) | Shares: ${position.shares}`);
const response = await client.createAndPostMarketOrder(
{
tokenID: tokenId,
side: Side.SELL,
amount: position.shares,
price: Math.max(price * 0.95, 0.01), // 5% slippage, min 0.01
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.FOK,
);
if (response && response.success) {
logger.success(`Sell order placed: ${response.orderID}`);
filled = true;
break;
} else {
logger.warn(`Sell not filled: ${response?.errorMsg || 'Unknown'}`);
}
} else {
// Limit sell at trader's sell price
logger.info(`Sell attempt ${attempt}/${config.maxRetries} (limit) | Price: ${price}`);
const response = await client.createAndPostOrder(
{
tokenID: tokenId,
price: price,
size: position.shares,
side: Side.SELL,
},
{
tickSize: marketOpts.tickSize,
negRisk: marketOpts.negRisk,
},
OrderType.GTC,
);
if (response && response.success) {
logger.success(`Limit sell placed: ${response.orderID} @ $${price}`);
filled = true;
break;
} else {
logger.warn(`Limit sell failed: ${response?.errorMsg || 'Unknown'}`);
}
}
} catch (err) {
logger.error(`Sell attempt ${attempt} failed:`, err.message);
}
if (attempt < config.maxRetries) {
await new Promise((r) => setTimeout(r, config.retryDelay));
}
}
if (filled) {
removePosition(effectiveConditionId);
logger.money(`Position sold: ${position.market}`);
} else {
updatePosition(effectiveConditionId, { status: 'open' });
logger.error(`Failed to sell ${position.market} after ${config.maxRetries} attempts`);
}
}
+112
View File
@@ -0,0 +1,112 @@
import { readState, writeState } from '../utils/state.js';
import logger from '../utils/logger.js';
const POSITIONS_FILE = 'positions.json';
/**
* Get all current positions
* @returns {Object} Map of conditionId -> position data
*/
export function getPositions() {
return readState(POSITIONS_FILE, {});
}
/**
* Check if we already have a position for this market (conditionId)
* @param {string} conditionId
* @returns {boolean}
*/
export function hasPosition(conditionId) {
const positions = getPositions();
return !!positions[conditionId];
}
/**
* Add a new position after buy is filled
* @param {Object} params
* @param {string} params.conditionId - Market condition ID
* @param {string} params.tokenId - CLOB token ID
* @param {string} params.market - Market question/title
* @param {number} params.shares - Number of shares bought
* @param {number} params.avgBuyPrice - Average buy price
* @param {number} params.totalCost - Total USDC spent
* @param {string} params.outcome - YES/NO outcome
* @param {string} [params.sellOrderId] - Auto-sell order ID if placed
*/
export function addPosition({
conditionId,
tokenId,
market,
shares,
avgBuyPrice,
totalCost,
outcome,
sellOrderId,
}) {
const positions = getPositions();
positions[conditionId] = {
conditionId,
tokenId,
market,
shares,
avgBuyPrice,
totalCost,
outcome: outcome || '',
sellOrderId: sellOrderId || null,
status: 'open', // open, selling, sold, redeemed
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
writeState(POSITIONS_FILE, positions);
logger.success(`Position added: ${market} | ${shares} shares @ $${avgBuyPrice}`);
}
/**
* Update a position
* @param {string} conditionId
* @param {Object} updates - Fields to update
*/
export function updatePosition(conditionId, updates) {
const positions = getPositions();
if (positions[conditionId]) {
positions[conditionId] = {
...positions[conditionId],
...updates,
updatedAt: new Date().toISOString(),
};
writeState(POSITIONS_FILE, positions);
}
}
/**
* Remove a position (after sell or redeem)
* @param {string} conditionId
*/
export function removePosition(conditionId) {
const positions = getPositions();
if (positions[conditionId]) {
const market = positions[conditionId].market;
delete positions[conditionId];
writeState(POSITIONS_FILE, positions);
logger.info(`Position removed: ${market}`);
}
}
/**
* Get position by conditionId
* @param {string} conditionId
* @returns {Object|null}
*/
export function getPosition(conditionId) {
const positions = getPositions();
return positions[conditionId] || null;
}
/**
* Get all open positions as an array
* @returns {Array}
*/
export function getOpenPositions() {
const positions = getPositions();
return Object.values(positions).filter((p) => p.status === 'open');
}
+148
View File
@@ -0,0 +1,148 @@
import { ethers } from 'ethers';
import config from '../config/index.js';
import { getOpenPositions, updatePosition, removePosition } from './position.js';
import logger from '../utils/logger.js';
// Contract addresses on Polygon
const CTF_ADDRESS = '0x4D97DCd97eC945f40cF65F87097ACe5EA0476045';
const NEG_RISK_CTF_ADDRESS = '0xC5d563A36AE78145C45a50134d48A1215220f80a';
const USDC_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174';
// CTF ABI (minimal for redeemPositions & balanceOf)
const CTF_ABI = [
'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)',
'function balanceOf(address owner, uint256 tokenId) view returns (uint256)',
'function payoutNumerators(bytes32 conditionId, uint256 outcomeIndex) view returns (uint256)',
'function payoutDenominator(bytes32 conditionId) view returns (uint256)',
];
/**
* Check if a market has been resolved and our position is a winner
* @param {string} conditionId
* @returns {Object|null} { resolved, won }
*/
async function checkMarketResolution(conditionId) {
try {
// Check via Gamma API
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
const response = await fetch(url);
if (!response.ok) return null;
const markets = await response.json();
if (!markets || markets.length === 0) return null;
const market = markets[0];
return {
resolved: market.closed || market.resolved || false,
active: market.active,
endDate: market.end_date_iso,
resolutionSource: market.resolution_source,
question: market.question,
};
} catch (err) {
logger.error('Failed to check market resolution:', err.message);
return null;
}
}
/**
* Check on-chain if a position (token) has value (payout available)
*/
async function checkOnChainPayout(conditionId) {
try {
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
const ctf = new ethers.Contract(CTF_ADDRESS, CTF_ABI, provider);
const denominator = await ctf.payoutDenominator(conditionId);
if (denominator.isZero()) return { resolved: false, payouts: [] };
// Check payouts for both outcomes (YES=0, NO=1)
const payouts = [];
for (let i = 0; i < 2; i++) {
const numerator = await ctf.payoutNumerators(conditionId, i);
payouts.push(numerator.toNumber() / denominator.toNumber());
}
return { resolved: true, payouts };
} catch (err) {
// If payoutDenominator is 0 or reverts, market not resolved
return { resolved: false, payouts: [] };
}
}
/**
* Redeem winning position on-chain
*/
async function redeemPosition(conditionId, isNegRisk = false) {
try {
const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com');
const wallet = new ethers.Wallet(config.privateKey, provider);
const ctfAddress = isNegRisk ? NEG_RISK_CTF_ADDRESS : CTF_ADDRESS;
const ctf = new ethers.Contract(ctfAddress, CTF_ABI, wallet);
const parentCollectionId = ethers.constants.HashZero;
const indexSets = [1, 2]; // Both outcomes
logger.info(`Redeeming position for conditionId: ${conditionId}`);
const tx = await ctf.redeemPositions(
USDC_ADDRESS,
parentCollectionId,
conditionId,
indexSets,
{ gasLimit: 300000 },
);
logger.info(`Redeem tx sent: ${tx.hash}`);
const receipt = await tx.wait();
logger.success(`Redeem confirmed in block ${receipt.blockNumber}`);
return true;
} catch (err) {
logger.error('Failed to redeem position:', err.message);
return false;
}
}
/**
* Check all open positions for redeemable (resolved & won) markets
*/
export async function checkAndRedeemPositions() {
const positions = getOpenPositions();
if (positions.length === 0) return;
logger.info(`Checking ${positions.length} position(s) for redemption...`);
for (const position of positions) {
try {
// Check via API first
const resolution = await checkMarketResolution(position.conditionId);
if (!resolution || !resolution.resolved) {
// Try on-chain check as fallback
const onChain = await checkOnChainPayout(position.conditionId);
if (!onChain.resolved) continue;
// Check if our outcome won
// Determine outcome index (0=YES, 1=NO based on token position)
logger.info(`Market resolved on-chain: ${position.market} | Payouts: ${onChain.payouts}`);
} else {
logger.info(`Market resolved: ${position.market}`);
}
if (config.dryRun) {
logger.info(`[DRY RUN] Would redeem position: ${position.market}`);
continue;
}
// Attempt to redeem
const success = await redeemPosition(position.conditionId);
if (success) {
removePosition(position.conditionId);
logger.money(`Redeemed: ${position.market}`);
}
} catch (err) {
logger.error(`Error checking position ${position.market}:`, err.message);
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import config from '../config/index.js';
import logger from '../utils/logger.js';
import { readState, writeState } from '../utils/state.js';
const PROCESSED_FILE = 'processed_trades.json';
/**
* Fetch trader's recent activity from Data API
* @returns {Array} List of trade activities
*/
async function fetchTraderActivity() {
const url = `${config.dataHost}/activity?user=${config.traderAddress}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Data API returned ${response.status}`);
}
return await response.json();
} catch (err) {
logger.error('Failed to fetch trader activity:', err.message);
return [];
}
}
/**
* Get list of already processed trade IDs
*/
function getProcessedTrades() {
return readState(PROCESSED_FILE, { tradeIds: [] });
}
/**
* Mark a trade as processed
*/
function markTradeProcessed(tradeId) {
const data = getProcessedTrades();
data.tradeIds.push(tradeId);
// Keep only last 500 trade IDs to prevent unbounded growth
if (data.tradeIds.length > 500) {
data.tradeIds = data.tradeIds.slice(-500);
}
writeState(PROCESSED_FILE, data);
}
/**
* Check for new trades from the watched trader
* @returns {Array} New trades to process: { id, type, tokenId, conditionId, market, price, size, timestamp, side }
*/
export async function checkNewTrades() {
const activities = await fetchTraderActivity();
const processed = getProcessedTrades();
if (!Array.isArray(activities) || activities.length === 0) {
return [];
}
const newTrades = [];
for (const activity of activities) {
// Skip already processed
const tradeId = activity.id || activity.transaction_hash || `${activity.timestamp}_${activity.asset}`;
if (processed.tradeIds.includes(tradeId)) {
continue;
}
// Only process filled trades (buys and sells)
const type = activity.type?.toUpperCase();
if (!['BUY', 'SELL'].includes(type)) {
// Mark non-buy/sell as processed so we don't re-check
markTradeProcessed(tradeId);
continue;
}
// Extract trade info
const trade = {
id: tradeId,
type, // BUY or SELL
tokenId: activity.asset || activity.token_id || '',
conditionId: activity.condition_id || activity.conditionId || '',
market: activity.title || activity.question || activity.market || '',
price: parseFloat(activity.price || '0'),
size: parseFloat(activity.size || activity.amount || '0'),
side: activity.side || type,
timestamp: activity.timestamp || activity.created_at || new Date().toISOString(),
outcome: activity.outcome || '',
proxyWalletAddress: activity.proxyWalletAddress || '',
};
// Need tokenId to trade
if (!trade.tokenId) {
logger.warn(`Skipping trade without tokenId: ${tradeId}`);
markTradeProcessed(tradeId);
continue;
}
newTrades.push(trade);
}
return newTrades;
}
/**
* Mark trade as processed after handling
*/
export { markTradeProcessed };
/**
* Fetch market info from Gamma API by condition ID
*/
export async function fetchMarketInfo(conditionId) {
try {
const url = `${config.gammaHost}/markets?condition_id=${conditionId}`;
const response = await fetch(url);
if (!response.ok) return null;
const markets = await response.json();
return markets && markets.length > 0 ? markets[0] : null;
} catch (err) {
logger.error('Failed to fetch market info:', err.message);
return null;
}
}
/**
* Fetch market info by token ID (CLOB token)
*/
export async function fetchMarketByTokenId(tokenId) {
try {
const url = `${config.gammaHost}/markets?clob_token_ids=${tokenId}`;
const response = await fetch(url);
if (!response.ok) return null;
const markets = await response.json();
return markets && markets.length > 0 ? markets[0] : null;
} catch (err) {
logger.error('Failed to fetch market by tokenId:', err.message);
return null;
}
}
+34
View File
@@ -0,0 +1,34 @@
const COLORS = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
};
function timestamp() {
return new Date().toISOString().replace('T', ' ').substring(0, 19);
}
function formatMsg(level, color, emoji, ...args) {
const ts = timestamp();
const prefix = `${COLORS.dim}[${ts}]${COLORS.reset} ${color}${emoji} ${level}${COLORS.reset}`;
console.log(prefix, ...args);
}
const logger = {
info: (...args) => formatMsg('INFO', COLORS.blue, '️ ', ...args),
success: (...args) => formatMsg('SUCCESS', COLORS.green, '✅', ...args),
warn: (...args) => formatMsg('WARN', COLORS.yellow, '⚠️ ', ...args),
error: (...args) => formatMsg('ERROR', COLORS.red, '❌', ...args),
trade: (...args) => formatMsg('TRADE', COLORS.magenta, '📊', ...args),
watch: (...args) => formatMsg('WATCH', COLORS.cyan, '👀', ...args),
money: (...args) => formatMsg('MONEY', COLORS.green, '💰', ...args),
};
export default logger;
+48
View File
@@ -0,0 +1,48 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
// Ensure data directory exists
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
/**
* Read JSON state file
* @param {string} filename - File name (e.g., "positions.json")
* @param {*} defaultValue - Default value if file doesn't exist
* @returns {*} Parsed JSON data
*/
export function readState(filename, defaultValue = {}) {
const filePath = path.join(DATA_DIR, filename);
try {
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(data);
}
} catch (err) {
console.error(`Error reading state file ${filename}:`, err.message);
}
return defaultValue;
}
/**
* Write JSON state file (atomic write via temp file)
* @param {string} filename - File name (e.g., "positions.json")
* @param {*} data - Data to write
*/
export function writeState(filename, data) {
const filePath = path.join(DATA_DIR, filename);
const tempPath = filePath + '.tmp';
try {
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8');
fs.renameSync(tempPath, filePath);
} catch (err) {
console.error(`Error writing state file ${filename}:`, err.message);
// Clean up temp file if rename failed
try { fs.unlinkSync(tempPath); } catch (_) { }
}
}