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