polly-gamba 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # polly-gamba
2
+
3
+ **Signal propagation arbitrage:** Coinbase price move → Polymarket bet, before the humans do.
4
+
5
+ ```
6
+ ┌─────────────────┐ signal ┌───────────────────┐ match ┌──────────────────┐
7
+ │ Coinbase WS │ ──────────→ │ Signal Router │ ─────────→ │ Polymarket CLOB │
8
+ │ BTC/ETH ticker │ │ (Claude brain) │ │ Order execution │
9
+ └─────────────────┘ └───────────────────┘ └──────────────────┘
10
+ ↑ ↑ ↑
11
+ 0.5% move in keyword + heuristic dry-run by default
12
+ 60s window market matching (set API key for live)
13
+ ```
14
+
15
+ ## Why this works
16
+
17
+ Human reaction time from seeing a BTC pump on Coinbase to placing a Polymarket bet: **5–30 minutes**.
18
+
19
+ That's the edge. We don't front-run algos — we front-run *people*. Crypto price signal → find correlated prediction markets → place before the crowd arrives.
20
+
21
+ News doesn't matter. Price signal does.
22
+
23
+ ## Architecture
24
+
25
+ | Component | File | Purpose |
26
+ |---|---|---|
27
+ | Coinbase WS | `src/signals/coinbase-ws.ts` | Real-time BTC/ETH ticker, threshold detection |
28
+ | Gamma API | `src/markets/gamma.ts` | Fetch active Polymarket markets, 10-min cache |
29
+ | CLOB API | `src/markets/clob.ts` | Orderbook reads, price queries, order placement |
30
+ | MCP Server | `src/mcp/server.ts` | Polymarket tools for Claude Code integration |
31
+ | Main loop | `src/index.ts` | Orchestration, latency tracking, dry-run |
32
+
33
+ ## Quick start
34
+
35
+ ```bash
36
+ npm install
37
+
38
+ # Run the signal → match → execute pipeline (dry-run mode)
39
+ npx ts-node src/index.ts
40
+
41
+ # Run as MCP server (for Claude Code)
42
+ npx ts-node src/mcp/server.ts
43
+
44
+ # Run smoke tests
45
+ npm test
46
+ ```
47
+
48
+ ## Signal detection
49
+
50
+ `coinbase-ws.ts` connects to Coinbase Advanced Trade WebSocket (no auth required):
51
+ - Subscribes to `ticker` channel for `BTC-USD` and `ETH-USD`
52
+ - Maintains a 60-second rolling price window
53
+ - Fires a `PriceSignal` when price moves ≥ 0.5% within the window
54
+
55
+ ```typescript
56
+ interface PriceSignal {
57
+ asset: 'BTC' | 'ETH'
58
+ direction: 'up' | 'down'
59
+ pct_change: number // e.g. 0.0213 for +2.13%
60
+ price: number // current USD price
61
+ window_secs: number // actual window duration
62
+ signal_ts: number // Date.now() at detection
63
+ }
64
+ ```
65
+
66
+ ## Market matching
67
+
68
+ On signal, the system:
69
+ 1. Fetches active Polymarket markets (Gamma API, cached 10 min)
70
+ 2. Filters for volume > $10k, liquidity > $1k
71
+ 3. Keyword-matches market questions against the signal asset
72
+ 4. Logs matched markets + latency breakdown
73
+
74
+ ## MCP Tools (Claude integration)
75
+
76
+ Add to `~/.claude/claude_desktop_config.json`:
77
+
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "polymarket": {
82
+ "command": "npx",
83
+ "args": ["ts-node", "/path/to/polly-gamba/src/mcp/server.ts"]
84
+ }
85
+ }
86
+ }
87
+ ```
88
+
89
+ Available tools:
90
+ - **`list_markets`** — fetch active markets with optional keyword filter
91
+ - **`get_orderbook`** — CLOB orderbook for a token
92
+ - **`get_market_price`** — buy/sell price (0–1 = probability)
93
+ - **`assess_signal`** — find markets affected by a price signal
94
+ - **`place_order`** — place order (dry-run default)
95
+
96
+ ## Latency tracking
97
+
98
+ Every signal logs:
99
+ ```
100
+ signal_ts → when the price move was detected
101
+ match_ts → when market matching completed
102
+ execute_ts → when dry-run/order was logged
103
+ ```
104
+
105
+ Target: signal → execute in < 500ms.
106
+
107
+ ## Config
108
+
109
+ | Env var | Required | Description |
110
+ |---|---|---|
111
+ | `POLYMARKET_API_KEY` | No (for now) | CLOB API key for live order placement |
112
+
113
+ ## Dry-run mode
114
+
115
+ All order placement is dry-run by default — logs `WOULD PLACE ORDER: ...` without touching the CLOB. Set `dry_run: false` and `POLYMARKET_API_KEY` for live execution.
116
+
117
+ ## Next steps
118
+
119
+ 1. **CLOB auth** — implement EIP-712 L1 + L2 API key header signing
120
+ 2. **Position sizing** — Kelly criterion on signal strength × market liquidity
121
+ 3. **Redis signal bus** — decouple signal detection from execution
122
+ 4. **Backtesting** — replay historical Coinbase ticks against historical Polymarket prices
123
+ 5. **Claude reasoning** — pipe signals through Claude for semantic market scoring
124
+
125
+ ## Build
126
+
127
+ ```bash
128
+ npm run build # tsc → dist/
129
+ npm test # smoke tests (live API, read-only)
130
+ ```
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "polly-gamba",
3
+ "version": "1.0.1",
4
+ "description": "Coinbase price signal → Claude brain → Polymarket CLOB execution",
5
+ "main": "dist/index.js",
6
+ "scripts": {
7
+ "start": "ts-node src/index.ts",
8
+ "mcp": "ts-node src/mcp/server.ts",
9
+ "mcp-paper": "ts-node src/mcp-server.ts",
10
+ "build": "tsc",
11
+ "test": "ts-node src/test/smoke.ts"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/gonzih/polly-gamba.git"
16
+ },
17
+ "keywords": [],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "bugs": {
21
+ "url": "https://github.com/gonzih/polly-gamba/issues"
22
+ },
23
+ "homepage": "https://github.com/gonzih/polly-gamba#readme",
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.28.0",
26
+ "@types/better-sqlite3": "^7.6.13",
27
+ "@types/node": "^25.5.0",
28
+ "@types/ws": "^8.18.1",
29
+ "better-sqlite3": "^12.8.0",
30
+ "ioredis": "^5.10.1",
31
+ "ts-node": "^10.9.2",
32
+ "typescript": "^6.0.2",
33
+ "ws": "^8.20.0"
34
+ }
35
+ }
@@ -0,0 +1,237 @@
1
+ import { spawn, ChildProcess, execSync } from 'child_process'
2
+ import { existsSync } from 'fs'
3
+ import { createInterface } from 'readline'
4
+ import Redis from 'ioredis'
5
+ import { PriceSignal } from './signals/coinbase-ws'
6
+ import { Market } from './markets/gamma'
7
+
8
+ function findClaudeBin(): string {
9
+ const candidates = [
10
+ process.env.CLAUDE_BIN,
11
+ '/Users/feral/.npm-global/bin/claude',
12
+ '/usr/local/bin/claude',
13
+ '/opt/homebrew/bin/claude',
14
+ ].filter(Boolean) as string[]
15
+
16
+ try {
17
+ const fromWhich = execSync('which claude 2>/dev/null', { encoding: 'utf8' }).trim()
18
+ if (fromWhich) candidates.unshift(fromWhich)
19
+ } catch {}
20
+
21
+ for (const p of candidates) {
22
+ if (existsSync(p)) return p
23
+ }
24
+ throw new Error(`claude binary not found. Tried: ${candidates.join(', ')}. Set CLAUDE_BIN env var.`)
25
+ }
26
+
27
+ const CLAUDE_BIN = findClaudeBin()
28
+ const CLAUDE_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY || ''
29
+
30
+ export interface TraderConfig {
31
+ cwd: string
32
+ redisPrefix: string
33
+ model?: string
34
+ envOverrides?: Record<string, string>
35
+ label: string
36
+ }
37
+
38
+ export class ClaudeTrader {
39
+ private proc: ChildProcess | null = null
40
+ private redis: Redis
41
+ private ready = false
42
+ private queue: string[] = []
43
+ private config: TraderConfig
44
+
45
+ constructor(config: TraderConfig) {
46
+ this.config = config
47
+ this.redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
48
+ retryStrategy: (times) => Math.min(times * 500, 5000),
49
+ maxRetriesPerRequest: null,
50
+ enableReadyCheck: false,
51
+ })
52
+ this.redis.on('error', (e) => console.error(`[redis:${config.label}]`, e.message))
53
+ }
54
+
55
+ start() {
56
+ const env: Record<string, string> = {
57
+ ...(process.env as Record<string, string>),
58
+ }
59
+
60
+ if (CLAUDE_TOKEN) {
61
+ if (CLAUDE_TOKEN.startsWith('sk-ant-oat')) {
62
+ env.CLAUDE_CODE_OAUTH_TOKEN = CLAUDE_TOKEN
63
+ } else {
64
+ env.ANTHROPIC_API_KEY = CLAUDE_TOKEN
65
+ }
66
+ }
67
+
68
+ if (this.config.envOverrides) {
69
+ Object.assign(env, this.config.envOverrides)
70
+ }
71
+
72
+ const args = [
73
+ '--continue',
74
+ '--output-format', 'stream-json',
75
+ '--input-format', 'stream-json',
76
+ '--print',
77
+ '--verbose',
78
+ '--dangerously-skip-permissions',
79
+ ]
80
+
81
+ if (this.config.model) {
82
+ args.push('--model', this.config.model)
83
+ }
84
+
85
+ this.proc = spawn(CLAUDE_BIN, args, { cwd: this.config.cwd, env })
86
+
87
+ const rl = createInterface({ input: this.proc.stdout! })
88
+ rl.on('line', (line) => this.handleOutputLine(line))
89
+
90
+ this.proc.stderr?.on('data', (d: Buffer) => {
91
+ const s = d.toString()
92
+ if (s.trim()) this.log('stderr', { text: s.trim() }).catch(() => {})
93
+ })
94
+
95
+ this.proc.on('close', (code: number | null) => {
96
+ console.error(`[claude-trader:${this.config.label}] process closed (code=${code}) — restarting in 5s`)
97
+ this.log('process', { event: 'closed', code }).catch(() => {})
98
+ this.proc = null
99
+ this.ready = false
100
+ setTimeout(() => this.start(), 5000)
101
+ })
102
+
103
+ this.proc.on('error', (err: Error) => {
104
+ console.error(`[claude-trader:${this.config.label}] spawn error:`, err.message)
105
+ this.log('process', { event: 'error', message: err.message }).catch(() => {})
106
+ })
107
+
108
+ // Send system context after brief startup delay
109
+ setTimeout(() => {
110
+ this.sendRaw(JSON.stringify({
111
+ type: 'user',
112
+ message: {
113
+ role: 'user',
114
+ content: `You are a Polymarket paper trader running a high-volume moneyball strategy. Your job is to place paper trades on EVERY market where you have ANY opinion on fair value — even slight.
115
+
116
+ TOOLS: place_order, skip_all
117
+ RULES:
118
+ - Output ONLY tool calls. Zero prose.
119
+ - For EVERY market in the list: if current price differs from your estimated fair probability by more than 5%, place a trade.
120
+ - YES is underpriced → BUY YES. NO is underpriced (YES overpriced) → BUY NO.
121
+ - $10 USDC per trade (small size, many bets).
122
+ - NO cap on number of trades — bet every market where you see any edge.
123
+ - Only skip_all if you genuinely have zero opinion on any market (rare).
124
+ - Use your world knowledge: sports standings, political context, recent events, base rates.`
125
+ }
126
+ }))
127
+ this.ready = true
128
+ this.flushQueue()
129
+ console.log(`[claude-trader:${this.config.label}] ready`)
130
+ }, 2000)
131
+ }
132
+
133
+ private handleOutputLine(line: string) {
134
+ if (!line.trim()) return
135
+ try {
136
+ const msg = JSON.parse(line)
137
+ this.log('claude_output', msg).catch(() => {})
138
+ } catch {
139
+ this.log('claude_raw', { text: line }).catch(() => {})
140
+ }
141
+ }
142
+
143
+ private sendRaw(json: string) {
144
+ if (this.proc?.stdin?.writable) {
145
+ this.proc.stdin.write(json + '\n')
146
+ }
147
+ }
148
+
149
+ private flushQueue() {
150
+ while (this.queue.length && this.ready) {
151
+ this.sendRaw(this.queue.shift()!)
152
+ }
153
+ }
154
+
155
+ async onSignal(signal: PriceSignal, markets: Market[]) {
156
+ const signalId = (signal as any).id || Math.random().toString(36).slice(2)
157
+ const pct = signal.pct_change
158
+ const prefix = this.config.redisPrefix
159
+
160
+ // Log signal to Redis
161
+ await this.redis.lpush(`${prefix}:signals`, JSON.stringify({ ...signal, id: signalId, markets_count: markets.length }))
162
+ await this.redis.ltrim(`${prefix}:signals`, 0, 9999)
163
+ await this.redis.set(`${prefix}:signal:${signalId}`, JSON.stringify({ signal, markets }), 'EX', 86400)
164
+
165
+ const prompt = `## Signal ${signalId}
166
+
167
+ **${signal.asset} ${signal.direction === 'up' ? '+' : '-'}${(Math.abs(pct) * 100).toFixed(2)}%** @ $${signal.price.toFixed(2)}
168
+ Window: ${signal.window_secs}s | Time: ${new Date(signal.signal_ts).toISOString()}
169
+
170
+ ## Relevant Polymarket Markets (${markets.length} found)
171
+
172
+ ${markets.map((m, i) => `### ${i + 1}. ${m.question}
173
+ - Volume: $${(m.volume || 0).toLocaleString()}
174
+ - Liquidity: $${(m.liquidity || 0).toLocaleString()}
175
+ - Market ID: ${m.id}
176
+ - Tokens: ${m.tokens?.map(t => `${t.outcome}@${t.price}`).join(', ') || 'none'}
177
+ ${m.description ? `- Description: ${m.description.slice(0, 200)}` : ''}`).join('\n\n')}
178
+
179
+ Call place_order on any market you have edge on. $10 per trade. No cap.`
180
+
181
+ const msg = JSON.stringify({
182
+ type: 'user',
183
+ message: { role: 'user', content: prompt }
184
+ })
185
+
186
+ if (this.ready) {
187
+ this.sendRaw(msg)
188
+ } else {
189
+ this.queue.push(msg)
190
+ }
191
+ }
192
+
193
+ async onAutonomousScan(markets: Market[]) {
194
+ const scanId = Math.random().toString(36).slice(2)
195
+ const prefix = this.config.redisPrefix
196
+
197
+ await this.redis.lpush(`${prefix}:scans`, JSON.stringify({ scanId, markets_count: markets.length, ts: Date.now() }))
198
+ await this.redis.ltrim(`${prefix}:scans`, 0, 9999)
199
+
200
+ const tradeable = markets.filter(m =>
201
+ m.tokens?.some(t => t.price > 0.02 && t.price < 0.98)
202
+ )
203
+
204
+ const prompt = `## Autonomous Scan ${scanId}
205
+
206
+ Time: ${new Date().toISOString()}
207
+
208
+ ## High-Quality Markets (${tradeable.length} found — vol24h>$50k, liq>$50k, price 0.10-0.90)
209
+
210
+ ${tradeable.map((m, i) => `### ${i + 1}. ${m.question}
211
+ - Volume 24h: $${(m.volume24hr || 0).toLocaleString()}
212
+ - Liquidity: $${(m.liquidity || 0).toLocaleString()}
213
+ - Market ID: ${m.id}
214
+ - Tokens: ${m.tokens?.map(t => `${t.outcome}@${t.price}`).join(', ') || 'none'}
215
+ ${m.description ? `- Description: ${m.description.slice(0, 200)}` : ''}`).join('\n\n')}
216
+
217
+ For EVERY market above: if price differs from your fair probability by >5%, place a trade ($10 USDC). Call skip_all only if you have zero opinion on all markets.`
218
+
219
+ const msg = JSON.stringify({
220
+ type: 'user',
221
+ message: { role: 'user', content: prompt }
222
+ })
223
+
224
+ if (this.ready) {
225
+ this.sendRaw(msg)
226
+ } else {
227
+ this.queue.push(msg)
228
+ }
229
+ }
230
+
231
+ private async log(type: string, data: any) {
232
+ const prefix = this.config.redisPrefix
233
+ const entry = JSON.stringify({ type, data, ts: Date.now(), model: this.config.label })
234
+ await this.redis.lpush(`${prefix}:log`, entry)
235
+ await this.redis.ltrim(`${prefix}:log`, 0, 9999)
236
+ }
237
+ }
package/src/index.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { CoinbaseSignalDetector, PriceSignal } from './signals/coinbase-ws'
2
+ import { fetchActiveMarkets, fetchHighQualityMarkets, filterBySignal } from './markets/gamma'
3
+ import { ClaudeTrader } from './claude-trader'
4
+ import { PositionMonitor } from './position-monitor'
5
+ import * as path from 'path'
6
+ import * as os from 'os'
7
+
8
+ const CWD = process.env.POLLY_CWD || path.join(os.homedir(), 'polly-gamba')
9
+
10
+ async function main(): Promise<void> {
11
+ console.log('[polly-gamba] Starting paper trading service')
12
+ console.log(`[polly-gamba] Claude cwd: ${CWD}`)
13
+
14
+ const anthropicTrader = new ClaudeTrader({ cwd: CWD, redisPrefix: 'polly', label: 'anthropic' })
15
+
16
+ const ollamaTrader = new ClaudeTrader({
17
+ cwd: CWD,
18
+ redisPrefix: 'polly:ollama',
19
+ label: 'ollama',
20
+ model: 'glm-4.7-flash',
21
+ envOverrides: {
22
+ ANTHROPIC_BASE_URL: 'http://localhost:11434',
23
+ ANTHROPIC_AUTH_TOKEN: 'ollama',
24
+ ANTHROPIC_API_KEY: 'ollama',
25
+ USER_TYPE: 'ant',
26
+ },
27
+ })
28
+
29
+ const signals = new CoinbaseSignalDetector()
30
+
31
+ const monitor = new PositionMonitor(
32
+ process.env.REDIS_URL || 'redis://localhost:6379',
33
+ 'polly'
34
+ )
35
+
36
+ anthropicTrader.start()
37
+ ollamaTrader.start()
38
+
39
+ signals.on('signal', (signal: PriceSignal) => {
40
+ console.log(`[signal] ${signal.asset} ${signal.direction} ${(Math.abs(signal.pct_change) * 100).toFixed(2)}% @ $${signal.price.toLocaleString()}`)
41
+ handleSignal(signal, anthropicTrader, ollamaTrader).catch((e: Error) => {
42
+ console.error('[error] signal handling failed:', e.message)
43
+ })
44
+ })
45
+
46
+ signals.start()
47
+ console.log('[polly-gamba] Listening for BTC/ETH price signals (threshold: 0.5% in 60s)...')
48
+
49
+ // Position monitor: check every 15 minutes for exits
50
+ const SCAN_INTERVAL_MS = 15 * 60 * 1000 // 15 minutes
51
+ monitor.checkPositions().catch((e: Error) => console.error('[monitor]', e.message))
52
+ setInterval(() => {
53
+ monitor.checkPositions().catch((e: Error) => console.error('[monitor]', e.message))
54
+ }, SCAN_INTERVAL_MS)
55
+
56
+ // Autonomous scan on startup and every 15 minutes
57
+ autonomousScan(anthropicTrader, ollamaTrader).catch((e: Error) => {
58
+ console.error('[error] autonomous scan failed:', e.message)
59
+ })
60
+ setInterval(() => {
61
+ autonomousScan(anthropicTrader, ollamaTrader).catch((e: Error) => {
62
+ console.error('[error] autonomous scan failed:', e.message)
63
+ })
64
+ }, SCAN_INTERVAL_MS)
65
+
66
+ process.on('SIGINT', () => {
67
+ console.log('\n[polly-gamba] Shutting down...')
68
+ signals.stop()
69
+ monitor.stop()
70
+ process.exit(0)
71
+ })
72
+
73
+ process.on('SIGTERM', () => {
74
+ signals.stop()
75
+ monitor.stop()
76
+ process.exit(0)
77
+ })
78
+ }
79
+
80
+ async function autonomousScan(...traders: ClaudeTrader[]): Promise<void> {
81
+ console.log('[scan] fetching high-quality markets (vol24h>$50k, liq>$50k, price 0.10-0.90)')
82
+ let markets
83
+ try {
84
+ markets = await fetchHighQualityMarkets()
85
+ } catch (e: any) {
86
+ console.error('[scan] failed to fetch markets:', e.message)
87
+ return
88
+ }
89
+ console.log(`[scan] ${markets.length} high-quality markets for autonomous review`)
90
+ await Promise.all(traders.map(t => t.onAutonomousScan(markets)))
91
+ }
92
+
93
+ async function handleSignal(signal: PriceSignal, ...traders: ClaudeTrader[]): Promise<void> {
94
+ let markets
95
+ try {
96
+ markets = await fetchActiveMarkets(10_000, 1_000)
97
+ } catch (e: any) {
98
+ console.error('[error] failed to fetch markets:', e.message)
99
+ return
100
+ }
101
+
102
+ const relevant = filterBySignal(signal.asset, markets)
103
+ console.log(`[match] ${relevant.length} markets for ${signal.asset} signal`)
104
+
105
+ if (relevant.length === 0) {
106
+ console.log('[match] no relevant markets found')
107
+ return
108
+ }
109
+
110
+ await Promise.all(traders.map(t => t.onSignal(signal, relevant)))
111
+ }
112
+
113
+ main().catch((err) => {
114
+ console.error('[polly-gamba] Fatal:', err)
115
+ process.exit(1)
116
+ })
@@ -0,0 +1,127 @@
1
+ const CLOB_BASE = 'https://clob.polymarket.com';
2
+
3
+ export interface OrderbookLevel {
4
+ price: string;
5
+ size: string;
6
+ }
7
+
8
+ export interface Orderbook {
9
+ market: string;
10
+ asset_id: string;
11
+ bids: OrderbookLevel[];
12
+ asks: OrderbookLevel[];
13
+ spread?: number;
14
+ }
15
+
16
+ export interface PlaceOrderParams {
17
+ token_id: string;
18
+ side: 'BUY' | 'SELL';
19
+ size_usdc: number;
20
+ price: number; // 0-1 range
21
+ dry_run?: boolean;
22
+ }
23
+
24
+ export interface OrderResult {
25
+ dry_run: boolean;
26
+ token_id: string;
27
+ side: 'BUY' | 'SELL';
28
+ size_usdc: number;
29
+ price: number;
30
+ estimated_shares: number;
31
+ order_id?: string;
32
+ logged_at: number;
33
+ }
34
+
35
+ export async function getOrderbook(tokenId: string): Promise<Orderbook> {
36
+ const url = `${CLOB_BASE}/book?token_id=${encodeURIComponent(tokenId)}`;
37
+ const res = await fetch(url);
38
+ if (!res.ok) {
39
+ throw new Error(`CLOB getOrderbook error: ${res.status} ${res.statusText}`);
40
+ }
41
+ const data: any = await res.json();
42
+
43
+ const bids: OrderbookLevel[] = (data.bids ?? []).slice(0, 10);
44
+ const asks: OrderbookLevel[] = (data.asks ?? []).slice(0, 10);
45
+
46
+ let spread: number | undefined;
47
+ if (bids.length > 0 && asks.length > 0) {
48
+ spread = parseFloat(asks[0].price) - parseFloat(bids[0].price);
49
+ }
50
+
51
+ return {
52
+ market: data.market ?? '',
53
+ asset_id: data.asset_id ?? tokenId,
54
+ bids,
55
+ asks,
56
+ spread,
57
+ };
58
+ }
59
+
60
+ export async function getPrice(tokenId: string, side: 'buy' | 'sell'): Promise<number> {
61
+ const url = `${CLOB_BASE}/price?token_id=${encodeURIComponent(tokenId)}&side=${side}`;
62
+ const res = await fetch(url);
63
+ if (!res.ok) {
64
+ throw new Error(`CLOB getPrice error: ${res.status} ${res.statusText}`);
65
+ }
66
+ const data: any = await res.json();
67
+ return parseFloat(data.price ?? data) ?? 0;
68
+ }
69
+
70
+ export async function getPrices(tokenIds: string[]): Promise<Map<string, number>> {
71
+ const prices = new Map<string, number>();
72
+
73
+ // Batch with concurrency limit of 5
74
+ const chunks: string[][] = [];
75
+ for (let i = 0; i < tokenIds.length; i += 5) {
76
+ chunks.push(tokenIds.slice(i, i + 5));
77
+ }
78
+
79
+ for (const chunk of chunks) {
80
+ await Promise.all(
81
+ chunk.map(async (id) => {
82
+ try {
83
+ const price = await getPrice(id, 'buy');
84
+ prices.set(id, price);
85
+ } catch (e) {
86
+ // skip failed lookups
87
+ }
88
+ })
89
+ );
90
+ }
91
+
92
+ return prices;
93
+ }
94
+
95
+ export async function placeOrder(params: PlaceOrderParams): Promise<OrderResult> {
96
+ const { token_id, side, size_usdc, price, dry_run = true } = params;
97
+ const estimated_shares = price > 0 ? size_usdc / price : 0;
98
+
99
+ const result: OrderResult = {
100
+ dry_run,
101
+ token_id,
102
+ side,
103
+ size_usdc,
104
+ price,
105
+ estimated_shares,
106
+ logged_at: Date.now(),
107
+ };
108
+
109
+ if (dry_run) {
110
+ console.log(
111
+ `[clob/dry-run] WOULD PLACE ORDER: ${side} ${size_usdc} USDC @ ${price} ` +
112
+ `(~${estimated_shares.toFixed(2)} shares) token=${token_id}`
113
+ );
114
+ return result;
115
+ }
116
+
117
+ // Real order placement — requires CLOB auth (L1/L2 headers)
118
+ // Auth implementation deferred — set POLYMARKET_KEY env var when ready
119
+ const apiKey = process.env.POLYMARKET_API_KEY;
120
+ if (!apiKey) {
121
+ throw new Error('POLYMARKET_API_KEY not set — cannot place real orders');
122
+ }
123
+
124
+ // TODO: implement full CLOB auth (EIP-712 L1 + L2 API key headers)
125
+ // See: https://docs.polymarket.com/#authentication
126
+ throw new Error('Real order placement not yet implemented — use dry_run=true');
127
+ }