commit de1810a01f53249cfb737c9e8d001758210cd6ae Author: Alex|PolyEcho Date: Sat Jul 18 09:04:18 2026 +0800 Initial commit for Polymarket Quick Trade OSS diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..cb0fc05 --- /dev/null +++ b/.env.template @@ -0,0 +1,17 @@ +# 配置你的 API Key (必须,否则无法读取链上数据) +ALCHEMY_KEY=你的Alchemy_RPC_KEY + +# 账号 1 (主要交易账号) +QUICK_PRIVATE_KEY=你的私钥(不需要0x) +QUICK_FUNDER=你的公共地址(0x开头) + +# 如果你设置了 Polymarket 的 Session Key 或 Relayer 交易 (可选) +# QUICK_RELAYER_API_KEY= +# QUICK_RELAYER_API_KEY_ADDRESS= + +# 账号 2 (如果需要多账号切换) +# QUICK_PRIVATE_KEY_2= +# QUICK_FUNDER_2= + +# 你的 Builder Code (可选,交易产生手续费返佣) +# POLY_BUILDER_CODE=0x0000000000000000000000000000000000000000000000000000000000000000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..67188a9 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Polymarket Quick Trade & Onchain Leaderboard + +一个支持 Polymarket 多市场的快捷交易面板,并集成实时的链上盈亏与大户交易排名榜单。 + +## 特性 +- ⚡️ **单键快速下单**: 支持通过按键快速进行买入/卖出。 +- 📊 **多时间维度市场**: 支持快速切换 5分钟、15分钟、1小时 市场。 +- 🏆 **链上大户排行榜**: 实时抓取链上订单数据,统计交易排名。 + +## 安装步骤 + +1. 克隆本仓库到本地。 +2. 安装依赖包: +```bash +pip install -r requirements.txt +``` +3. 复制配置模板并填写配置: +```bash +cp .env.template .env +``` + - 申请并在 `.env` 里填入你的 `ALCHEMY_KEY` + - 填入你的 Polymarket 交易钱包私钥 `QUICK_PRIVATE_KEY` 与对应的地址 `QUICK_FUNDER` + +## 运行方法 + +在项目目录下执行以下命令启动服务: + +```bash +python onchain_leaderboard.py +``` +终端会出现如下提示: +```text + ⚡ Quick Trade: http://localhost:8890 +``` +使用浏览器打开 `http://localhost:8890` 即可使用快速交易面板和查看实时链上榜单。 diff --git a/onchain_leaderboard.py b/onchain_leaderboard.py new file mode 100644 index 0000000..7f9216f --- /dev/null +++ b/onchain_leaderboard.py @@ -0,0 +1,1700 @@ +#!/usr/bin/env python3 +""" +onchain_leaderboard.py — 链上持仓排行榜 (实时 Top 20) + +通过扫描 CTF 合约的 TransferSingle 事件重建每个地址的净持仓, +包括 maker + taker + 直接转账,解决 DATA-API 只显示 taker 的问题。 + +用法: + python3 onchain_leaderboard.py # 实时监控当前回合 + python3 onchain_leaderboard.py --once # 只查一次 + python3 onchain_leaderboard.py --ts 1775757000 # 指定回合时间戳 +""" + +import json +import os +import sys +import time +import threading +from collections import defaultdict +from datetime import datetime, timezone +from typing import Optional + +import requests +from dotenv import load_dotenv +from web3 import Web3 + +load_dotenv() + +# ── 配置 ────────────────────────────────────────────────────────────────────── +CTF_ADDR = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" +TRANSFER_SINGLE_TOPIC = Web3.keccak( + text="TransferSingle(address,address,address,uint256,uint256)" +) +ZERO_ADDR = "0x" + "0" * 40 +ROUND_DURATION = 300 # 5 分钟 + +ALCHEMY_KEY = os.getenv("ALCHEMY_KEY", "") +RPC_URL = f"https://polygon-mainnet.g.alchemy.com/v2/{ALCHEMY_KEY}" + +# 已跟踪的鲸鱼 (从外部文件加载, 修改文件后下个回合自动生效) +WHALES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tracked_whales_list.txt") +TRACKED_WHALES = set() +# Checksum 处理 +TRACKED_WHALES_CS = set() + + +def reload_tracked_whales(): + """从 tracked_whales_list.txt 重载鲸鱼地址列表 (无需重启)""" + global TRACKED_WHALES, TRACKED_WHALES_CS + addrs = set() + try: + with open(WHALES_FILE, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("0x") and len(line) >= 42: + addrs.add(line[:42]) + except FileNotFoundError: + print(f"⚠️ 鲸鱼列表文件不存在: {WHALES_FILE}") + return + old_count = len(TRACKED_WHALES) + TRACKED_WHALES = addrs + try: + from web3 import Web3 + TRACKED_WHALES_CS = {Web3.to_checksum_address(a) for a in TRACKED_WHALES} + except Exception: + TRACKED_WHALES_CS = TRACKED_WHALES.copy() + new_count = len(TRACKED_WHALES) + if new_count != old_count: + print(f"🐋 鲸鱼列表已重载: {old_count} → {new_count} 个地址") + +# 用户数据缓存: {addr_lower: {"name": str, "pnl": float|None, "ts": float}} +_user_cache = {} +_PNL_REFRESH_INTERVAL = 86400 # 鲸鱼 PnL 每天刷新一次 +CACHE_FILE = os.path.join(os.path.dirname(__file__), "username_cache.json") + + +def load_username_cache(): + global _user_cache + try: + if os.path.exists(CACHE_FILE): + with open(CACHE_FILE, "r") as f: + raw = json.load(f) + for k, v in raw.items(): + if isinstance(v, dict): + # 只保留有实际数据的条目 + if v.get("name") or v.get("pnl") is not None: + _user_cache[k] = v + elif isinstance(v, str) and v: + _user_cache[k] = {"name": v, "pnl": None} + except Exception: + pass + + +def save_username_cache(): + try: + with open(CACHE_FILE, "w") as f: + json.dump(_user_cache, f, ensure_ascii=False) + except Exception: + pass + + +def _fetch_user_info(addr_lower: str) -> dict: + """从 Polymarket 获取用户名 + PnL""" + import re + info = {"name": "", "pnl": None} + + # 1. userData API → 用户名 + try: + r = requests.get( + f"https://polymarket.com/api/profile/userData?address={addr_lower}", + timeout=5, verify=False, + headers={"User-Agent": "Mozilla/5.0"}, + ) + if r.status_code == 200: + data = r.json() + info["name"] = data.get("name") or data.get("pseudonym") or "" + except Exception: + pass + + # 2. profile HTML → PnL + try: + r = requests.get( + f"https://polymarket.com/profile/{addr_lower}", + timeout=10, verify=False, + headers={"User-Agent": "Mozilla/5.0"}, + ) + m = re.search(r'"pnl"\s*:\s*(\-?\d+(?:\.\d+)?)', r.text) + if m: + info["pnl"] = float(m.group(1)) + except Exception: + pass + + return info + + +def get_user_info(addr: str) -> dict: + """非阻塞: 从缓存读取用户数据, 无缓存则提交队列""" + addr_lower = addr.lower() + if addr_lower in _user_cache: + return _user_cache[addr_lower] + _user_resolve_queue.add(addr_lower) + return {"name": "", "pnl": None} + + +_user_resolve_queue = set() + +def _user_resolver_worker(): + """后台线程: 批量解析用户信息 (name + pnl)""" + while True: + # 处理新地址 + if _user_resolve_queue: + addr = _user_resolve_queue.pop() + if addr not in _user_cache: + info = _fetch_user_info(addr) + info["ts"] = time.time() + _user_cache[addr] = info + time.sleep(0.3) + continue + + # 空闲时刷新过期的鲸鱼 PnL + now = time.time() + stale = None + for addr in TRACKED_WHALES_CS: + a = addr.lower() + entry = _user_cache.get(a) + if entry and now - entry.get("ts", 0) > _PNL_REFRESH_INTERVAL: + stale = a + break + if stale: + info = _fetch_user_info(stale) + info["ts"] = time.time() + _user_cache[stale] = info + time.sleep(0.3) + else: + time.sleep(2) + + +# ── Web3 连接 ────────────────────────────────────────────────────────────────── +def get_w3(): + try: + from web3.middleware import ExtraDataToPOAMiddleware as POA + except ImportError: + try: + from web3.middleware import geth_poa_middleware as POA + except ImportError: + POA = None + + w3 = Web3(Web3.HTTPProvider(RPC_URL, request_kwargs={"timeout": 30})) + if POA: + try: + w3.middleware_onion.inject(POA, layer=0) + except Exception: + pass + + # web3.py v7: is_connected() 调用 web3_clientVersion, Alchemy 不支持 + # 改用 eth_blockNumber 实际测试 + try: + blk = w3.eth.block_number + print(f" ✅ RPC 连接正常, 当前区块 {blk}") + except Exception as e: + print(f"❌ 无法连接 RPC: {e}") + sys.exit(1) + return w3 + + +# ── 获取回合信息 ────────────────────────────────────────────────────────────── +def get_current_round_ts() -> int: + now = int(time.time()) + return now - (now % ROUND_DURATION) + + + + +def _make_hourly_slug(round_ts: int) -> str: + """生成 1h 市场的 slug, 如 bitcoin-up-or-down-may-16-2026-5am-et""" + from datetime import timedelta + et_dt = datetime.fromtimestamp(round_ts, tz=timezone.utc) - timedelta(hours=4) + month = et_dt.strftime("%B").lower() + day = et_dt.day + year = et_dt.year + hour = et_dt.hour + ampm = "am" if hour < 12 else "pm" + h12 = hour % 12 + if h12 == 0: + h12 = 12 + return f"bitcoin-up-or-down-{month}-{day}-{year}-{h12}{ampm}-et" + + +def get_round_token_ids(round_ts: int, prefix: str = "5m") -> Optional[dict]: + """获取回合的 UP/DOWN token_id (含重试) + prefix: "5m", "15m", "1h" + """ + if prefix == "1h": + slug = _make_hourly_slug(round_ts) + else: + slug = f"btc-updown-{prefix}-{round_ts}" + for attempt in range(3): + try: + r = requests.get( + "https://gamma-api.polymarket.com/events", + params={"slug": slug}, + timeout=20, + ) + events = r.json() + if not events or not isinstance(events, list): + return None + + markets = events[0].get("markets", []) + if not markets: + return None + + mkt = markets[0] + tokens = mkt.get("clobTokenIds", "[]") + if isinstance(tokens, str): + tokens = json.loads(tokens) + + outcomes = mkt.get("outcomes", "[]") + if isinstance(outcomes, str): + outcomes = json.loads(outcomes) + + result = {} + for i, outcome in enumerate(outcomes): + if i < len(tokens): + key = outcome.lower() + result[key] = tokens[i] + + return result if result else None + except Exception as e: + if attempt < 2: + print(f" ⚠️ Gamma API 第{attempt+1}次失败: {e}, 重试...") + time.sleep(3) + else: + print(f"❌ 获取回合信息失败 (3次重试): {e}") + return None + + +def ts_to_block(w3, ts: int) -> int: + """粗略估算时间戳对应的区块号 (Polygon ~2s/块)""" + current_block = w3.eth.block_number + current_ts = int(time.time()) + blocks_ago = (current_ts - ts) // 2 + estimated = current_block - blocks_ago + return max(estimated - 10, 0) # 多往前 10 块确保不漏 + + +# ── 链上事件扫描 ────────────────────────────────────────────────────────────── +def scan_transfers(w3, from_block: int, to_block: int, quiet: bool = False) -> list: + """扫描 CTF 合约的 TransferSingle 事件 (并发 RPC)""" + from concurrent.futures import ThreadPoolExecutor, as_completed + total_blocks = to_block - from_block + 1 + if total_blocks <= 0: + return [] + + BATCH = 5 + WORKERS = 3 + ctf_cs = w3.to_checksum_address(CTF_ADDR) + + # 构建批次列表 + batches = [] + for start in range(from_block, to_block + 1, BATCH): + end = min(start + BATCH - 1, to_block) + batches.append((start, end)) + + all_logs = [None] * len(batches) # 保持顺序 + done_count = [0] + + def _fetch_batch(idx, start, end): + try: + logs = w3.eth.get_logs({ + "address": ctf_cs, + "topics": [TRANSFER_SINGLE_TOPIC], + "fromBlock": start, + "toBlock": end, + }) + return idx, logs + except Exception as e: + # 逐块重试 + result = [] + err_count = 0 + for b in range(start, end + 1): + try: + logs = w3.eth.get_logs({ + "address": ctf_cs, + "topics": [TRANSFER_SINGLE_TOPIC], + "fromBlock": b, + "toBlock": b, + }) + result.extend(logs) + except Exception: + err_count += 1 + if err_count > 0 and not quiet: + print(f"\n ⚠️ 批次 {start}-{end} 重试, {err_count}/{end-start+1} 块失败", flush=True) + return idx, result + + with ThreadPoolExecutor(max_workers=WORKERS) as pool: + futures = {pool.submit(_fetch_batch, i, s, e): i for i, (s, e) in enumerate(batches)} + for future in as_completed(futures): + idx, logs = future.result() + all_logs[idx] = logs + done_count[0] += 1 + if not quiet and total_blocks > 30: + done_blocks = min(done_count[0] * BATCH, total_blocks) + total_logs = sum(len(l) for l in all_logs if l) + print(f"\r 扫描进度: {done_blocks}/{total_blocks} 块, {total_logs} 条事件", end="", flush=True) + + if not quiet and total_blocks > 30: + print() # 换行 + + # 展平 (保持区块顺序) + result = [] + for batch_logs in all_logs: + if batch_logs: + result.extend(batch_logs) + return result + + +def build_balances(logs: list, target_token_ids: set, w3): + """ + 从 TransferSingle 事件构建余额表 + 返回: (balances, block_map) + balances: {token_id: {address: shares}} + block_map: {token_id: {address: [block_numbers...]}} + """ + balances = defaultdict(lambda: defaultdict(float)) + block_map = defaultdict(lambda: defaultdict(list)) + _cs_cache = {} # checksum 缓存 + + def _checksum(hex40: str) -> str: + if hex40 not in _cs_cache: + _cs_cache[hex40] = w3.to_checksum_address("0x" + hex40) + return _cs_cache[hex40] + + for log_entry in logs: + data = log_entry["data"] + if isinstance(data, str): + data = bytes.fromhex(data[2:]) + elif hasattr(data, "hex"): + data = bytes(data) + + token_id = str(int.from_bytes(data[:32], "big")) + if token_id not in target_token_ids: + continue + + shares = int.from_bytes(data[32:64], "big") / 1e6 + block_num = log_entry["blockNumber"] + + topics = log_entry["topics"] + from_hex = topics[2].hex()[-40:] if hasattr(topics[2], "hex") else topics[2][-40:] + to_hex = topics[3].hex()[-40:] if hasattr(topics[3], "hex") else topics[3][-40:] + from_addr = _checksum(from_hex) + to_addr = _checksum(to_hex) + + if from_addr != ZERO_ADDR: + balances[token_id][from_addr] -= shares + + if to_addr != ZERO_ADDR: + balances[token_id][to_addr] += shares + block_map[token_id][to_addr].append(block_num) + + return balances, block_map + + +# ── 显示排行 ────────────────────────────────────────────────────────────────── +import unicodedata + +# 4连胜名单 +_STREAK_LIST_FILE = os.path.join(os.path.dirname(__file__), "持仓", "4轮连胜down.txt") +_streak_list = set() +if os.path.exists(_STREAK_LIST_FILE): + with open(_STREAK_LIST_FILE) as _f: + for _line in _f: + _line = _line.strip() + if _line.startswith("0x") and len(_line) == 42: + _streak_list.add(Web3.to_checksum_address(_line)) + +def _display_width(s: str) -> int: + """计算字符串在终端中的实际显示宽度 (emoji/中文占2格)""" + w = 0 + for ch in s: + if unicodedata.east_asian_width(ch) in ('W', 'F'): + w += 2 + elif ord(ch) >= 0x1F000: + w += 2 + else: + w += 1 + return w + + +def _pad(s: str, width: int) -> str: + """按终端显示宽度填充空格对齐""" + return s + " " * max(0, width - _display_width(s)) + + +# ANSI 颜色 +_GREEN = "\033[32m" +_RED = "\033[31m" +_RESET = "\033[0m" + +def format_entry(rank: int, addr: str, shares: float, pct: float, hedged: bool = False, hot_streak: bool = False) -> str: + """格式化单条排名 (含用户名 + PnL 颜色, 仅鲸鱼)""" + is_whale = addr in TRACKED_WHALES_CS + whale_tag = "🐋" if is_whale else " " + hedge_tag = "⚖️" if hedged else " " + streak_tag = "🔥" if hot_streak else "" + + extra = "" + if is_whale: + info = get_user_info(addr) + name = info.get("name", "") + pnl = info.get("pnl") + + # 用户名 + name_str = f"({name})" if name else "" + if len(name_str) > 28: + name_str = f"({name[:25]}..)" + + # PnL 带颜色 + pnl_str = "" + if pnl is not None: + pnl_val = f"${pnl:+,.0f}" + if pnl > 1000: + pnl_str = f"{_GREEN}{pnl_val}{_RESET}" + elif pnl < 0: + pnl_str = f"{_RED}{pnl_val}{_RESET}" + else: + pnl_str = pnl_val + + extra_parts = " ".join(filter(None, [name_str, pnl_str])) + if extra_parts: + extra = " " + extra_parts + + tag = f"{whale_tag}{hedge_tag}" + line = f"{rank:>2}. {addr} {shares:>8,.0f}股 ({pct:>5.1f}%) {tag}{extra}{streak_tag}" + if hot_streak: + line = f"{_RED}{line}{_RESET}" + return line + + +def net_positions(balances: dict, token_ids: dict): + """ + 对两边都持仓的地址进行净额计算: + - 8000 UP + 6000 DOWN → 显示 2000 UP, 标记为 ⚖️ + 返回: (up_list, down_list, hedged_addrs) + up_list/down_list: [(addr, net_shares), ...] + hedged_addrs: set of addresses that had positions on both sides + """ + up_tid = token_ids.get("up") + dn_tid = token_ids.get("down") + + up_raw = {a: s for a, s in balances.get(up_tid, {}).items() if s > 0.5} if up_tid else {} + dn_raw = {a: s for a, s in balances.get(dn_tid, {}).items() if s > 0.5} if dn_tid else {} + + # 找出两边都有仓位的地址 + both = set(up_raw.keys()) & set(dn_raw.keys()) + hedged_addrs = set() + + up_net = {} + dn_net = {} + + # 处理只有一边的 + for addr, shares in up_raw.items(): + if addr not in both: + up_net[addr] = shares + for addr, shares in dn_raw.items(): + if addr not in both: + dn_net[addr] = shares + + # 处理两边都有的 → 净额 + for addr in both: + u = up_raw[addr] + d = dn_raw[addr] + net = u - d + hedged_addrs.add(addr) + if net > 0.5: + up_net[addr] = net + elif net < -0.5: + dn_net[addr] = abs(net) + # net ≈ 0 → 完全对冲, 两边都不显示 + + up_list = sorted(up_net.items(), key=lambda x: -x[1]) + dn_list = sorted(dn_net.items(), key=lambda x: -x[1]) + + return up_list, dn_list, hedged_addrs + + +# ── BTC 价格获取 ───────────────────────────────────────────────────────────── +_price_session = None +_cached_strike = {"round_ts": 0, "price": 0.0} + +def _get_price_session(): + global _price_session + if _price_session is None: + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + _price_session = requests.Session() + adapter = requests.adapters.HTTPAdapter(max_retries=2) + _price_session.mount("https://", adapter) + return _price_session + +def _robust_get_json(session, url, timeout=5, headers=None) -> dict: + """ + 发送 GET 请求并解析为 JSON。 + 首先尝试使用 session.get,若失败或遭遇非 200 状态码, + 则自动 fallback 使用系统 curl 命令抓取以绕过 Cloudflare WAF 拦截。 + """ + try: + r = session.get(url, timeout=timeout, verify=False, headers=headers) + if r.status_code == 200: + return r.json() + except Exception: + pass + + # Fallback: 使用系统 curl + import subprocess, json + try: + headers_list = [] + if headers: + for k, v in headers.items(): + headers_list.extend(["-H", f"{k}: {v}"]) + cmd = ["curl", "-s"] + headers_list + [url] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if res.returncode == 0: + return json.loads(res.stdout.strip()) + except Exception: + pass + return None + + +def get_strike_price(round_ts: int) -> float: + """获取本回合的锚定价 (strike), 每回合只需获取一次""" + global _cached_strike + if _cached_strike["round_ts"] == round_ts and _cached_strike["price"] > 0: + return _cached_strike["price"] + try: + from datetime import timezone as tz + start_iso = datetime.fromtimestamp(round_ts, tz=tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + end_iso = datetime.fromtimestamp(round_ts + ROUND_DURATION, tz=tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + url = f"https://polymarket.com/api/crypto/crypto-price?symbol=BTC&eventStartTime={start_iso}&variant=fiveminute&endDate={end_iso}" + s = _get_price_session() + data = _robust_get_json(s, url, timeout=5, headers={"User-Agent": "Mozilla/5.0"}) + if data: + price = float(data.get("openPrice") or 0) + if price > 0: + _cached_strike = {"round_ts": round_ts, "price": price} + return price + except Exception: + pass + return _cached_strike["price"] + +_btc_prices = {"strike": 0.0, "current": 0.0, "round_ts": 0, + "up_price": 0.0, "down_price": 0.0, "token_ids": {}, + "strike_15m": 0.0, "strike_1h": 0.0, + "_last_15m_ts": 0, "_last_1h_ts": 0} +_btc_price_lock = threading.Lock() + +def _btc_price_ws_worker(): + """后台线程: 通过 Polymarket RTDS WebSocket 接收实时 BTC 价格 + - 当前价: Chainlink Data Streams via WebSocket (每秒更新) + - 锚定价: Polymarket crypto-price REST API (每回合一次) + """ + import asyncio, json as _json + + def _detect_proxy(): + """自动检测代理设置""" + import socket + # 1. 检查环境变量 + for key in ("https_proxy", "HTTPS_PROXY", "all_proxy", "ALL_PROXY", + "http_proxy", "HTTP_PROXY"): + val = os.environ.get(key, "").strip() + if val: + return val + # 2. 探测常见本地代理端口 + for port in (9098, 9099, 7890, 7897, 1080, 8080): + try: + s = socket.create_connection(("127.0.0.1", port), timeout=0.3) + s.close() + return f"http://127.0.0.1:{port}" + except (OSError, socket.timeout): + continue + return None + + + ws_kwargs = dict(ping_interval=20, ping_timeout=10, close_timeout=5) + + async def _run_rtds(): + import websockets + s = _get_price_session() + from datetime import timezone as tz + last_strike_round = 0 + + while True: + try: + async with websockets.connect( + "wss://ws-live-data.polymarket.com", **ws_kwargs, + ) as ws: + # 订阅所有 Chainlink 实时价格 (不带 filter 可同时收 BTC + ETH) + sub = {"action": "subscribe", "subscriptions": [ + {"topic": "crypto_prices_chainlink", "type": "*"} + ]} + await ws.send(_json.dumps(sub)) + + async for raw in ws: + if not raw or not raw.strip(): + continue + try: + data = _json.loads(raw) + except ValueError: + continue + + # 解析价格推送 + payload = data.get("payload", {}) + if isinstance(payload, dict) and payload.get("value"): + price = float(payload["value"]) + symbol = payload.get("symbol", "").lower() + if price > 0 and symbol == "btc/usd": + with _btc_price_lock: + _btc_prices["current"] = price + # 同步到 quick_trade (嵌入模式) + if price > 0 and symbol in ("btc/usd", "eth/usd"): + try: + import quick_trade + quick_trade._rtds_prices[symbol] = price + except Exception: + pass + + # 检查是否需要更新锚定价 (每回合一次) + with _btc_price_lock: + round_ts = _btc_prices["round_ts"] + if round_ts > 0 and last_strike_round != round_ts: + try: + start_iso = datetime.fromtimestamp(round_ts, tz=tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + end_iso = datetime.fromtimestamp(round_ts + ROUND_DURATION, tz=tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + url = f"https://polymarket.com/api/crypto/crypto-price?symbol=BTC&eventStartTime={start_iso}&variant=fiveminute&endDate={end_iso}" + data = _robust_get_json(s, url, timeout=5, headers={"User-Agent": "Mozilla/5.0"}) + if data: + strike = float(data.get("openPrice") or 0) + if strike > 0: + with _btc_price_lock: + _btc_prices["strike"] = strike + last_strike_round = round_ts + except Exception: + pass + + # 15分钟 / 1小时 strike (每10秒检查一次) + if time.time() - getattr(_run_rtds, '_last_multi_check', 0) > 10: + _run_rtds._last_multi_check = time.time() + try: + now_utc = datetime.now(tz=tz.utc) + # 15m 对齐 + m15 = now_utc.minute // 15 * 15 + ts_15m = int(now_utc.replace(minute=m15, second=0, microsecond=0).timestamp()) + # 1h 对齐 + ts_1h = int(now_utc.replace(minute=0, second=0, microsecond=0).timestamp()) + headers_ua = {"User-Agent": "Mozilla/5.0"} + with _btc_price_lock: + last_15m = _btc_prices["_last_15m_ts"] + last_1h = _btc_prices["_last_1h_ts"] + if ts_15m != last_15m: + s15 = datetime.fromtimestamp(ts_15m, tz=tz.utc) + e15 = datetime.fromtimestamp(ts_15m + 900, tz=tz.utc) + url15 = f"https://polymarket.com/api/crypto/crypto-price?symbol=BTC&eventStartTime={s15.strftime('%Y-%m-%dT%H:%M:%SZ')}&variant=fiveminute&endDate={e15.strftime('%Y-%m-%dT%H:%M:%SZ')}" + data15 = _robust_get_json(s, url15, timeout=5, headers=headers_ua) + if data15: + op15 = data15.get("openPrice") + if op15 is not None and float(op15) > 0: + with _btc_price_lock: + _btc_prices["strike_15m"] = float(op15) + _btc_prices["_last_15m_ts"] = ts_15m + if ts_1h != last_1h: + s1h = datetime.fromtimestamp(ts_1h, tz=tz.utc) + e1h = datetime.fromtimestamp(ts_1h + 3600, tz=tz.utc) + url1h = f"https://polymarket.com/api/crypto/crypto-price?symbol=BTC&eventStartTime={s1h.strftime('%Y-%m-%dT%H:%M:%SZ')}&variant=fiveminute&endDate={e1h.strftime('%Y-%m-%dT%H:%M:%SZ')}" + data1h = _robust_get_json(s, url1h, timeout=5, headers=headers_ua) + if data1h: + op1h = data1h.get("openPrice") + if op1h is not None and float(op1h) > 0: + with _btc_price_lock: + _btc_prices["strike_1h"] = float(op1h) + _btc_prices["_last_1h_ts"] = ts_1h + except Exception: + pass + + except Exception: + pass + # 断线重连 + await asyncio.sleep(2) + + async def _run_clob(): + """CLOB Market WS: 订阅 UP/DOWN 价格变动""" + import websockets + while True: + try: + with _btc_price_lock: + tids = _btc_prices["token_ids"] + asset_ids = [v for v in tids.values() if v] + if not asset_ids: + await asyncio.sleep(2) + continue + + subscribed_tids = set(asset_ids) + + async with websockets.connect( + "wss://ws-subscriptions-clob.polymarket.com/ws/market", + **ws_kwargs, + ) as ws: + sub = {"assets_ids": asset_ids, "type": "market"} + await ws.send(_json.dumps(sub)) + + async for raw in ws: + if not raw or not raw.strip(): + continue + try: + data = _json.loads(raw) + except ValueError: + continue + + # 统一处理: list(初始快照) 或 dict(后续事件) + events = data if isinstance(data, list) else [data] + with _btc_price_lock: + cur_tids = _btc_prices["token_ids"] + + for item in events: + if not isinstance(item, dict): + continue + evt = item.get("event_type", "") + aid = item.get("asset_id", "") + + if evt == "book": + # 初始快照: 取 best bid 作为初始价 + bids = item.get("bids", []) + if bids: + best = max(bids, key=lambda b: float(b.get("price", 0))) + price = float(best.get("price", 0)) + if price > 0: + if aid == cur_tids.get("up"): + with _btc_price_lock: + _btc_prices["up_price"] = price + elif aid == cur_tids.get("down"): + with _btc_price_lock: + _btc_prices["down_price"] = price + + elif evt == "price_change": + for pc in item.get("price_changes", []): + pc_aid = pc.get("asset_id", "") + # 用 best_bid (盘口最优买价) 而非 price (挂单价) + best_bid = float(pc.get("best_bid", 0)) + if best_bid > 0: + if pc_aid == cur_tids.get("up"): + with _btc_price_lock: + _btc_prices["up_price"] = best_bid + elif pc_aid == cur_tids.get("down"): + with _btc_price_lock: + _btc_prices["down_price"] = best_bid + + # 检查 token 是否变了 (新回合) → 断开重连新 token + with _btc_price_lock: + new_tids = set(_btc_prices["token_ids"].values()) + if new_tids != subscribed_tids: + break # 退出重连 + + except Exception: + pass + await asyncio.sleep(2) + + async def _poll_clob_prices(): + """REST 轮询 UP/DOWN 最优价 (兜底, 防 WS 不推送)""" + import aiohttp + while True: + try: + with _btc_price_lock: + tids = dict(_btc_prices["token_ids"]) + if not tids: + await asyncio.sleep(3) + continue + + async with aiohttp.ClientSession() as session: + for side, tid in tids.items(): + if not tid: + continue + try: + url = f"https://clob.polymarket.com/book?token_id={tid}" + async with session.get(url, timeout=aiohttp.ClientTimeout(total=5), ssl=False) as resp: + if resp.status == 200: + book = await resp.json(content_type=None) + bids = book.get("bids", []) + if bids: + best = max(bids, key=lambda b: float(b.get("price", 0))) + price = float(best.get("price", 0)) + if price > 0: + key = f"{side}_price" + with _btc_price_lock: + _btc_prices[key] = price + except Exception: + pass + except Exception: + pass + await asyncio.sleep(3) + + async def _run_all(): + await asyncio.gather(_run_rtds(), _run_clob(), _poll_clob_prices()) + + asyncio.run(_run_all()) + +def start_btc_price_feed(): + """启动 BTC 实时价格 WebSocket 后台线程""" + t = threading.Thread(target=_btc_price_ws_worker, daemon=True) + t.start() + +def set_price_round(round_ts: int, token_ids: dict = None): + """设置当前回合时间和 token_ids (供价格线程使用)""" + with _btc_price_lock: + if _btc_prices["round_ts"] != round_ts: + _btc_prices["round_ts"] = round_ts + _btc_prices["current"] = 0.0 + _btc_prices["up_price"] = 0.0 + _btc_prices["down_price"] = 0.0 + if token_ids: + _btc_prices["token_ids"] = dict(token_ids) + +def get_btc_prices() -> tuple: + """返回 (锚定价, 当前价)""" + with _btc_price_lock: + return _btc_prices["strike"], _btc_prices["current"] + +_PRICE_LINE_NUM = 3 # 价格行在终端中的行号 (清屏后) +_PRICE_LINE_W = 163 # 行宽度 + +def _format_price_line() -> str: + """生成价格行文本""" + strike, btc_price = get_btc_prices() + with _btc_price_lock: + up_p = _btc_prices["up_price"] + dn_p = _btc_prices["down_price"] + s15 = _btc_prices["strike_15m"] + s1h = _btc_prices["strike_1h"] + if strike <= 0 and btc_price <= 0: + return "" + parts = [] + # 现价 + if btc_price > 0: + parts.append(f"💰 现价: ${btc_price:,.2f}") + # 三个锚定价 + 各自差值 + def _strike_part(label, s): + if s <= 0: + return None + if btc_price > 0: + d = btc_price - s + arrow = "🟢" if d > 0 else "🔴" if d < 0 else "⚪" + return f"📌{label}: ${s:,.2f} ({arrow}{d:+,.2f})" + return f"📌{label}: ${s:,.2f}" + for label, sv in [("5m", strike), ("15m", s15), ("1h", s1h)]: + p = _strike_part(label, sv) + if p: + parts.append(p) + # UP/DN 价格 + if up_p > 0 or dn_p > 0: + up_c = int(up_p * 100) if up_p > 0 else "--" + dn_c = int(dn_p * 100) if dn_p > 0 else "--" + parts.append(f"UP {up_c}¢ / DN {dn_c}¢") + return f" {' | '.join(parts)}" + +def _update_price_line(): + """用 ANSI 转义原地刷新价格行 (不重绘整个屏幕)""" + line = _format_price_line() + if not line: + return + # \033[s = 保存光标 \033[{row};1H = 移动到第 row 行第 1 列 \033[2K = 清除整行 \033[u = 恢复光标 + import sys + sys.stdout.write(f"\033[s\033[{_PRICE_LINE_NUM};1H\033[2K{line}\033[u") + sys.stdout.flush() + + +def display_leaderboard( + balances: dict, token_ids: dict, round_ts: int, round_end: int, scan_block: int, +): + """终端显示排行榜 — UP/DOWN 左右并排, 净额显示""" + now = int(time.time()) + remaining = max(0, round_end - now) + round_time = datetime.fromtimestamp(round_ts).strftime("%H:%M:%S") + + # 净额计算 + up_ranked, dn_ranked, hedged_addrs = net_positions(balances, token_ids) + up_total = sum(s for _, s in up_ranked) + dn_total = sum(s for _, s in dn_ranked) + + # 列宽 — 分割线固定在第 COL+1 列 + COL = 80 + DIV_COL = COL + 1 # │ 的绝对列位置 + DIV = " │ " + W = COL * 2 + len(DIV) + + # ANSI 光标定位: 移动到第 N 列 + def goto(col): + return f"\033[{col}G" + + def print_row(left, right): + """打印一行: left 内容 + 光标跳到固定列 + │ + right 内容""" + print(f"{left}{goto(DIV_COL)}{DIV}{right}") + + # 清屏 + 清滚动缓冲区 (ANSI, 无需 fork 子进程) + sys.stdout.write("\033[2J\033[3J\033[H") + sys.stdout.flush() + + print("═" * W) + header = f" 🏆 BTC 5m 链上持仓排行榜 | 回合 {round_time} | 剩余 {remaining}s | 区块 {scan_block} | 📡 链上 maker+taker" + print(header) + # BTC 价格行 (Polymarket RTDS WebSocket) + price_line = _format_price_line() + if price_line: + print(price_line) + else: + print(" ⏳ 等待价格数据...") + if hedged_addrs: + print(f" ⚖️ = 两边持仓已净额合并 ({len(hedged_addrs)} 个做市商)") + # 上轮赢家在本轮的方向统计 + if _last_round_winners: + up_addrs_set = {a for a, _ in up_ranked} + dn_addrs_set = {a for a, _ in dn_ranked} + up_dict = dict(up_ranked) + dn_dict = dict(dn_ranked) + w_up_n, w_dn_n = 0, 0 + w_up_s, w_dn_s = 0.0, 0.0 + for addr in _last_round_winners: + if addr in up_addrs_set: + w_up_n += 1 + w_up_s += up_dict[addr] + if addr in dn_addrs_set: + w_dn_n += 1 + w_dn_s += dn_dict[addr] + net_s = abs(w_up_s - w_dn_s) + net_dir = "UP" if w_up_s > w_dn_s else "DN" if w_dn_s > w_up_s else "--" + arrow = "🟢" if net_dir == "UP" else "🔴" if net_dir == "DN" else "⚪" + side_label = _last_round_winner_side.upper() if _last_round_winner_side else "?" + total_w = len(_last_round_winners) + present = w_up_n + w_dn_n + print(f" 🏅 上轮{side_label}赢家 {total_w}人 | 本轮: {w_up_n}人UP({w_up_s:,.0f}股) vs {w_dn_n}人DN({w_dn_s:,.0f}股) | {arrow} 净{net_dir} {net_s:,.0f}股") + # 4连胜名单方向统计 + if _streak_list: + up_addrs_sl = {a for a, _ in up_ranked} + dn_addrs_sl = {a for a, _ in dn_ranked} + sl_up = len(_streak_list & up_addrs_sl) + sl_dn = len(_streak_list & dn_addrs_sl) + sl_arrow = "🟢" if sl_up > sl_dn else "🔴" if sl_dn > sl_up else "⚪" + print(f" 📊 4连胜名单 {len(_streak_list)}人 | 本轮: {sl_up}人UP vs {sl_dn}人DN | {sl_arrow}") + print("═" * W) + + # 列标题 (含三连胜人数) + up_addrs = {a for a, _ in up_ranked} + dn_addrs = {a for a, _ in dn_ranked} + streak_up = len(_streak_3_addrs & up_addrs) + streak_dn = len(_streak_3_addrs & dn_addrs) + up_streak_str = f" 🔥{streak_up}" if streak_up else "" + dn_streak_str = f" 🔥{streak_dn}" if streak_dn else "" + up_hdr = f" 🟢 UP (涨) {len(up_ranked)}人 净总计 {up_total:,.0f}股{up_streak_str}" + dn_hdr = f" 🔴 DOWN (跌) {len(dn_ranked)}人 净总计 {dn_total:,.0f}股{dn_streak_str}" + print_row(up_hdr, dn_hdr) + print(f"{'─' * COL}{DIV}{'─' * COL}") + + # 逐行输出 (同排名对齐) + max_rows = max(len(up_ranked[:100]), len(dn_ranked[:100])) + for i in range(max_rows): + # 左列 (UP) + if i < len(up_ranked) and i < 100: + addr, shares = up_ranked[i] + pct = shares / up_total * 100 if up_total > 0 else 0 + left = format_entry(i + 1, addr, shares, pct, addr in hedged_addrs, addr in _streak_3_addrs) + else: + left = "" + + # 右列 (DOWN) + if i < len(dn_ranked) and i < 100: + addr, shares = dn_ranked[i] + pct = shares / dn_total * 100 if dn_total > 0 else 0 + right = format_entry(i + 1, addr, shares, pct, addr in hedged_addrs, addr in _streak_3_addrs) + else: + right = "" + + print_row(left, right) + + print(f"{'─' * COL}{DIV}{'─' * COL}") + print(f" 🔄 {datetime.now().strftime('%H:%M:%S')} 更新 | Ctrl+C 退出") + print() + + # ── 推送到 Web 前端 ── + try: + import quick_trade + def _entry(addr, shares, hedged, streak): + info = get_user_info(addr) + return { + "addr": addr, + "shares": round(shares, 1), + "whale": addr in TRACKED_WHALES_CS, + "hedged": hedged, + "streak": streak, + "name": info.get("name", ""), + "pnl": info.get("pnl"), + } + lb_data = { + "up": [_entry(a, s, a in hedged_addrs, a in _streak_3_addrs) for a, s in up_ranked[:100]], + "down": [_entry(a, s, a in hedged_addrs, a in _streak_3_addrs) for a, s in dn_ranked[:100]], + "up_total": round(up_total, 0), + "dn_total": round(dn_total, 0), + "up_count": len(up_ranked), + "dn_count": len(dn_ranked), + "hedged_count": len(hedged_addrs), + "scan_block": scan_block, + "streak_up": len(_streak_3_addrs & {a for a, _ in up_ranked}), + "streak_dn": len(_streak_3_addrs & {a for a, _ in dn_ranked}), + } + # 上轮赢家统计 + if _last_round_winners: + up_dict_lb = dict(up_ranked) + dn_dict_lb = dict(dn_ranked) + w_up_n_lb = sum(1 for a in _last_round_winners if a in up_dict_lb) + w_dn_n_lb = sum(1 for a in _last_round_winners if a in dn_dict_lb) + w_up_s_lb = sum(up_dict_lb.get(a, 0) for a in _last_round_winners) + w_dn_s_lb = sum(dn_dict_lb.get(a, 0) for a in _last_round_winners) + lb_data["winners"] = { + "side": _last_round_winner_side, + "total": len(_last_round_winners), + "up_n": w_up_n_lb, "dn_n": w_dn_n_lb, + "up_s": round(w_up_s_lb, 0), "dn_s": round(w_dn_s_lb, 0), + } + # 4连胜统计 + if _streak_list: + sl_up_lb = len(_streak_list & {a for a, _ in up_ranked}) + sl_dn_lb = len(_streak_list & {a for a, _ in dn_ranked}) + lb_data["streak4"] = {"total": len(_streak_list), "up": sl_up_lb, "dn": sl_dn_lb} + with quick_trade.state_lock: + quick_trade.state["leaderboard"] = lb_data + + # ── 15m / 1h 排行榜 ── + for tf_key in ("15m", "1h"): + tf_tids = quick_trade.state.get(f"_lb_tids_{tf_key}") + if not tf_tids: + continue + tf_up, tf_dn, tf_hedged = net_positions(balances, tf_tids) + tf_up_total = sum(s for _, s in tf_up) + tf_dn_total = sum(s for _, s in tf_dn) + tf_data = { + "up": [_entry(a, s, a in tf_hedged, False) for a, s in tf_up[:100]], + "down": [_entry(a, s, a in tf_hedged, False) for a, s in tf_dn[:100]], + "up_total": round(tf_up_total, 0), + "dn_total": round(tf_dn_total, 0), + "up_count": len(tf_up), + "dn_count": len(tf_dn), + "hedged_count": len(tf_hedged), + "scan_block": scan_block, + } + quick_trade.state[f"leaderboard_{tf_key}"] = tf_data + except Exception: + pass + + + +# ── 上轮赢家跟踪 ────────────────────────────────────────────────────────────── +SAVE_DIR = os.path.join(os.path.dirname(__file__), "持仓") +_last_round_winners = {} # {addr: net_shares} 上轮赢家地址及净持仓 +_last_round_winner_side = "" # "up" or "down" +_streak_3_addrs = set() # 连续3轮获胜的地址 + +def load_last_round_winners(): + """从持仓目录读取最新 CSV, 提取赢家地址 + 计算三连胜""" + global _last_round_winners, _last_round_winner_side, _streak_3_addrs + import glob, re, csv as csv_mod + csv_files = sorted(glob.glob(os.path.join(SAVE_DIR, "*-blocks.csv"))) + if not csv_files: + return + + # 最新一轮赢家 + latest = csv_files[-1] + fname = os.path.basename(latest) + m = re.match(r"\d{4}-\d{4}-(up|down)-blocks\.csv", fname) + if not m: + return + winner = m.group(1) + winners = {} + with open(latest, encoding="utf-8") as f: + reader = csv_mod.DictReader(f) + for row in reader: + if row["方向"] == winner: + winners[row["地址"]] = float(row["持仓"]) + _last_round_winners = winners + _last_round_winner_side = winner + + # 三连胜: 最近3个CSV的赢家交集 + _streak_3_addrs = set() + if len(csv_files) >= 3: + winner_sets = [] + for cf in csv_files[-3:]: + fn = os.path.basename(cf) + m2 = re.match(r"\d{4}-\d{4}-(up|down)-blocks\.csv", fn) + if not m2: + winner_sets = [] + break + w_side = m2.group(1) + addrs = set() + with open(cf, encoding="utf-8") as f: + reader = csv_mod.DictReader(f) + for row in reader: + if row["方向"] == w_side: + addrs.add(row["地址"]) + winner_sets.append(addrs) + if len(winner_sets) == 3: + _streak_3_addrs = winner_sets[0] & winner_sets[1] & winner_sets[2] + +# ── 回合结算 + 保存 ────────────────────────────────────────────────────────── +_save_status = "" # 用于在看板底部显示保存状态 + + +def get_round_winner(round_ts: int, timeout: int = 120) -> str: + """通过 crypto-price API 快速判断赢家 (通常 <10s) + 原理: 拿到 strike 价和结算价,比较大小即可判断 UP/DOWN""" + global _save_status + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + session = requests.Session() + adapter = requests.adapters.HTTPAdapter(max_retries=3) + session.mount("https://", adapter) + + round_end = round_ts + ROUND_DURATION + deadline = time.time() + timeout + attempt = 0 + + # 先等回合实际结束 + now = time.time() + if now < round_end + 5: + wait = round_end + 5 - now + _save_status = f"⏳ 等待回合 {datetime.fromtimestamp(round_ts).strftime('%H:%M')} 结束 ({wait:.0f}s)" + time.sleep(max(0, wait)) + + # 1. 获取 strike (本回合开盘价) + from datetime import timezone as tz + start_iso = datetime.fromtimestamp(round_ts, tz=tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + end_iso = datetime.fromtimestamp(round_end, tz=tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + strike = 0.0 + while time.time() < deadline and strike <= 0: + attempt += 1 + try: + url = f"https://polymarket.com/api/crypto/crypto-price?symbol=BTC&eventStartTime={start_iso}&variant=fiveminute&endDate={end_iso}" + data = _robust_get_json(session, url, timeout=10, headers={ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" + }) + if data: + strike = float(data.get("openPrice") or 0) + except Exception: + pass + if strike <= 0: + _save_status = f"⏳ 获取 strike 第{attempt}次..." + time.sleep(2) + + if strike <= 0: + _save_status = f"❌ 无法获取 strike,回退到 Gamma API" + return _get_round_winner_gamma(round_ts, timeout=180) + + # 2. 获取结算价 (下一回合的 strike 就是本回合的结算价) + next_start_iso = datetime.fromtimestamp(round_end, tz=tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + next_end_iso = datetime.fromtimestamp(round_end + ROUND_DURATION, tz=tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + resolution = 0.0 + while time.time() < deadline and resolution <= 0: + attempt += 1 + try: + url = f"https://polymarket.com/api/crypto/crypto-price?symbol=BTC&eventStartTime={next_start_iso}&variant=fiveminute&endDate={next_end_iso}" + data = _robust_get_json(session, url, timeout=10, headers={ + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" + }) + if data: + resolution = float(data.get("openPrice") or 0) + except Exception: + pass + if resolution <= 0: + _save_status = f"⏳ 等待结算价 (strike=${strike:.2f})..." + time.sleep(3) + + if resolution <= 0: + _save_status = f"❌ 无法获取结算价,回退到 Gamma API" + return _get_round_winner_gamma(round_ts, timeout=180) + + # 3. 判断赢家 + winner = "up" if resolution > strike else "down" + _save_status = f"📊 strike=${strike:.2f} → ${resolution:.2f} → {winner.upper()}" + return winner + + +def _get_round_winner_gamma(round_ts: int, timeout: int = 180) -> str: + """回退: 通过 Gamma API 获取赢家 (慢, 需要 2-3 分钟)""" + global _save_status + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + session = requests.Session() + adapter = requests.adapters.HTTPAdapter(max_retries=3) + session.mount("https://", adapter) + + slug = f"btc-updown-5m-{round_ts}" + deadline = time.time() + timeout + attempt = 0 + + while time.time() < deadline: + attempt += 1 + try: + r = session.get( + "https://gamma-api.polymarket.com/events", + params={"slug": slug}, timeout=15, + verify=False, + ) + events = r.json() + if events and isinstance(events, list): + mkt = events[0].get("markets", [{}])[0] + prices = mkt.get("outcomePrices", "") + if isinstance(prices, str): + try: + prices = json.loads(prices) + except Exception: + prices = [] + outcomes = mkt.get("outcomes", []) + if isinstance(outcomes, str): + outcomes = json.loads(outcomes) + for i, p in enumerate(prices): + if float(p) > 0.9 and i < len(outcomes): + return outcomes[i].lower() + except Exception: + pass + _save_status = f"⏳ Gamma回退: 等待结算 (第{attempt}次)..." + time.sleep(5) + + return "unknown" + + +def save_round_file(round_ts: int, balances_snapshot: dict, token_ids: dict, block_map: dict = None): + """后台线程: 等待结算 → 保存持仓 + 区块信息""" + global _save_status + + round_time = datetime.fromtimestamp(round_ts) + _save_status = f"⏳ 等待回合 {round_time.strftime('%H:%M')} 结算..." + + # 1. 等待赢家 + winner = get_round_winner(round_ts) + + # 2. 生成文件名: 0411-1855-up.txt + fname = f"{round_time.strftime('%m%d-%H%M')}-{winner}.txt" + fpath = os.path.join(SAVE_DIR, fname) + os.makedirs(SAVE_DIR, exist_ok=True) + + # 3. 净额计算 + up_ranked, dn_ranked, hedged_addrs = net_positions(balances_snapshot, token_ids) + up_total = sum(s for _, s in up_ranked) + dn_total = sum(s for _, s in dn_ranked) + + # 获取 token_id → side 映射 + tid_to_side = {} + for side, tid in token_ids.items(): + tid_to_side[tid] = side + + # 合并所有 token 的 block_map: addr -> {side: [blocks]} + addr_blocks = defaultdict(lambda: defaultdict(list)) + if block_map: + for tid, addr_map in block_map.items(): + side = tid_to_side.get(tid, "?") + for addr, blocks in addr_map.items(): + addr_blocks[addr.lower()][side].extend(blocks) + + # 4. 写 txt (同终端显示格式, 全部持仓 + 区块列) + COL = 105 + DIV = " │ " + + lines = [] + lines.append("═" * (COL * 2 + len(DIV))) + lines.append(f" 🏆 BTC 5m 链上持仓 | 回合 {round_time.strftime('%H:%M:%S')} | 赢家: {winner.upper()} | 📡 链上 maker+taker") + if hedged_addrs: + lines.append(f" ⚖️ = 两边持仓已净额合并 ({len(hedged_addrs)} 个做市商)") + # 上轮赢家在本轮的方向统计 + if _last_round_winners: + up_addrs_set = {a for a, _ in up_ranked} + dn_addrs_set = {a for a, _ in dn_ranked} + up_dict = dict(up_ranked) + dn_dict = dict(dn_ranked) + w_up_n, w_dn_n = 0, 0 + w_up_s, w_dn_s = 0.0, 0.0 + for addr in _last_round_winners: + if addr in up_addrs_set: + w_up_n += 1 + w_up_s += up_dict[addr] + if addr in dn_addrs_set: + w_dn_n += 1 + w_dn_s += dn_dict[addr] + net_s = abs(w_up_s - w_dn_s) + net_dir = "UP" if w_up_s > w_dn_s else "DN" if w_dn_s > w_up_s else "--" + arrow = "🟢" if net_dir == "UP" else "🔴" if net_dir == "DN" else "⚪" + side_label = _last_round_winner_side.upper() if _last_round_winner_side else "?" + total_w = len(_last_round_winners) + lines.append(f" 🏅 上轮{side_label}赢家 {total_w}人 | 本轮: {w_up_n}人UP({w_up_s:,.0f}股) vs {w_dn_n}人DN({w_dn_s:,.0f}股) | {arrow} 净{net_dir} {net_s:,.0f}股") + # 4连胜名单方向统计 + if _streak_list: + up_addrs_sl = {a for a, _ in up_ranked} + dn_addrs_sl = {a for a, _ in dn_ranked} + sl_up = len(_streak_list & up_addrs_sl) + sl_dn = len(_streak_list & dn_addrs_sl) + sl_arrow = "🟢" if sl_up > sl_dn else "🔴" if sl_dn > sl_up else "⚪" + lines.append(f" 📊 4连胜名单 {len(_streak_list)}人 | 本轮: {sl_up}人UP vs {sl_dn}人DN | {sl_arrow}") + lines.append("═" * (COL * 2 + len(DIV))) + + up_hdr = f" 🟢 UP (涨) {len(up_ranked)}人 净总计 {up_total:,.0f}股" + dn_hdr = f" 🔴 DOWN (跌) {len(dn_ranked)}人 净总计 {dn_total:,.0f}股" + lines.append(f"{up_hdr:<{COL}}{DIV}{dn_hdr:<{COL}}") + lines.append(f"{'─' * COL}{DIV}{'─' * COL}") + + max_rows = max(len(up_ranked), len(dn_ranked)) + for i in range(max_rows): + if i < len(up_ranked): + addr, shares = up_ranked[i] + pct = shares / up_total * 100 if up_total > 0 else 0 + left = format_entry(i + 1, addr, shares, pct, addr in hedged_addrs) + else: + left = "" + + if i < len(dn_ranked): + addr, shares = dn_ranked[i] + pct = shares / dn_total * 100 if dn_total > 0 else 0 + right = format_entry(i + 1, addr, shares, pct, addr in hedged_addrs) + else: + right = "" + + lines.append(f"{left:<{COL}}{DIV}{right}") + + lines.append(f"{'─' * COL}{DIV}{'─' * COL}") + + with open(fpath, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + # 5. 写区块信息 CSV (用于小号聚类分析) + if block_map: + csv_name = f"{round_time.strftime('%m%d-%H%M')}-{winner}-blocks.csv" + csv_path = os.path.join(SAVE_DIR, csv_name) + + # 合并排名信息 + all_addrs = {} + for side_list, side_name in [(up_ranked, "up"), (dn_ranked, "down")]: + for rank, (addr, shares) in enumerate(side_list, 1): + all_addrs[addr.lower()] = { + "addr": addr, "side": side_name, + "rank": rank, "shares": shares, + "hedged": addr in hedged_addrs, + } + + with open(csv_path, "w", encoding="utf-8") as f: + f.write("地址,方向,排名,持仓,对冲,买入区块\n") + for addr_lower, info in sorted(all_addrs.items(), key=lambda x: x[1]["rank"]): + side_map = addr_blocks.get(addr_lower, {}) + side_blocks = sorted(set(side_map.get(info['side'], []))) + blocks_str = "|".join(str(b) for b in side_blocks) + hedge = "⚖️" if info["hedged"] else "" + f.write(f"{info['addr']},{info['side']},{info['rank']},{info['shares']:.0f},{hedge},{blocks_str}\n") + + _save_status = f"✅ 已保存 {fname} ({len(up_ranked)}+{len(dn_ranked)} 人)" + load_last_round_winners() + + +# ── 主循环 ──────────────────────────────────────────────────────────────────── +def main(): + + import argparse + + parser = argparse.ArgumentParser(description="链上持仓排行榜") + parser.add_argument("--once", action="store_true", help="只查一次") + parser.add_argument("--ts", type=int, help="指定回合时间戳") + parser.add_argument("--interval", type=int, default=5, help="显示刷新间隔(秒)") + parser.add_argument("--top", type=int, default=20, help="显示前N名") + parser.add_argument("--no-save", action="store_true", help="不保存持仓文件") + parser.add_argument("--lookback", type=int, default=300, + help="往前扫描秒数 (捕捉提前布局, 默认 300s = 1个回合)") + args = parser.parse_args() + + load_username_cache() + # 启动用户名后台解析线程 + threading.Thread(target=_user_resolver_worker, daemon=True).start() + w3 = get_w3() + + # 加载鲸鱼列表 (从 tracked_whales_list.txt) + reload_tracked_whales() + + current_round_ts = args.ts or get_current_round_ts() + round_end = current_round_ts + ROUND_DURATION + + print(f"🔍 获取回合 {datetime.fromtimestamp(current_round_ts).strftime('%H:%M:%S')} 的 token 信息...") + token_ids = get_round_token_ids(current_round_ts) + if not token_ids: + print("❌ 无法获取回合 token_id,可能回合尚未创建") + sys.exit(1) + + print(f" UP token: {token_ids.get('up', 'N/A')[:20]}...") + print(f" DOWN token: {token_ids.get('down', 'N/A')[:20]}...") + + target_set = set(token_ids.values()) + + # ── 15m / 1h 额外 token_ids (共用同一套区块扫描) ── + _extra_tids = {} # {"15m": {"up": tid, "down": tid}, "1h": ...} + now_ts = int(time.time()) + for tf_key, duration in [("15m", 900), ("1h", 3600)]: + tf_round = now_ts - (now_ts % duration) + tids = get_round_token_ids(tf_round, prefix=tf_key) + if tids: + _extra_tids[tf_key] = tids + target_set |= set(tids.values()) + print(f" {tf_key} tokens: UP={list(tids.values())[0][:16]}... DN={list(tids.values())[1][:16]}...") + else: + print(f" ⚠️ {tf_key} token 获取失败, 跳过") + + + # 计算起始区块 (往前 lookback 秒捕捉提前布局) + scan_from_ts = current_round_ts - args.lookback + start_block = ts_to_block(w3, scan_from_ts) + last_scanned_block = start_block - 1 + + print(f" 起始区块: {start_block} (往前 {args.lookback}s)") + + # 启动 BTC 实时价格 WebSocket (Polymarket RTDS Chainlink Data Streams) + set_price_round(current_round_ts, token_ids) + start_btc_price_feed() + + # 启动 Quick Trade 面板 (嵌入模式, 共享 RTDS 价格) + try: + import quick_trade + quick_trade.start_embedded() + except Exception as e: + print(f" ⚠️ Quick Trade 启动失败: {e}") + + print(f" 启动中...") + + # 加载上轮赢家 (从已保存的 CSV) + load_last_round_winners() + if _last_round_winners: + print(f" 📂 已加载上轮 {_last_round_winner_side.upper()} 赢家 {len(_last_round_winners)} 人") + + # ── 共享状态 (扫描线程 ↔ 显示线程) ── + _scan_lock = threading.Lock() + cumulative_balances = defaultdict(lambda: defaultdict(float)) + cumulative_blocks = defaultdict(lambda: defaultdict(list)) + _scan_state = { + "last_scanned_block": last_scanned_block, + "current_round_ts": current_round_ts, + "round_end": round_end, + "token_ids": dict(token_ids), + "target_set": set(target_set), + "next_round_ts": current_round_ts + ROUND_DURATION, + "next_token_ids": {}, + "extra_tids": dict(_extra_tids), # 15m/1h token_ids + } + # 把 15m/1h token_ids 传给 quick_trade state 供 display_leaderboard 使用 + try: + import quick_trade + for tf_key, tids in _extra_tids.items(): + quick_trade.state[f"_lb_tids_{tf_key}"] = tids + except Exception: + pass + + def _scan_worker(): + """后台扫描线程: 持续扫描新区块""" + while True: + try: + with _scan_lock: + scan_from = _scan_state["last_scanned_block"] + 1 + ts = _scan_state["target_set"] + + current_block = w3.eth.block_number + if scan_from > current_block: + time.sleep(1) + continue + + # 当回合过半,预取下一回合 token + now_ts = int(time.time()) + with _scan_lock: + rts = _scan_state["current_round_ts"] + nrt = _scan_state["next_round_ts"] + ntids = _scan_state["next_token_ids"] + if now_ts >= rts + ROUND_DURATION // 2 and not ntids: + try: + nts = get_round_token_ids(nrt) + if nts: + with _scan_lock: + _scan_state["next_token_ids"] = nts + extra = set() + for tf_tids in _scan_state.get("extra_tids", {}).values(): + extra |= set(tf_tids.values()) + _scan_state["target_set"] = set(_scan_state["token_ids"].values()) | set(nts.values()) | extra + ts = _scan_state["target_set"] + except Exception: + pass + + # 刷新 15m / 1h token_ids (各自回合可能已换) + try: + for tf_key, duration in [("15m", 900), ("1h", 3600)]: + tf_round = now_ts - (now_ts % duration) + old_tids = _scan_state.get("extra_tids", {}).get(tf_key) + if old_tids: + # 检查是否已过期 + old_vals = set(old_tids.values()) + else: + old_vals = set() + # 每个回合只需刷新一次 + new_tids = get_round_token_ids(tf_round, prefix=tf_key) + if new_tids and set(new_tids.values()) != old_vals: + with _scan_lock: + _scan_state.setdefault("extra_tids", {})[tf_key] = new_tids + extra_all = set() + for tf_tids_v in _scan_state["extra_tids"].values(): + extra_all |= set(tf_tids_v.values()) + _scan_state["target_set"] = set(_scan_state["token_ids"].values()) | extra_all + if ntids: + _scan_state["target_set"] |= set(ntids.values()) + ts = _scan_state["target_set"] + try: + import quick_trade + quick_trade.state[f"_lb_tids_{tf_key}"] = new_tids + except Exception: + pass + except Exception: + pass + + quiet = (current_block - scan_from) < 10 + n_blocks = current_block - scan_from + 1 + logs = scan_transfers(w3, scan_from, current_block, quiet=quiet) + new_balances, new_blocks = build_balances(logs, ts, w3) + + if not logs and n_blocks > 50: + # 大量区块但 0 条日志 → RPC 可能有问题 + print(f"\n ⚠️ 扫描 {scan_from}-{current_block} ({n_blocks}块) 返回 0 条日志, 可能 RPC 异常", flush=True) + + with _scan_lock: + for tid, holders in new_balances.items(): + for addr, delta in holders.items(): + cumulative_balances[tid][addr] += delta + for tid, addr_blocks in new_blocks.items(): + for addr, blocks in addr_blocks.items(): + cumulative_blocks[tid][addr].extend(blocks) + _scan_state["last_scanned_block"] = current_block + + except Exception as e: + import traceback + print(f"\n ❌ 扫描异常: {e}", flush=True) + traceback.print_exc() + time.sleep(3) + + # 启动扫描线程 + scan_thread = threading.Thread(target=_scan_worker, daemon=True) + scan_thread.start() + + try: + while True: + with _scan_lock: + cur_block = _scan_state["last_scanned_block"] + cur_round_ts = _scan_state["current_round_ts"] + cur_round_end = _scan_state["round_end"] + cur_token_ids = dict(_scan_state["token_ids"]) + + # 更新价格线程的回合时间 + set_price_round(cur_round_ts, cur_token_ids) + + display_leaderboard( + cumulative_balances, cur_token_ids, + cur_round_ts, cur_round_end, cur_block, + ) + + # 显示后台保存状态 + if _save_status: + print(f" 📁 {_save_status}") + + if args.once: + break + + # 检查是否进入新回合 + now = int(time.time()) + if now >= cur_round_end: + # ── 回合结束: 后台保存持仓 (仅旧回合 token) ── + # 记录本轮赢家地址 (用于下轮显示) + with _scan_lock: + old_tids_set = set(cur_token_ids.values()) + snapshot = {} + for tid in old_tids_set: + if tid in cumulative_balances: + snapshot[tid] = dict(cumulative_balances[tid]) + blocks_snapshot = {} + for tid in old_tids_set: + if tid in cumulative_blocks: + blocks_snapshot[tid] = {a: list(bs) for a, bs in cumulative_blocks[tid].items()} + old_token_ids = dict(cur_token_ids) + old_round_ts = cur_round_ts + + if not args.no_save: + + t = threading.Thread( + target=save_round_file, + args=(old_round_ts, snapshot, old_token_ids, blocks_snapshot), + daemon=True, + ) + t.start() + + # ── 立即进入新回合 ── + save_username_cache() + reload_tracked_whales() # 热重载鲸鱼列表 + new_round_ts = get_current_round_ts() + new_round_end = new_round_ts + ROUND_DURATION + + # 优先用已预取的 token_ids + with _scan_lock: + ntids = _scan_state["next_token_ids"] + nrt = _scan_state["next_round_ts"] + if ntids and nrt == new_round_ts: + new_token_ids = ntids + else: + new_token_ids = get_round_token_ids(new_round_ts) + if not new_token_ids: + time.sleep(5) + continue + + # 保留 15m/1h 的 token_ids + extra_tids_keep = set() + with _scan_lock: + for tf_tids in _scan_state.get("extra_tids", {}).values(): + extra_tids_keep |= set(tf_tids.values()) + + new_target = set(new_token_ids.values()) | extra_tids_keep + + # 提取已预扫到的下一回合仓位 + with _scan_lock: + new_balances = defaultdict(lambda: defaultdict(float)) + new_blocks_map = defaultdict(lambda: defaultdict(list)) + for tid in new_target: + if tid in cumulative_balances: + new_balances[tid] = cumulative_balances[tid] + if tid in cumulative_blocks: + new_blocks_map[tid] = cumulative_blocks[tid] + # 原地替换共享数据 + cumulative_balances.clear() + cumulative_balances.update(new_balances) + cumulative_blocks.clear() + cumulative_blocks.update(new_blocks_map) + + _scan_state["current_round_ts"] = new_round_ts + _scan_state["round_end"] = new_round_end + _scan_state["token_ids"] = dict(new_token_ids) + _scan_state["target_set"] = new_target + _scan_state["next_round_ts"] = new_round_ts + ROUND_DURATION + _scan_state["next_token_ids"] = {} + + set_price_round(new_round_ts, new_token_ids) + + # ── 等 1 秒后重绘 (倒计时/价格秒级刷新) ── + time.sleep(1) + + except KeyboardInterrupt: + print("\n\n👋 已退出") + save_username_cache() + + +if __name__ == "__main__": + main() diff --git a/quick_trade.py b/quick_trade.py new file mode 100644 index 0000000..dcf5963 --- /dev/null +++ b/quick_trade.py @@ -0,0 +1,2182 @@ +#!/usr/bin/env python3 +""" +quick_trade.py — 多市场一键交易面板 + +启动: python quick_trade.py +打开: http://localhost:8890 + +快捷键: + ↑ = 买 UP ↓ = 买 DOWN + Q = 卖 UP ALL W = 卖 DOWN ALL + 1-5 = 选金额 ($5/$10/$20/$50/$100) +""" + +import asyncio +import json +import math +import os +import sys +import time +import threading +import webbrowser +from collections import deque +from datetime import datetime, timezone + +import aiohttp +from aiohttp import web +import requests +from dotenv import load_dotenv +from web3 import Web3 + +load_dotenv() + +# ── 多账号配置 ───────────────────────────────────────────────────────────────── +CHAIN_ID = 137 +SIGNATURE_TYPE = 3 # Polymarket Deposit Wallet (POLY_1271, V2 新钱包类型) +CLOB_HTTP = "https://clob.polymarket.com" +CTF_ADDR = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045" + +def _load_accounts(): + """从 .env 加载所有 QUICK 账号: QUICK_PRIVATE_KEY / QUICK_FUNDER (第1个), + QUICK_PRIVATE_KEY_2 / QUICK_FUNDER_2 (第2个), ...""" + accts = [] + # 第 1 个 (无编号) + pk = os.getenv("QUICK_PRIVATE_KEY", "") + fd = os.getenv("QUICK_FUNDER", "") + rk = os.getenv("QUICK_RELAYER_API_KEY", "") + ra = os.getenv("QUICK_RELAYER_API_KEY_ADDRESS", "") + if pk and fd: + accts.append({"private_key": pk, "funder": fd, + "relayer_key": rk, "relayer_addr": ra, + "label": fd[:6] + ".." + fd[-4:]}) + # 第 2~N 个 (有编号, 兼容 _2 和 _02 格式) + for i in range(2, 20): + pk = os.getenv(f"QUICK_PRIVATE_KEY_{i}", "") or os.getenv(f"QUICK_PRIVATE_KEY_{i:02d}", "") + fd = os.getenv(f"QUICK_FUNDER_{i}", "") or os.getenv(f"QUICK_FUNDER_{i:02d}", "") + rk = os.getenv(f"QUICK_RELAYER_API_KEY_{i}", "") or os.getenv(f"QUICK_RELAYER_API_KEY_{i:02d}", "") + ra = os.getenv(f"QUICK_RELAYER_API_KEY_ADDRESS_{i}", "") or os.getenv(f"QUICK_RELAYER_API_KEY_ADDRESS_{i:02d}", "") + if pk and fd: + accts.append({"private_key": pk, "funder": fd, + "relayer_key": rk, "relayer_addr": ra, + "label": fd[:6] + ".." + fd[-4:]}) + else: + break + return accts + +ACCOUNTS = _load_accounts() +_active_account_idx = 0 +_account_lock = threading.Lock() + +# 便捷访问当前账号 +def _current_account(): + with _account_lock: + idx = _active_account_idx + if idx < len(ACCOUNTS): + return ACCOUNTS[idx] + return {"private_key": "", "funder": "", "relayer_key": "", "relayer_addr": "", "label": "N/A"} + +# 兼容旧代码 +PRIVATE_KEY = ACCOUNTS[0]["private_key"] if ACCOUNTS else "" +FUNDER = ACCOUNTS[0]["funder"] if ACCOUNTS else "" + +# ── 多市场配置 ───────────────────────────────────────────────────────────────── +MARKETS = { + "btc_5m": {"label": "BTC 5m", "slug_prefix": "btc-updown-5m", "duration": 300, "symbol": "BTC", "variant": "fiveminute", "rtds": "btc/usd"}, + "btc_15m": {"label": "BTC 15m", "slug_prefix": "btc-updown-15m", "duration": 900, "symbol": "BTC", "variant": "fifteenminute", "rtds": "btc/usd"}, + "btc_1h": {"label": "BTC 1h", "slug_prefix": "bitcoin", "duration": 3600, "symbol": "BTC", "variant": "onehour", "rtds": "btc/usd", "slug_type": "hourly"}, + "eth_5m": {"label": "ETH 5m", "slug_prefix": "eth-updown-5m", "duration": 300, "symbol": "ETH", "variant": "fiveminute", "rtds": "eth/usd"}, + "eth_15m": {"label": "ETH 15m", "slug_prefix": "eth-updown-15m", "duration": 900, "symbol": "ETH", "variant": "fifteenminute", "rtds": "eth/usd"}, + "eth_1h": {"label": "ETH 1h", "slug_prefix": "ethereum", "duration": 3600, "symbol": "ETH", "variant": "onehour", "rtds": "eth/usd", "slug_type": "hourly"}, +} +active_market = "btc_5m" +active_market_lock = threading.Lock() +PORT = 8890 + +ALCHEMY_KEY = os.getenv("ALCHEMY_KEY", "") +RPC_URL = f"https://polygon-mainnet.g.alchemy.com/v2/{ALCHEMY_KEY}" + +POLY_BUILDER_CODE = os.getenv("POLY_BUILDER_CODE", "0x0000000000000000000000000000000000000000000000000000000000000000") + +# 滑点配置 +BUY_SLIPPAGE = 0.10 # 买入滑点 (10 cents) +SELL_SLIPPAGE = 0.10 # 卖出滑点 (10 cents) + +# ── 全局状态 ────────────────────────────────────────────────────────────────── +state_lock = threading.Lock() +state = { + "round_ts": 0, + "round_end": 0, + "token_ids": {}, + "strike": 0.0, + "crypto_current": 0.0, # BTC 或 ETH 现价 + "up_price": 0.0, + "down_price": 0.0, + "up_ask": 0.0, + "down_ask": 0.0, + "up_position": 0.0, + "down_position": 0.0, + "usdc_balance": 0.0, + "keys_ok": bool(ACCOUNTS), + "up_book": {"bids": [], "asks": []}, + "down_book": {"bids": [], "asks": []}, +} +# RTDS 推送缓存: symbol -> price +_rtds_prices = {"btc/usd": 0.0, "eth/usd": 0.0} +trade_log = deque(maxlen=50) + + +# ── Web3 连接 ───────────────────────────────────────────────────────────────── +_w3 = None + +def get_w3(): + global _w3 + if _w3: + return _w3 + try: + from web3.middleware import ExtraDataToPOAMiddleware as POA + except ImportError: + try: + from web3.middleware import geth_poa_middleware as POA + except ImportError: + POA = None + _w3 = Web3(Web3.HTTPProvider(RPC_URL, request_kwargs={"timeout": 10})) + if POA: + try: + _w3.middleware_onion.inject(POA, layer=0) + except Exception: + pass + return _w3 + + +# ── CLOB Client (多账号, 延迟初始化, 凭证缓存) ───────────────────────────────── +_clob = None +_clob_lock = threading.Lock() +_clob_account_idx = -1 # 当前 _clob 对应的账号索引 +_CREDS_DIR = os.path.dirname(os.path.abspath(__file__)) + +def _creds_file_for(funder): + return os.path.join(_CREDS_DIR, f".quick_trade_creds_{funder[:8].lower()}.json") + +def _load_cached_creds(funder): + """从本地文件加载缓存的 API 凭证""" + try: + path = _creds_file_for(funder) + if not os.path.exists(path): + return None + with open(path, "r") as f: + data = json.load(f) + if data.get("funder") != funder: + return None + from py_clob_client_v2.clob_types import ApiCreds + return ApiCreds( + api_key=data["api_key"], + api_secret=data["api_secret"], + api_passphrase=data["api_passphrase"], + ) + except Exception: + return None + +def _save_creds(funder, creds): + """缓存 API 凭证到本地文件""" + try: + data = { + "funder": funder, + "api_key": creds.api_key, + "api_secret": creds.api_secret, + "api_passphrase": creds.api_passphrase, + } + with open(_creds_file_for(funder), "w") as f: + json.dump(data, f) + except Exception: + pass + +def get_clob_client(): + global _clob, _clob_account_idx + acct = _current_account() + pk, funder = acct["private_key"], acct["funder"] + with _account_lock: + idx = _active_account_idx + with _clob_lock: + if _clob is not None and _clob_account_idx == idx: + return _clob + if not pk or not funder: + raise RuntimeError("账号未配置") + from py_clob_client_v2.client import ClobClient + _clob = ClobClient( + host=CLOB_HTTP, + key=pk, + chain_id=CHAIN_ID, + signature_type=SIGNATURE_TYPE, + funder=funder, + ) + cached = _load_cached_creds(funder) + if cached: + _clob.set_api_creds(cached) + print(f"✅ CLOB client [{acct['label']}] (缓存凭证)") + else: + creds = _clob.create_or_derive_api_key() + _clob.set_api_creds(creds) + _save_creds(funder, creds) + print(f"✅ CLOB client [{acct['label']}] (首次签名, 已缓存)") + _clob_account_idx = idx + return _clob + + +# ── 回合信息 ────────────────────────────────────────────────────────────────── +def _make_hourly_slug(crypto_name: str, round_ts: int) -> str: + """生成 1h 市场的 slug, 如 bitcoin-up-or-down-april-25-2026-11am-et""" + from datetime import timedelta + # round_ts 是 UTC 整小时, 转换为 ET (UTC-4) + et_dt = datetime.fromtimestamp(round_ts, tz=timezone.utc) - timedelta(hours=4) + month = et_dt.strftime("%B").lower() # april + day = et_dt.day + year = et_dt.year + hour = et_dt.hour + ampm = "am" if hour < 12 else "pm" + h12 = hour % 12 + if h12 == 0: + h12 = 12 + return f"{crypto_name}-up-or-down-{month}-{day}-{year}-{h12}{ampm}-et" + + +def get_round_token_ids(round_ts: int, slug_prefix: str = "btc-updown-5m", slug_type: str = "") -> dict: + """获取回合的 UP/DOWN token_id""" + if slug_type == "hourly": + slug = _make_hourly_slug(slug_prefix, round_ts) + else: + slug = f"{slug_prefix}-{round_ts}" + try: + r = requests.get( + "https://gamma-api.polymarket.com/events", + params={"slug": slug}, timeout=10, + ) + events = r.json() + if not events or not isinstance(events, list): + return {} + mkt = events[0].get("markets", [{}])[0] + tokens = mkt.get("clobTokenIds", "[]") + outcomes = mkt.get("outcomes", "[]") + if isinstance(tokens, str): + tokens = json.loads(tokens) + if isinstance(outcomes, str): + outcomes = json.loads(outcomes) + result = {} + for i, o in enumerate(outcomes): + if i < len(tokens): + result[o.lower()] = tokens[i] + return result + except Exception: + return {} + + +def _robust_get_json(url, timeout=5, headers=None) -> dict: + """ + 发送 GET 请求并解析为 JSON。 + 首先尝试使用 requests.get,若失败或遭遇非 200 状态码, + 则自动 fallback 使用系统 curl 命令抓取以绕过 Cloudflare WAF 拦截。 + """ + try: + r = requests.get(url, timeout=timeout, verify=False, headers=headers) + if r.status_code == 200: + return r.json() + except Exception: + pass + + # Fallback: 使用系统 curl + import subprocess, json + try: + headers_list = [] + if headers: + for k, v in headers.items(): + headers_list.extend(["-H", f"{k}: {v}"]) + cmd = ["curl", "-s"] + headers_list + [url] + res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + if res.returncode == 0: + return json.loads(res.stdout.strip()) + except Exception: + pass + return None + + +def get_strike_price(round_ts: int, duration: int = 300, symbol: str = "BTC", variant: str = "fiveminute") -> tuple: + """获取本回合的锚定价。 + 返回 (price, confirmed): confirmed=True 表示数据已稳定可缓存。 + 注: 统一用 variant=fiveminute, 因为 fifteenminute/onehour variant + 返回的是整点 openPrice 而非该回合真实起始价。 + """ + try: + start = datetime.fromtimestamp(round_ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + end = datetime.fromtimestamp(round_ts + duration, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + url = (f"https://polymarket.com/api/crypto/crypto-price?symbol={symbol}" + f"&eventStartTime={start}&variant=fiveminute&endDate={end}") + data = _robust_get_json(url, timeout=5, headers={"User-Agent": "Mozilla/5.0"}) + if data: + op = data.get("openPrice") + incomplete = data.get("incomplete", False) + if op is not None and float(op) > 0: + # incomplete=True 表示数据可能不准, 需要继续重试 + return float(op), not incomplete + except Exception: + pass + return 0, False + + +# ── Token 余额查询 ─────────────────────────────────────────────────────────── +_ctf_abi = json.loads( + '[{"inputs":[{"name":"account","type":"address"},{"name":"id","type":"uint256"}],' + '"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],' + '"type":"function","constant":true}]' +) + +def get_token_balance(w3, token_id: str) -> float: + """查询当前账号 CTF token 持仓 (shares)""" + try: + funder = _current_account()["funder"] + ctf = w3.eth.contract(address=w3.to_checksum_address(CTF_ADDR), abi=_ctf_abi) + bal = ctf.functions.balanceOf(w3.to_checksum_address(funder), int(token_id)).call() + return bal / 1e6 + except Exception: + return 0 + + +# ══════════════════════════════════════════════════════════════════════════════ +# 交易执行 +# ══════════════════════════════════════════════════════════════════════════════ + +def execute_buy(side: str, amount_usd: float) -> dict: + """市价买入 (FOK)""" + ts = time.time() + with state_lock: + token_id = state["token_ids"].get(side) + ask = state.get(f"{side}_ask", 0) + bid = state.get(f"{side}_price", 0) + + if not token_id: + return {"success": False, "message": "回合未就绪,无 token"} + + price = ask if ask > 0 else bid + if price <= 0: + return {"success": False, "message": "无可用盘口价格"} + + try: + client = get_clob_client() + from py_clob_client_v2.clob_types import MarketOrderArgs, OrderType + from py_clob_client_v2.order_builder.constants import BUY + + buy_price = round(min(price + BUY_SLIPPAGE, 0.99), 2) + + args = MarketOrderArgs( + token_id=token_id, + amount=round(amount_usd, 2), + price=buy_price, + side=BUY, + order_type=OrderType.FOK, + builder_code=POLY_BUILDER_CODE, + ) + + signed = client.create_market_order(args) + resp = client.post_order(signed, OrderType.FOK) + + elapsed_ms = int((time.time() - ts) * 1000) + order_id = resp.get("orderID", resp.get("id", "")) + shares = round(amount_usd / price, 1) + + msg = f"BUY {side.upper()} {shares}sh @ {price:.2f} (${amount_usd})" + _log_trade(True, msg, elapsed_ms) + return {"success": True, "message": msg, "order_id": order_id, "elapsed_ms": elapsed_ms} + + except Exception as e: + elapsed_ms = int((time.time() - ts) * 1000) + msg = f"BUY {side.upper()} 失败: {str(e)[:100]}" + _log_trade(False, msg, elapsed_ms) + return {"success": False, "message": msg, "elapsed_ms": elapsed_ms} + + +def execute_sell(side: str, amount) -> dict: + """市价卖出 (FOK). amount='all' 或 USD 金额.""" + ts = time.time() + with state_lock: + token_id = state["token_ids"].get(side) + bid = state.get(f"{side}_price", 0) + position = state.get(f"{side}_position", 0) + + if not token_id: + return {"success": False, "message": "回合未就绪,无 token"} + if position <= 0.01: + return {"success": False, "message": "无持仓可卖"} + + # 确定卖出份额 + if amount == "all": + # 从链上读取最新余额,避免缓存不准 + w3 = get_w3() + actual = get_token_balance(w3, token_id) + shares = math.floor(actual * 100) / 100 + else: + usd = float(amount) + price = bid if bid > 0 else 0.5 + shares = usd / price + shares = min(shares, position) + shares = math.floor(shares * 100) / 100 + + if shares <= 0: + return {"success": False, "message": "可卖份额为 0"} + + try: + client = get_clob_client() + from py_clob_client_v2.clob_types import MarketOrderArgs, OrderType + from py_clob_client_v2.order_builder.constants import SELL + + sell_price = round(max((bid if bid > 0 else 0.5) - SELL_SLIPPAGE, 0.01), 2) + + args = MarketOrderArgs( + token_id=token_id, + amount=round(shares, 2), + price=sell_price, + side=SELL, + order_type=OrderType.FOK, + builder_code=POLY_BUILDER_CODE, + ) + + signed = client.create_market_order(args) + resp = client.post_order(signed, OrderType.FOK) + + elapsed_ms = int((time.time() - ts) * 1000) + order_id = resp.get("orderID", resp.get("id", "")) + + label = "ALL " if amount == "all" else "" + msg = f"SELL {label}{side.upper()} {shares:.1f}sh @ {sell_price:.2f}" + _log_trade(True, msg, elapsed_ms) + return {"success": True, "message": msg, "order_id": order_id, "elapsed_ms": elapsed_ms} + + except Exception as e: + elapsed_ms = int((time.time() - ts) * 1000) + msg = f"SELL {side.upper()} 失败: {str(e)[:100]}" + _log_trade(False, msg, elapsed_ms) + return {"success": False, "message": msg, "elapsed_ms": elapsed_ms} + + +def _log_trade(success: bool, message: str, elapsed_ms: int): + """添加交易记录""" + trade_log.appendleft({ + "time": datetime.now().strftime("%H:%M:%S"), + "success": success, + "message": message, + "elapsed_ms": elapsed_ms, + }) + + +def handle_trade(data: dict) -> dict: + """统一交易入口""" + action = data.get("action", "") + side = data.get("side", "") + amount = data.get("amount", 20) + + # cancel_all 不需要 side + if action == "cancel_all_orders": + return execute_cancel_all() + + if side not in ("up", "down"): + return {"success": False, "message": "无效方向"} + + if action == "buy": + return execute_buy(side, float(amount)) + elif action == "sell": + return execute_sell(side, amount) + elif action == "sell_all": + return execute_sell(side, "all") + elif action in ("limit_buy", "limit_sell"): + limit_price = data.get("price", 0) + if not limit_price or float(limit_price) <= 0: + return {"success": False, "message": "请输入限价"} + return execute_limit_order( + side=side, + buy_or_sell="buy" if action == "limit_buy" else "sell", + limit_price=float(limit_price), + amount_usd=float(amount), + ) + else: + return {"success": False, "message": f"未知操作: {action}"} + + +def execute_cancel_all() -> dict: + """取消所有挂单""" + ts = time.time() + try: + client = get_clob_client() + resp = client.cancel_all() + elapsed_ms = int((time.time() - ts) * 1000) + # resp 通常返回 {"canceled": [...], "not_canceled": [...]} + canceled = resp.get("canceled", []) if isinstance(resp, dict) else [] + count = len(canceled) if canceled else 0 + msg = f"已取消 {count} 笔挂单" + _log_trade(True, msg, elapsed_ms) + return {"success": True, "message": msg, "elapsed_ms": elapsed_ms} + except Exception as e: + elapsed_ms = int((time.time() - ts) * 1000) + msg = f"取消挂单失败: {str(e)[:100]}" + _log_trade(False, msg, elapsed_ms) + return {"success": False, "message": msg, "elapsed_ms": elapsed_ms} + + +def execute_limit_order(side: str, buy_or_sell: str, limit_price: float, amount_usd: float) -> dict: + """GTC 限价单""" + ts = time.time() + with state_lock: + token_id = state["token_ids"].get(side) + + if not token_id: + return {"success": False, "message": "回合未就绪,无 token"} + + limit_price = round(limit_price, 2) + if limit_price <= 0 or limit_price >= 1: + return {"success": False, "message": f"价格 {limit_price} 无效 (需 0.01-0.99)"} + + # USD → shares + shares = round(amount_usd / limit_price, 2) + if shares <= 0: + return {"success": False, "message": "份额为 0"} + + try: + client = get_clob_client() + from py_clob_client_v2.clob_types import OrderArgs, OrderType + from py_clob_client_v2.order_builder.constants import BUY, SELL + + args = OrderArgs( + token_id=token_id, + price=limit_price, + size=shares, + side=BUY if buy_or_sell == "buy" else SELL, + builder_code=POLY_BUILDER_CODE, + ) + + signed = client.create_order(args) + resp = client.post_order(signed, OrderType.GTC) + + elapsed_ms = int((time.time() - ts) * 1000) + order_id = resp.get("orderID", resp.get("id", "")) + + label = "BUY" if buy_or_sell == "buy" else "SELL" + msg = f"LIMIT {label} {side.upper()} {shares:.1f}sh @ {limit_price:.2f} (${amount_usd})" + _log_trade(True, msg, elapsed_ms) + return {"success": True, "message": msg, "order_id": order_id, "elapsed_ms": elapsed_ms} + + except Exception as e: + elapsed_ms = int((time.time() - ts) * 1000) + label = "BUY" if buy_or_sell == "buy" else "SELL" + msg = f"LIMIT {label} {side.upper()} 失败: {str(e)[:100]}" + _log_trade(False, msg, elapsed_ms) + return {"success": False, "message": msg, "elapsed_ms": elapsed_ms} + + +# ══════════════════════════════════════════════════════════════════════════════ +# 后台工作线程 +# ══════════════════════════════════════════════════════════════════════════════ + +def state_worker(): + """后台线程: 回合管理、锚定价、持仓、余额 (每 3 秒, 慢速任务)""" + import urllib3 + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + last_round = 0 + strike_confirmed_round = 0 + last_market_key = "" + + while True: + try: + with active_market_lock: + mkt_key = active_market + mkt = MARKETS[mkt_key] + duration = mkt["duration"] + + # 市场切换 → 强制刷新 + if mkt_key != last_market_key: + last_round = 0 + strike_confirmed_round = 0 + last_market_key = mkt_key + + # 更新 RTDS → state.crypto_current + rtds_key = mkt["rtds"] + with state_lock: + state["crypto_current"] = _rtds_prices.get(rtds_key, 0.0) + + now = int(time.time()) + round_ts = now - (now % duration) + + # ── 新回合检测 ── + if round_ts != last_round: + token_ids = get_round_token_ids(round_ts, mkt["slug_prefix"], mkt.get("slug_type", "")) + if token_ids: + with state_lock: + state["round_ts"] = round_ts + state["round_end"] = round_ts + duration + state["token_ids"] = token_ids + state["up_position"] = 0 + state["down_position"] = 0 + state["strike"] = 0 + last_round = round_ts + rt = datetime.fromtimestamp(round_ts).strftime("%H:%M:%S") + print(f"🔄 [{mkt['label']}] 新回合 {rt} | 等待锚定价...") + + # ── 锚定价 (持续重试) ── + with state_lock: + current_round = state["round_ts"] + + strike_pending = False + if current_round > 0 and strike_confirmed_round != current_round: + strike_pending = True + strike, confirmed = get_strike_price(current_round, duration, mkt["symbol"], mkt["variant"]) + if strike > 0: + with state_lock: + state["strike"] = strike + if confirmed: + strike_confirmed_round = current_round + rt = datetime.fromtimestamp(current_round).strftime("%H:%M:%S") + print(f"📌 [{mkt['label']}] 回合 {rt} 锚定价: ${strike:,.2f}") + strike_pending = False + elif not hasattr(state_worker, '_strike_notified') or state_worker._strike_notified != current_round: + state_worker._strike_notified = current_round + rt = datetime.fromtimestamp(current_round).strftime("%H:%M:%S") + print(f"⏳ [{mkt['label']}] 回合 {rt} 锚定价(未确认): ${strike:,.2f}") + + + # ── 资产查询 (pUSD 和持仓, 走 CLOB API 不消耗 Alchemy CU, 每5秒刷新) ── + with state_lock: + tids = dict(state["token_ids"]) + acct = _current_account() + _now = time.time() + if not hasattr(state_worker, '_last_bal_t'): + state_worker._last_bal_t = 0 + state_worker._last_bal_idx = -1 + # 切换账号后立即刷新余额 + with _account_lock: + cur_idx = _active_account_idx + acct_changed = (cur_idx != getattr(state_worker, '_last_bal_idx', -1)) + if acct["private_key"] and acct["funder"] and (acct_changed or _now - state_worker._last_bal_t >= 5): + state_worker._last_bal_t = _now + state_worker._last_bal_idx = cur_idx + try: + from py_clob_client_v2.clob_types import BalanceAllowanceParams, AssetType + client = get_clob_client() + # 1. 查询 pUSD 余额 + data = client.get_balance_allowance( + BalanceAllowanceParams(asset_type=AssetType.COLLATERAL) + ) + if isinstance(data, dict): + b = data.get("balance", 0) + if not b: + b = data.get("collateral", {}).get("balance", 0) + with state_lock: + state["usdc_balance"] = float(b) / 1e6 if b else 0 + + # 2. 查询持仓 (Token ID) + for side in ("up", "down"): + tid = tids.get(side) + if tid: + c_data = client.get_balance_allowance( + BalanceAllowanceParams(asset_type=AssetType.CONDITIONAL, token_id=str(tid)) + ) + if isinstance(c_data, dict): + cb = c_data.get("balance", 0) + with state_lock: + state[f"{side}_position"] = float(cb) / 1e6 if cb else 0 + except Exception: + pass + + except Exception as e: + print(f"[state_worker] 错误: {e}") + + # 锚定价待确认时加快轮询 + time.sleep(1 if strike_pending else 3) + + +def clob_price_worker(): + """后台线程: 高频 CLOB 盘口价轮询 (每秒, 独立线程)""" + session = requests.Session() + session.headers.update({"Accept": "application/json"}) + + while True: + try: + with state_lock: + tids = dict(state["token_ids"]) + + for side in ("up", "down"): + tid = tids.get(side) + if not tid: + continue + try: + r = session.get( + f"{CLOB_HTTP}/book", + params={"token_id": tid}, + timeout=2, + ) + if r.status_code == 200: + book = r.json() + bids = book.get("bids", []) + asks = book.get("asks", []) + best_bid = max((float(b["price"]) for b in bids), default=0) if bids else 0 + best_ask = min((float(a["price"]) for a in asks), default=0) if asks else 0 + # Top-5 orderbook + sorted_bids = sorted(bids, key=lambda b: float(b["price"]), reverse=True)[:10] + sorted_asks = sorted(asks, key=lambda a: float(a["price"]))[:10] + top_bids = [{"p": float(b["price"]), "s": float(b["size"])} for b in sorted_bids] + top_asks = [{"p": float(a["price"]), "s": float(a["size"])} for a in sorted_asks] + with state_lock: + state[f"{side}_price"] = best_bid + state[f"{side}_ask"] = best_ask + state[f"{side}_book"] = {"bids": top_bids, "asks": top_asks} + except Exception: + pass + except Exception: + pass + + time.sleep(0.1) + + +# 嵌入模式标志 (由 onchain_leaderboard 设置) +_embedded = False + + + +# ══════════════════════════════════════════════════════════════════════════════ +# aiohttp Web 应用 +# ══════════════════════════════════════════════════════════════════════════════ + +app = web.Application() + + +async def index(request): + return web.Response(text=HTML_TEMPLATE, content_type="text/html", charset="utf-8") + + +async def trade_api(request): + data = await request.json() + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, handle_trade, data) + return web.json_response(result) + + +async def switch_market_api(request): + global active_market + data = await request.json() + mkt_key = data.get("market", "") + if mkt_key not in MARKETS: + return web.json_response({"success": False, "message": f"未知市场: {mkt_key}"}) + with active_market_lock: + active_market = mkt_key + # 清空 state 让 worker 立即刷新 + with state_lock: + state["round_ts"] = 0 + state["round_end"] = 0 + state["token_ids"] = {} + state["strike"] = 0 + state["up_price"] = 0 + state["down_price"] = 0 + state["up_ask"] = 0 + state["down_ask"] = 0 + state["up_position"] = 0 + state["down_position"] = 0 + label = MARKETS[mkt_key]["label"] + print(f"🔀 切换市场 → {label}") + return web.json_response({"success": True, "message": f"已切换到 {label}"}) + + +async def switch_account_api(request): + global _active_account_idx + data = await request.json() + idx = data.get("index", 0) + if idx < 0 or idx >= len(ACCOUNTS): + return web.json_response({"success": False, "message": f"无效账号索引: {idx}"}) + with _account_lock: + _active_account_idx = idx + acct = ACCOUNTS[idx] + # 同步 relayer 环境变量 + if acct.get("relayer_key"): + os.environ["RELAYER_API_KEY"] = acct["relayer_key"] + if acct.get("relayer_addr"): + os.environ["RELAYER_API_KEY_ADDRESS"] = acct["relayer_addr"] + # 清空持仓 & 余额 (让 worker 重新查询) + with state_lock: + state["up_position"] = 0 + state["down_position"] = 0 + state["usdc_balance"] = 0 + print(f"👛 切换账号 → [{acct['label']}]") + # 后台初始化新账号的 CLOB client + def _init(): + try: + get_clob_client() + except Exception as e: + print(f" ❌ CLOB 初始化失败: {e}") + threading.Thread(target=_init, daemon=True).start() + return web.json_response({"success": True, "message": f"已切换到 {acct['label']}"}) + + +async def ws_handler(request): + ws = web.WebSocketResponse() + await ws.prepare(request) + try: + while not ws.closed: + with state_lock: + data = dict(state) + with active_market_lock: + data["active_market"] = active_market + data["market_label"] = MARKETS.get(active_market, {}).get("label", "") + data["trades"] = list(trade_log) + data["server_time"] = time.time() + # 账号信息 + with _account_lock: + data["active_account"] = _active_account_idx + data["accounts"] = [a["label"] for a in ACCOUNTS] + await ws.send_json(data) + await asyncio.sleep(0.1) + except Exception: + pass + return ws + + +app.router.add_get("/", index) +app.router.add_post("/api/trade", trade_api) +app.router.add_post("/api/switch_market", switch_market_api) +app.router.add_post("/api/switch_account", switch_account_api) +app.router.add_get("/ws", ws_handler) + + + +# ══════════════════════════════════════════════════════════════════════════════ +# HTML 模板 (嵌入式, 单文件部署) +# ══════════════════════════════════════════════════════════════════════════════ + +HTML_TEMPLATE = r""" + + + + +⚡ Quick Trade + + + + + +
+ + + + + + +
+ + + + + + + + 👛 + + +
+ + +
+ +
+
+
+ 📊 Order Book (10档) + +
+
+
+
🟢 UP价格 / 数量
+
+
+
+
+
+
🔴 DOWN价格 / 数量
+
+
+
+
+
+
+
+ + +
+ +
+
+
+
📌 锚定价
+
--
+
+
+
💰 BTC 现价
+
--
+
+
+
--
+
+
+
+
+
⏱ 倒计时
+
--:--
+
+
+
💵 pUSD 余额
+
--
+
+
+
+ + +
+ +
+
+ 🟢 UP 涨 + -- +
+
+ 持仓: 0 + +
+ +
+ + +
+
+ + +
+
+ 🔴 DOWN 跌 + -- +
+
+ 持仓: 0 + +
+ +
+ + +
+
+
+ + +
+ 金额 +
+ + + + + +
+ +
+ + +
+ 限价 + + + + + + +
+ +
+
+ + + + + +
+
📋 交易记录
+
+
暂无交易记录 — 按 U / D 快速下单
+
+
+
+ + + + +""" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 主入口 +# ══════════════════════════════════════════════════════════════════════════════ + +def _start_workers(): + """启动后台工作线程""" + threading.Thread(target=state_worker, daemon=True).start() + threading.Thread(target=clob_price_worker, daemon=True).start() + if ACCOUNTS: + def _init(): + try: + get_clob_client() + except Exception as e: + print(f" ❌ CLOB 初始化失败: {e}") + threading.Thread(target=_init, daemon=True).start() + + +def start_embedded(): + """嵌入模式: 由 onchain_leaderboard 调用, 在后台线程启动 web 服务""" + global _embedded + _embedded = True + _start_workers() + + async def _run_server(): + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "0.0.0.0", PORT) + await site.start() + print(f" ⚡ Quick Trade: http://localhost:{PORT}") + # keep running forever + while True: + await asyncio.sleep(3600) + + def _thread(): + asyncio.run(_run_server()) + + threading.Thread(target=_thread, daemon=True).start() + + +def main(): + print("=" * 55) + print(" ⚡ Quick Trade Panel (多市场/多账号)") + print("=" * 55) + + if not ACCOUNTS: + print(" ⚠️ QUICK_PRIVATE_KEY / QUICK_FUNDER 未配置") + print(" 请在 .env 中添加后重启") + print() + else: + for i, a in enumerate(ACCOUNTS): + tag = " ← 当前" if i == 0 else "" + print(f" 👛 账号{i+1}: {a['label']}{tag}") + print() + + _start_workers() + + # 延迟打开浏览器 + threading.Timer(1.5, lambda: webbrowser.open(f"http://localhost:{PORT}")).start() + + print(f" 🌐 http://localhost:{PORT}") + print(f" 快捷键: ↑=买UP ↓=买DOWN Q=卖UP W=卖DOWN") + print(f" 金额键: 1=$5 2=$10 3=$20 4=$50 5=$100") + print() + + web.run_app(app, port=PORT, print=None) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a7c3eb9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +aiohttp==3.9.5 +web3==6.19.0 +requests==2.32.3 +python-dotenv==1.0.1 +websockets==12.0 +pyclob-polymarket==1.0.14 diff --git a/tracked_whales_list.txt b/tracked_whales_list.txt new file mode 100644 index 0000000..7daa69d --- /dev/null +++ b/tracked_whales_list.txt @@ -0,0 +1 @@ +0x0000000000000000000000000000000000000000 diff --git a/username_cache.json b/username_cache.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/username_cache.json @@ -0,0 +1 @@ +{}