-
-
Save discountry/c2b4c7106430ec06a704939210c4b155 to your computer and use it in GitHub Desktop.
| // scan-polymarket-orderbook.ts | |
| // 运行: npx tsx scan-polymarket-orderbook.ts | |
| // 或: npx ts-node scan-polymarket-orderbook.ts (需安装 ts-node/typescript) | |
| import * as readline from "node:readline"; | |
| type PMEvent = { slug?: string; title?: string }; | |
| type Market = { | |
| id: string; | |
| question: string; | |
| slug: string; | |
| outcomes?: string | string[]; | |
| outcomePrices?: string | string[]; | |
| events?: PMEvent[]; | |
| clobTokenIds?: string | string[]; | |
| }; | |
| type BookQuote = { price: string; size: string }; | |
| type Book = { | |
| market: string; // condition hash | |
| asset_id: string; // token_id | |
| bids: BookQuote[]; | |
| asks: BookQuote[]; | |
| min_order_size?: string; | |
| tick_size?: string; | |
| neg_risk?: boolean; | |
| }; | |
| const MARKETS_BASE = | |
| "https://gamma-api.polymarket.com/markets?&volume_num_min=1000&closed=false&offset="; | |
| const BOOKS_URL = "https://clob.polymarket.com/books"; | |
| const PAGE_SIZE = 20; | |
| // 间隔 100ms:任何请求之间 | |
| const GAP_MS = 100; | |
| // 漂移容差 | |
| const EPS = 1e-6; | |
| const C = { | |
| reset: "\x1b[0m", | |
| dim: "\x1b[2m", | |
| green: "\x1b[32m", | |
| red: "\x1b[31m", | |
| cyan: "\x1b[36m", | |
| gray: "\x1b[90m", | |
| bold: "\x1b[1m", | |
| }; | |
| const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); | |
| const fmtNum = (x: number) => x.toFixed(6); | |
| const fmtPct = (x: number) => (x * 100).toFixed(3) + "%"; | |
| function safeParseArray<T = unknown>(v: unknown): T[] | null { | |
| try { | |
| if (Array.isArray(v)) return v as T[]; | |
| if (typeof v === "string") return JSON.parse(v) as T[]; | |
| } catch {} | |
| return null; | |
| } | |
| // --- 进度单行刷新 --- | |
| function renderProgress(processed: number) { | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| process.stdout.write( | |
| `${C.dim}Scanning markets (orderbooks)… processed: ${C.bold}${processed}${C.reset}` | |
| ); | |
| } | |
| // 打印机会:清进度→输出→重画进度 | |
| function printOpportunity(lines: string[], processed: number) { | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| process.stdout.write("\n"); | |
| for (const line of lines) process.stdout.write(line + "\n"); | |
| renderProgress(processed); | |
| } | |
| async function fetchMarkets(offset: number): Promise<Market[]> { | |
| const url = `${MARKETS_BASE}${offset}`; | |
| const res = await fetch(url, { headers: { accept: "application/json" } }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); | |
| const data = (await res.json()) as unknown; | |
| await sleep(GAP_MS); | |
| return Array.isArray(data) ? (data as Market[]) : []; | |
| } | |
| // 批量请求订单簿 | |
| async function fetchBooks(tokenIds: string[]): Promise<Book[]> { | |
| if (tokenIds.length === 0) return []; | |
| // 去重 | |
| const uniq = [...new Set(tokenIds)]; | |
| const body = JSON.stringify(uniq.map((id) => ({ token_id: id }))); | |
| const res = await fetch(BOOKS_URL, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body, | |
| }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status} fetching books`); | |
| const data = (await res.json()) as Book[]; | |
| await sleep(GAP_MS); | |
| return Array.isArray(data) ? data : []; | |
| } | |
| function bestAsk(asks: BookQuote[] | undefined): number | null { | |
| if (!asks || asks.length === 0) return null; | |
| // asks 已按价从低到高;为了稳,手动选最小 | |
| let best = Infinity; | |
| for (const q of asks) { | |
| const p = parseFloat(q.price); | |
| if (Number.isFinite(p) && p < best) best = p; | |
| } | |
| return best === Infinity ? null : best; | |
| } | |
| function bestBid(bids: BookQuote[] | undefined): number | null { | |
| if (!bids || bids.length === 0) return null; | |
| let best = -Infinity; | |
| for (const q of bids) { | |
| const p = parseFloat(q.price); | |
| if (Number.isFinite(p) && p > best) best = p; | |
| } | |
| return best === -Infinity ? null : best; | |
| } | |
| function analyzeWithBooks(m: Market, booksByToken: Map<string, Book>, processed: number) { | |
| const tokenIds = safeParseArray<string>(m.clobTokenIds); | |
| if (!tokenIds || tokenIds.length === 0) return; | |
| const outcomes = safeParseArray<string>(m.outcomes) || []; | |
| // 逐选项取 best ask/bid | |
| const asks: number[] = []; | |
| const bids: number[] = []; | |
| for (let i = 0; i < tokenIds.length; i++) { | |
| const id = tokenIds[i]; | |
| const book = booksByToken.get(id); | |
| if (!book) { | |
| // 没有订单簿,无法计算;直接返回(也可选择跳过该选项) | |
| return; | |
| } | |
| const a = bestAsk(book.asks); | |
| const b = bestBid(book.bids); | |
| if (a == null || b == null) { | |
| // 任一选项缺 ask 或 bid,无法完整构造两边;直接返回(更保守) | |
| return; | |
| } | |
| asks.push(a); | |
| bids.push(b); | |
| } | |
| const sumAsks = asks.reduce((x, y) => x + y, 0); | |
| const sumBids = bids.reduce((x, y) => x + y, 0); | |
| const under = 1 - sumAsks; // >0 有正空间(买齐) | |
| const over = sumBids - 1; // >0 有正空间(卖齐) | |
| if (under <= EPS && over <= EPS) return; // 无明显空间 | |
| const ev = m.events?.[0]; | |
| const link = ev?.slug | |
| ? `https://polymarket.com/event/${ev.slug}` | |
| : `https://polymarket.com/market/${m.slug}`; | |
| const title = ev?.title || m.question || "(no title)"; | |
| // 输出块 | |
| const out: string[] = []; | |
| if (under > EPS) { | |
| out.push( | |
| `[${C.green}UNDERROUND via Asks${C.reset}] ${C.cyan}${link}${C.reset}`, | |
| ` ${C.bold}Title/Question:${C.reset} ${title}`, | |
| ` ${C.bold}Sum(ask1):${C.reset} ${fmtNum(sumAsks)} | ${C.bold}Edge:${C.reset} ${fmtNum( | |
| under | |
| )} (${fmtPct(under)})` | |
| ); | |
| } | |
| if (over > EPS) { | |
| out.push( | |
| `[${C.red}OVERROUND via Bids${C.reset}] ${C.cyan}${link}${C.reset}`, | |
| ` ${C.bold}Title/Question:${C.reset} ${title}`, | |
| ` ${C.bold}Sum(bid1):${C.reset} ${fmtNum(sumBids)} | ${C.bold}Edge:${C.reset} ${fmtNum( | |
| over | |
| )} (${fmtPct(over)})` | |
| ); | |
| } | |
| // 附上选项视图(若有 outcomes),辅助人工核对 | |
| if (outcomes.length === tokenIds.length && outcomes.length > 0) { | |
| out.push( | |
| ` ${C.bold}Outcomes:${C.reset} ${outcomes.join(" | ")}`, | |
| ` ${C.bold}ask1s:${C.reset} ${asks.map(fmtNum).join(", ")}`, | |
| ` ${C.bold}bid1s:${C.reset} ${bids.map(fmtNum).join(", ")}` | |
| ); | |
| } else { | |
| out.push( | |
| ` ${C.bold}ask1s:${C.reset} ${asks.map(fmtNum).join(", ")}`, | |
| ` ${C.bold}bid1s:${C.reset} ${bids.map(fmtNum).join(", ")}` | |
| ); | |
| } | |
| printOpportunity(out, processed); | |
| } | |
| async function main() { | |
| let offset = 0; | |
| let processed = 0; | |
| renderProgress(processed); | |
| while (true) { | |
| try { | |
| const markets = await fetchMarkets(offset); | |
| if (markets.length === 0) { | |
| renderProgress(processed); | |
| process.stdout.write(`\n${C.gray}No data. Stop.${C.reset}\n`); | |
| break; | |
| } | |
| // 收集该页 token_ids | |
| const allTokenIds: string[] = []; | |
| for (const m of markets) { | |
| const ids = safeParseArray<string>(m.clobTokenIds); | |
| if (ids && ids.length > 0) allTokenIds.push(...ids); | |
| } | |
| // 拉取该页所有订单簿(批量) | |
| const books = await fetchBooks(allTokenIds); | |
| const booksByToken = new Map<string, Book>(); | |
| for (const b of books) booksByToken.set(b.asset_id, b); | |
| // 先更新 processed(用于机会打印后的进度重画) | |
| processed += markets.length; | |
| // 分析该页每个市场 | |
| for (const m of markets) { | |
| analyzeWithBooks(m, booksByToken, processed); | |
| } | |
| renderProgress(processed); | |
| if (markets.length < PAGE_SIZE) { | |
| renderProgress(processed); | |
| process.stdout.write( | |
| `\n${C.gray}Received ${markets.length} (< ${PAGE_SIZE}). Done.${C.reset}\n` | |
| ); | |
| break; | |
| } | |
| offset += PAGE_SIZE; | |
| await sleep(GAP_MS); | |
| } catch (e) { | |
| // 打印错误信息,并保持底部只留一条进度 | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| process.stdout.write(`${C.red}Error:${C.reset} ${(e as Error).message}\n`); | |
| renderProgress(processed); | |
| // 继续下一页,避免阻塞 | |
| offset += PAGE_SIZE; | |
| await sleep(GAP_MS); | |
| } | |
| } | |
| } | |
| main().catch((e) => { | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| process.stdout.write(`${C.red}Fatal:${C.reset} ${e}\n`); | |
| process.exit(1); | |
| }); |
| // scan-polymarket-markets.ts | |
| // Node 18+ (内置 fetch) | |
| // 运行: npx tsx scan-polymarket-markets.ts | |
| import * as readline from "node:readline"; | |
| type PMEvent = { slug?: string; title?: string }; | |
| type Market = { | |
| id: string; | |
| question: string; | |
| slug: string; | |
| outcomes?: string | string[]; | |
| outcomePrices?: string | string[]; | |
| events?: PMEvent[]; | |
| }; | |
| const BASE = "https://gamma-api.polymarket.com/markets?closed=false&offset="; | |
| const PAGE_SIZE = 20; | |
| const INTERVAL_MS = 100; | |
| const EPS = 1e-6; | |
| const C = { | |
| reset: "\x1b[0m", | |
| dim: "\x1b[2m", | |
| green: "\x1b[32m", | |
| red: "\x1b[31m", | |
| cyan: "\x1b[36m", | |
| gray: "\x1b[90m", | |
| bold: "\x1b[1m", | |
| }; | |
| const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); | |
| const fmtNum = (x: number) => x.toFixed(6); | |
| const fmtPct = (x: number) => (x * 100).toFixed(3) + "%"; | |
| function safeParseArray<T = unknown>(v: unknown): T[] | null { | |
| try { | |
| if (Array.isArray(v)) return v as T[]; | |
| if (typeof v === "string") return JSON.parse(v) as T[]; | |
| } catch {} | |
| return null; | |
| } | |
| // 单行刷新进度 | |
| function renderProgress(processed: number) { | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| process.stdout.write( | |
| `${C.dim}Scanning markets... processed: ${C.bold}${processed}${C.reset}` | |
| ); | |
| } | |
| // ✅ 修复:打印机会时,先清掉进度行 -> 打印机会 -> 重画进度 | |
| function printOpportunity(lines: string[], processed: number) { | |
| // 清除当前进度行 | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| // 打印机会块(带一个空行作为分隔) | |
| process.stdout.write("\n"); | |
| for (const line of lines) process.stdout.write(line + "\n"); | |
| // 重新绘制最新进度行,确保底部只有一条 | |
| renderProgress(processed); | |
| } | |
| async function fetchPage(offset: number): Promise<Market[]> { | |
| const url = `${BASE}${offset}`; | |
| const res = await fetch(url, { headers: { accept: "application/json" } }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`); | |
| const data = (await res.json()) as unknown; | |
| return Array.isArray(data) ? (data as Market[]) : []; | |
| } | |
| function analyzeMarket(m: Market, processed: number) { | |
| const pricesRaw = safeParseArray<string | number>(m.outcomePrices); | |
| if (!pricesRaw || pricesRaw.length === 0) return; | |
| const prices = pricesRaw.map(p => (typeof p === "string" ? parseFloat(p) : Number(p))); | |
| if (prices.some(n => !Number.isFinite(n))) return; | |
| const sum = prices.reduce((a, b) => a + b, 0); | |
| if (Math.abs(sum - 1) <= EPS) return; | |
| const edge = 1 - sum; | |
| const ev = m.events?.[0]; | |
| const link = ev?.slug | |
| ? `https://polymarket.com/event/${ev.slug}` | |
| : `https://polymarket.com/market/${m.slug}`; | |
| const title = ev?.title || m.question || "(no title)"; | |
| const tag = edge > 0 ? `${C.green}UNDERROUND(+)$` : `${C.red}OVERROUND(-)`; | |
| const out: string[] = [ | |
| `[${tag}${C.reset}] ${C.cyan}${link}${C.reset}`, | |
| ` ${C.bold}Title/Question:${C.reset} ${title}`, | |
| ` ${C.bold}Prices:${C.reset} ${prices.map(fmtNum).join(", ")}`, | |
| ` ${C.bold}Sum:${C.reset} ${fmtNum(sum)} | ${C.bold}Edge:${C.reset} ${fmtNum(edge)} (${fmtPct(edge)})`, | |
| ]; | |
| printOpportunity(out, processed); | |
| } | |
| async function main() { | |
| let offset = 0; | |
| let processed = 0; | |
| renderProgress(processed); | |
| while (true) { | |
| try { | |
| const markets = await fetchPage(offset); | |
| if (markets.length === 0) { | |
| renderProgress(processed); | |
| process.stdout.write(`\n${C.gray}No data. Stop.${C.reset}\n`); | |
| break; | |
| } | |
| // 先更新 processed,再用于机会打印后的进度重画 | |
| processed += markets.length; | |
| for (const m of markets) { | |
| // 发现机会时会清进度->打印->重画,底部始终仅一条进度行 | |
| analyzeMarket(m, processed); | |
| } | |
| renderProgress(processed); | |
| if (markets.length < PAGE_SIZE) { | |
| renderProgress(processed); | |
| process.stdout.write( | |
| `\n${C.gray}Received ${markets.length} (< ${PAGE_SIZE}). Done.${C.reset}\n` | |
| ); | |
| break; | |
| } | |
| offset += PAGE_SIZE; | |
| await sleep(INTERVAL_MS); | |
| } catch (e) { | |
| // 打印错误信息,并保持底部只留一条进度 | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| process.stdout.write(`${C.red}Error:${C.reset} ${(e as Error).message}\n`); | |
| renderProgress(processed); | |
| offset += PAGE_SIZE; | |
| await sleep(INTERVAL_MS); | |
| } | |
| } | |
| } | |
| main().catch(e => { | |
| readline.clearLine(process.stdout, 0); | |
| readline.cursorTo(process.stdout, 0); | |
| process.stdout.write(`${C.red}Fatal:${C.reset} ${e}\n`); | |
| process.exit(1); | |
| }); |
Debugger listening on ws://127.0.0.1:53462/102bb609-e762-4488-b625-ff2c30ab6875
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.
Error: fetch failed
Error: fetch failed
Error: fetch failed
Error: fetch failed
报错了。
具体报错信息:
TypeError: fetch failed
at node:internal/deps/undici/undici:12637:11
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
at fetchMarkets (/Users/hufang/Desktop/web3 project/polymarket/obtainOrders/scan-polymarket-orderbook.ts:106:15)
at main (/Users/hufang/Desktop/web3 project/polymarket/obtainOrders/scan-polymarket-orderbook.ts:269:23) {
cause: ConnectTimeoutError: Connect Timeout Error
at onConnectTimeout (node:internal/deps/undici/undici:7771:28)
at node:internal/deps/undici/undici:7727:50
at Immediate._onImmediate (node:internal/deps/undici/undici:7759:13)
at process.processImmediate (node:internal/timers:476:21) {
code: 'UND_ERR_CONNECT_TIMEOUT'
感觉像是fetch的问题,我换成axios就好了。
asd