Skip to content

Instantly share code, notes, and snippets.

@discountry
Last active December 3, 2025 06:32
Show Gist options
  • Select an option

  • Save discountry/c2b4c7106430ec06a704939210c4b155 to your computer and use it in GitHub Desktop.

Select an option

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);
});
@1halibote
Copy link

asd

@hufang1
Copy link

hufang1 commented Oct 21, 2025

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
报错了。

@hufang1
Copy link

hufang1 commented Oct 21, 2025

具体报错信息:
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就好了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment