-
-
Save lism/8931fe17fe9ba1294243a0b8dc2203b6 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment