diff --git a/README.md b/README.md index dd647c4..9139228 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,8 @@ python3 tools/stackchan_kalshi_watch.py \ Deploy `config/espn_player_catalog.json` with the watcher checkout. For exact upgrade boundaries, see the bilingual -[Matchday MOD 1.6.0 notes](docs/releases/1.6.0.md), +[Matchday MOD 1.7.0 multi-venue notes](docs/releases/1.7.0.md), +[1.6.0 notes](docs/releases/1.6.0.md), [1.5.0 notes](docs/releases/1.5.0.md), and [1.4.0 commentary-style notes](docs/releases/1.4.0.md). @@ -151,7 +152,8 @@ The currently documented device release is [Matchday Mod 1.5.0](docs/releases/1. | [Development](docs/development.md) | Repository map, tests, archive builds, and replay tooling | | [Host preparation](host/README.md) | Partition and optional CJK-font patches | | [Commentary styles PRD](docs/commentary-styles-prd.md) | Product rules and implementation contract | -| [Release notes](docs/releases/1.6.0.md) | Version-specific upgrade boundaries | +| [Release notes](docs/releases/1.7.0.md) | Version-specific upgrade boundaries | +| [Venue adapter contract](docs/venue-adapter-api.zh-CN.md) | Normalized quotes and multi-venue aggregation rules | | [GitHub Wiki](https://github.com/xymeow/stackchan-matchday/wiki) | Human- and agent-friendly navigation, FAQ, and field debugging | English guides link to their Chinese counterparts at the top of each page. diff --git a/README.zh-CN.md b/README.zh-CN.md index 40e5edf..34f72c5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -120,7 +120,8 @@ python3 tools/stackchan_kalshi_watch.py \ 示例 ticker 是占位值;从手机设置页选择真实比赛后,watcher 会校验 ESPN 与 Kalshi 的双方匹配并原子更新配置。旧版升级前请查看 -[Matchday Mod 1.6.0 说明](docs/releases/1.6.0.md)及[全部版本说明](docs/releases/); +[Matchday Mod 1.7.0 多盘口说明](docs/releases/1.7.0.md)、 +[1.6.0 说明](docs/releases/1.6.0.md)及[全部版本说明](docs/releases/); 设备手机页的防剧透开关需要更新 watcher 与 Mod,但无需重刷官方 host。 日常启动不需要 `git pull`、重新克隆或刷机。需要语音时,再启动 [安装与升级](docs/getting-started.zh-CN.md)中说明的可选局域网 TTS 服务。 @@ -156,6 +157,7 @@ watcher。完整、可复现的命令见[安装与升级](docs/getting-started.z | 调用设备命令、状态与 Match Setup 接口 | [设备 API](docs/device-api.zh-CN.md) | | 运行测试、构建归档和回放比赛 | [开发指南](docs/development.zh-CN.md) | | 理解三档语气的产品规则 | [播报语气 PRD](docs/commentary-styles-prd.md) | +| 多品类、多盘口的演进方向 | [多品类·多盘口 PRD](docs/multi-venue-roadmap-prd.zh-CN.md)、[VenueAdapter 契约](docs/venue-adapter-api.zh-CN.md) | | 查看版本差异与升级边界 | [版本说明](docs/releases/) | | 处理联网、二维码、TTS、xsbug 等常见问题 | [排障与 FAQ(Wiki)](https://github.com/xymeow/stackchan-matchday/wiki/Troubleshooting) | diff --git a/agent-skills/market-pairing/SKILL.md b/agent-skills/market-pairing/SKILL.md new file mode 100644 index 0000000..ee92ea4 --- /dev/null +++ b/agent-skills/market-pairing/SKILL.md @@ -0,0 +1,136 @@ +--- +name: market-pairing +description: 为 stackchan-matchday 发现并配对跨平台盘口:拉取 Kalshi、Polymarket、ESPN 的候选事件,模糊匹配出"现实中的同一场比赛/事件",生成 config/pairing_registry.json 的配对提议供人工确认。当用户说"配对"、"找盘口"、"发现比赛"、"帮我配下周的 MLB/英超/NBA"、"这场比赛 Kalshi/Polymarket 上有没有盘"、"pairing"、"更新 registry",或提到把某场赛事和预测市场对应起来时,使用本 skill。 +--- + +# Market Pairing(盘口配对助手) + +把三类数据源中"现实中的同一件事"对应起来,产出配对注册表提议: + +- Kalshi:结构化 event/market ticker(如 `KXWCADVANCE-26JUL15ENGARG-ENG`) +- Polymarket:自由文本标题 + outcome token(Gamma API) +- ESPN:赛程 event id(可选;非体育品类可以没有赛事源) + +架构背景见 `docs/multi-venue-roadmap-prd.zh-CN.md` 第 4.3 与第 5 节。 +核心原则:**本 skill 只提议,不确认**。watcher 只消费 `confirmed: true` +的条目,而把 `confirmed` 置真的动作必须来自用户明确指示。 + +## 工作流程 + +### 1. 明确范围 + +从用户请求中确定:品类(mlb / tennis / epl / ucl / nba / nfl / 政治等)、 +日期窗口(默认未来 7 天)、是否需要 ESPN 赛事源(政治、选秀等品类跳过)。 + +### 2. 拉取候选(用脚本,不要手写请求) + +```sh +python3 scripts/fetch_pairing_candidates.py --source kalshi --days 7 --query "yankees" +python3 scripts/fetch_pairing_candidates.py --source polymarket --tag mlb --days 7 +python3 scripts/fetch_pairing_candidates.py --source espn --espn-league baseball/mlb --days 7 +``` + +脚本输出裁剪过的紧凑 JSON(标题、时间、结果、量价字段),避免把原始 +API 响应灌进上下文。常用参数速查: + +| 品类 | --espn-league | Polymarket --tag | Kalshi 建议 | +| --- | --- | --- | --- | +| MLB | `baseball/mlb` | `mlb` | `--query` 队名 | +| 网球 ATP/WTA | `tennis/atp` / `tennis/wta` | `tennis` | `--query` 球员姓 | +| 英超 | `soccer/eng.1` | `epl` | `--query` 队名 | +| 欧冠 | `soccer/uefa.champions` | `champions-league` | `--query` 队名 | +| NBA | `basketball/nba` | `nba` | `--query` 队名 | +| NFL | `football/nfl` | `nfl` | `--query` 队名 | + +Kalshi 的 series ticker 命名随品类扩张变化,不要凭记忆硬编码; +不确定时先 `--source kalshi --list-series --query <运动名>` 查当前 +开放的 series,再用 `--kalshi-series` 精确过滤(同一运动会有几十个 +series:单场胜负、季后赛、总冠军、球员数据盘等,配对陪看用的是 +单场胜负类)。 + +已验证的数据坑(2026-07 实测): + +- Kalshi 事件标题用**城市名**不用队名("New York M vs Philadelphia", + 没有 "Yankees"/"Mets"),`--query` 要用城市名或缩写;`sub_title` + 含缩写和日期("NYM vs PHI (Jul 16)")更可靠。 +- Kalshi `event_ticker` 内嵌日期、时间与两队缩写 + (`KXMLBGAME-26JUL161910NYMPHI`),是最强的匹配信号,优先解析它。 +- Kalshi 的 `close_time` 可能比开赛晚数天(7/16 的比赛 7/19 收盘), + 与 Polymarket `endDate` 同理,都不能当开赛时间用。 +- Kalshi 嵌套 markets 视图里 `volume`/`yes_bid` 可能为 null, + 需要量价证据时按单个 ticker 再查 `/markets/`。 +- Polymarket 的 `endDate` 经常晚于实际开赛时间(结算缓冲或系列赛 + 打包),脚本已对时间窗放宽 +3 天;比赛时间以标题和 ESPN 对照为准, + 不要拿 endDate 当开赛时间写进 registry。 +- Polymarket 无 `--tag` 时,`--query` 只能在 24h 成交量前 100 的 + 事件里搜,冷门比赛会漏——先用 `--tag` 缩小品类再查;同一场比赛 + 会同时存在胜负盘和 "Player Props" 等衍生事件,配对只取胜负盘。 + +### 3. 匹配 + +对每个候选组合按以下规则打分,全部通过才算高置信: + +- **参赛方一致**:队名/人名规范化后匹配。注意别名——ESPN 用 + "New York Yankees"/"NYY",Polymarket 标题可能只写 "Yankees", + Kalshi ticker 用缩写。城市名 + 队名任一匹配即可,但同城多队 + (Yankees/Mets、Lakers/Clippers)必须靠队名区分。 +- **时间一致**:开赛时间容差 ±2 小时(跨时区、推迟常见); + 盘口收盘时间应晚于开赛时间且在赛期内。 +- **结果结构一致**:两平台的 outcome 数量与语义对得上 + (胜负盘对胜负盘,别把"晋级盘"配给"单场胜负盘")。 +- **量价合理**:目标市场 status 为 open 且有流动性;已结算或 + 空订单簿的候选降级列出。 + +网球注意:同一轮次同名选手极少但存在(如同姓兄弟),用比赛时间 +和赛事名双重确认。政治/选秀等多路盘:outcomes 按候选人列全, +无 event_source 属正常。 + +### 4. 写入提议 + +追加或更新 `config/pairing_registry.json`(只碰这个文件,不改 +`kalshi_watchlist.json` 等其他配置)。条目格式: + +```json +{ + "id": "mlb-2026-07-21-NYY-BOS", + "category": "mlb", + "label": { "zh": "扬基 vs 红袜", "en": "Yankees vs Red Sox" }, + "starts_at": "2026-07-21T23:10:00+00:00", + "outcomes": ["NYY", "BOS"], + "event_source": { "provider": "espn", "league": "baseball/mlb", "event_id": "401234567" }, + "venue_markets": [ + { "venue": "kalshi", "event_ticker": "…", "outcome_map": { "NYY": "…-NYY", "BOS": "…-BOS" } }, + { "venue": "polymarket", "event_id": "…", "outcome_map": { "NYY": "", "BOS": "" } } + ], + "pairing": { + "proposed_by": "agent", + "confidence": 0.97, + "evidence": "队名、开赛时间(±10min)、收盘时间三项一致", + "confirmed": false + } +} +``` + +要求: + +- `confirmed` 一律写 `false`。用户明确说"确认第 N 条"后才改真。 +- `id` 规范:`--<按字母序的参赛方缩写>`; + 更新既有条目时按 `id` 合并,不产生重复。 +- `evidence` 写人能读懂的匹配依据,方便用户扫一眼就决定。 +- 置信度低于 0.8 的组合不写入文件,在回复中单独列出并说明疑点。 +- 写文件前先读旧文件,保留已 `confirmed` 的条目原样不动。 + +### 5. 汇报 + +用简短表格向用户汇报:每条提议的对阵/事件、时间、两平台市场、 +置信度、疑点。结尾询问确认哪些条目。不要替用户做交易判断, +不要评价哪边"值得买"——本项目是只读陪看工具。 + +## 边界 + +- 任何平台请求失败:跳过该平台继续(单平台配对也有价值), + 在汇报中注明缺了哪个源。 +- 不要为凑数强行配对:宁可"Kalshi 有、Polymarket 没找到", + 也不要把语义不同的盘(让分/大小分/晋级)硬配成胜负盘。 +- 不修改 watcher 代码或其他配置文件;不在 registry 里存 API key + (这些 API 本就无需鉴权)。 diff --git a/agent-skills/market-pairing/scripts/fetch_pairing_candidates.py b/agent-skills/market-pairing/scripts/fetch_pairing_candidates.py new file mode 100644 index 0000000..123e662 --- /dev/null +++ b/agent-skills/market-pairing/scripts/fetch_pairing_candidates.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Fetch trimmed pairing candidates from Kalshi, Polymarket, or ESPN. + +Emits compact JSON on stdout so an agent can match events across venues +without loading raw API responses into context. Standard library only. + +Examples: + fetch_pairing_candidates.py --source kalshi --days 7 --query yankees + fetch_pairing_candidates.py --source kalshi --list-series --query baseball + fetch_pairing_candidates.py --source polymarket --tag mlb --days 7 + fetch_pairing_candidates.py --source espn --espn-league baseball/mlb --days 7 +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.parse +import urllib.request +from datetime import datetime, timedelta, timezone + +KALSHI_BASE = "https://external-api.kalshi.com/trade-api/v2" +POLYMARKET_BASE = "https://gamma-api.polymarket.com" +ESPN_BASE = "https://site.api.espn.com/apis/site/v2/sports" +USER_AGENT = "stackchan-matchday-pairing/1.0" +TIMEOUT_SECONDS = 20 + + +def fetch_json(url: str) -> object: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: + return json.loads(response.read().decode("utf-8")) + + +def parse_iso(value: object) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def within_window(when: datetime | None, now: datetime, days: int) -> bool: + if when is None: + return True # keep candidates with unknown timestamps; agent decides + return now - timedelta(hours=12) <= when <= now + timedelta(days=days) + + +def text_matches(query: str, *fields: object) -> bool: + if not query: + return True + needle = query.casefold() + return any(needle in str(field).casefold() for field in fields if field) + + +# --- Kalshi --------------------------------------------------------------- + + +def kalshi_series(args: argparse.Namespace) -> list[dict]: + url = f"{args.kalshi_base}/series?category=" + urllib.parse.quote( + args.kalshi_category + ) + payload = fetch_json(url) + out = [] + for series in payload.get("series", []): + if not text_matches(args.query, series.get("ticker"), series.get("title"), + series.get("category")): + continue + out.append({ + "series_ticker": series.get("ticker"), + "title": series.get("title"), + "category": series.get("category"), + "frequency": series.get("frequency"), + }) + return out + + +def kalshi_events(args: argparse.Namespace, now: datetime) -> list[dict]: + candidates: list[dict] = [] + cursor = "" + pages = 0 + while pages < args.max_pages: + params = {"status": "open", "limit": "200", "with_nested_markets": "true"} + if args.kalshi_series: + params["series_ticker"] = args.kalshi_series + if cursor: + params["cursor"] = cursor + url = f"{args.kalshi_base}/events?" + urllib.parse.urlencode(params) + payload = fetch_json(url) + for event in payload.get("events", []): + markets = event.get("markets") or [] + close_times = [parse_iso(m.get("close_time")) for m in markets] + close_times = [t for t in close_times if t is not None] + soonest_close = min(close_times) if close_times else None + if not within_window(soonest_close, now, args.days): + continue + if not text_matches(args.query, event.get("event_ticker"), + event.get("title"), event.get("sub_title")): + continue + trimmed_markets = [] + for market in markets[: args.max_markets_per_event]: + trimmed_markets.append({ + "ticker": market.get("ticker"), + "yes_sub_title": market.get("yes_sub_title") + or market.get("subtitle") or market.get("title"), + "status": market.get("status"), + "close_time": market.get("close_time"), + "volume": market.get("volume"), + "open_interest": market.get("open_interest"), + "yes_bid": market.get("yes_bid"), + "yes_ask": market.get("yes_ask"), + }) + candidates.append({ + "event_ticker": event.get("event_ticker"), + "series_ticker": event.get("series_ticker"), + "title": event.get("title"), + "sub_title": event.get("sub_title"), + "soonest_close": soonest_close.isoformat() if soonest_close else None, + "markets": trimmed_markets, + }) + cursor = payload.get("cursor") or "" + pages += 1 + if not cursor: + break + candidates.sort(key=lambda c: c.get("soonest_close") or "9999") + return candidates[: args.limit] + + +# --- Polymarket ----------------------------------------------------------- + + +def polymarket_events(args: argparse.Namespace, now: datetime) -> list[dict]: + params = { + "closed": "false", + "limit": "100", # always fetch a full page; text filter happens locally + "order": "volume24hr", + "ascending": "false", + } + if args.tag: + params["tag_slug"] = args.tag + url = f"{POLYMARKET_BASE}/events?" + urllib.parse.urlencode(params) + payload = fetch_json(url) + events = payload if isinstance(payload, list) else payload.get("events", []) + candidates = [] + for event in events: + end_date = parse_iso(event.get("endDate")) + # endDate often trails the actual start time (settlement buffer, + # multi-game bundles), so allow extra slack beyond --days. + if not within_window(end_date, now, args.days + 3): + continue + if not text_matches(args.query, event.get("title"), event.get("slug")): + continue + def decode(value: object) -> object: + # Gamma often returns these fields as JSON-encoded strings. + if isinstance(value, str): + try: + return json.loads(value) + except ValueError: + return value + return value + + trimmed_markets = [] + for market in (event.get("markets") or [])[: args.max_markets_per_event]: + trimmed_markets.append({ + "question": market.get("question"), + "condition_id": market.get("conditionId"), + "outcomes": decode(market.get("outcomes")), + "outcome_prices": decode(market.get("outcomePrices")), + "clob_token_ids": decode(market.get("clobTokenIds")), + "volume": market.get("volume"), + "liquidity": market.get("liquidity"), + "end_date": market.get("endDate"), + "active": market.get("active"), + }) + candidates.append({ + "event_id": event.get("id"), + "slug": event.get("slug"), + "title": event.get("title"), + "end_date": event.get("endDate"), + "volume": event.get("volume"), + "liquidity": event.get("liquidity"), + "markets": trimmed_markets, + }) + return candidates[: args.limit] + + +# --- ESPN ----------------------------------------------------------------- + + +def espn_events(args: argparse.Namespace, now: datetime) -> list[dict]: + if not args.espn_league: + raise SystemExit("--espn-league is required for --source espn " + "(e.g. baseball/mlb, soccer/eng.1, tennis/atp)") + start = now.strftime("%Y%m%d") + end = (now + timedelta(days=args.days)).strftime("%Y%m%d") + url = (f"{ESPN_BASE}/{args.espn_league}/scoreboard?" + + urllib.parse.urlencode({"dates": f"{start}-{end}", "limit": "300"})) + payload = fetch_json(url) + candidates = [] + for event in payload.get("events", []): + if not text_matches(args.query, event.get("name"), event.get("shortName")): + continue + competitors = [] + for competition in (event.get("competitions") or [])[:1]: + for competitor in competition.get("competitors", []): + team = competitor.get("team") or competitor.get("athlete") or {} + competitors.append({ + "home_away": competitor.get("homeAway"), + "abbreviation": team.get("abbreviation"), + "display_name": team.get("displayName") + or team.get("fullName"), + }) + candidates.append({ + "event_id": event.get("id"), + "name": event.get("name"), + "short_name": event.get("shortName"), + "date": event.get("date"), + "competitors": competitors, + }) + candidates.sort(key=lambda c: c.get("date") or "9999") + return candidates[: args.limit] + + +# --- main ----------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", required=True, + choices=["kalshi", "polymarket", "espn"]) + parser.add_argument("--days", type=int, default=7, + help="lookahead window in days (default 7)") + parser.add_argument("--query", default="", + help="case-insensitive text filter on titles/tickers") + parser.add_argument("--limit", type=int, default=25, + help="max candidates in output (default 25)") + parser.add_argument("--max-markets-per-event", type=int, default=8) + parser.add_argument("--kalshi-base", default=KALSHI_BASE) + parser.add_argument("--kalshi-series", default="", + help="filter Kalshi events by series ticker") + parser.add_argument("--kalshi-category", default="Sports", + help="category for --list-series (default Sports)") + parser.add_argument("--list-series", action="store_true", + help="list Kalshi series instead of events") + parser.add_argument("--max-pages", type=int, default=3, + help="max Kalshi pagination pages (200 events each)") + parser.add_argument("--tag", default="", + help="Polymarket tag_slug filter (e.g. mlb, nba, epl)") + parser.add_argument("--espn-league", default="", + help="ESPN sport/league path (e.g. baseball/mlb)") + args = parser.parse_args() + + now = datetime.now(timezone.utc) + try: + if args.source == "kalshi" and args.list_series: + candidates = kalshi_series(args) + elif args.source == "kalshi": + candidates = kalshi_events(args, now) + elif args.source == "polymarket": + candidates = polymarket_events(args, now) + else: + candidates = espn_events(args, now) + except Exception as error: # noqa: BLE001 - agent needs the reason + json.dump({"source": args.source, "error": str(error)}, sys.stdout) + print() + raise SystemExit(1) + + json.dump({ + "source": args.source, + "fetched_at": now.isoformat(), + "count": len(candidates), + "candidates": candidates, + }, sys.stdout, ensure_ascii=False, indent=1) + print() + + +if __name__ == "__main__": + main() diff --git a/config/kalshi_watchlist.example.json b/config/kalshi_watchlist.example.json index 9273208..72bb57e 100644 --- a/config/kalshi_watchlist.example.json +++ b/config/kalshi_watchlist.example.json @@ -31,12 +31,17 @@ "host": "127.0.0.1", "port": 8788, "public_base_url": "", - "kalshi_series_ticker": "KXWCADVANCE", + "kalshi_series_ticker": "KXEPLGAME", "lookahead_days": 10, "refresh_seconds": 900, "prompt_minutes_before": 90, "daily_prompt_hour": 10 }, + "polymarket": { + "enabled": true, + "base_url": "https://gamma-api.polymarket.com", + "poll_seconds": 30 + }, "probability_bar": { "enabled": true, "market_ticker": "KXEXAMPLE-26JUL09-ARG", @@ -44,7 +49,12 @@ "left_flag": "ar", "left_color": "#75AADB", "right_flag": "br", - "right_color": "#009C3B" + "right_color": "#009C3B", + "polymarket": { + "market_id": "", + "left_outcome": "", + "right_outcome": "" + } }, "alert_balloon_seconds": 8, "quiet_hours": { diff --git a/config/pairing_registry.json b/config/pairing_registry.json new file mode 100644 index 0000000..e121292 --- /dev/null +++ b/config/pairing_registry.json @@ -0,0 +1,115 @@ +{ + "canonical_events": [ + { + "id": "mlb-2026-07-20-LAD-PHI", + "category": "mlb", + "label": { "zh": "道奇 vs 费城人", "en": "Dodgers vs Phillies" }, + "starts_at": "2026-07-20T23:10:00+00:00", + "outcomes": ["LAD", "PHI"], + "event_source": { + "provider": "espn", + "league": "baseball/mlb", + "event_id": "401816187" + }, + "venue_markets": [ + { + "venue": "kalshi", + "event_ticker": "KXMLBGAME-26JUL201910LADPHI", + "outcome_map": { + "LAD": "KXMLBGAME-26JUL201910LADPHI-LAD", + "PHI": "KXMLBGAME-26JUL201910LADPHI-PHI" + } + }, + { + "venue": "polymarket", + "event_id": "702860", + "market_id": "2922141", + "outcome_map": { + "LAD": "Los Angeles Dodgers", + "PHI": "Philadelphia Phillies" + } + } + ], + "pairing": { + "proposed_by": "agent", + "confidence": 0.98, + "evidence": "队名一致(LAD/PHI 缩写 vs 全名);开赛时间一致(Kalshi ticker 19:10 ET,ESPN 23:00Z,±10min);两平台报价一致(LAD 44.5% vs 44.5%);均为单场胜负盘、两路结果", + "confirmed": false + } + }, + { + "id": "mlb-2026-07-21-LAD-PHI", + "category": "mlb", + "label": { "zh": "道奇 vs 费城人(第二场)", "en": "Dodgers vs Phillies (game 2)" }, + "starts_at": "2026-07-21T22:40:00+00:00", + "outcomes": ["LAD", "PHI"], + "event_source": { + "provider": "espn", + "league": "baseball/mlb", + "event_id": "401816202" + }, + "venue_markets": [ + { + "venue": "kalshi", + "event_ticker": "KXMLBGAME-26JUL211840LADPHI", + "outcome_map": { + "LAD": "KXMLBGAME-26JUL211840LADPHI-LAD", + "PHI": "KXMLBGAME-26JUL211840LADPHI-PHI" + } + }, + { + "venue": "polymarket", + "event_id": "706490", + "market_id": "2934540", + "outcome_map": { + "LAD": "Los Angeles Dodgers", + "PHI": "Philadelphia Phillies" + } + } + ], + "pairing": { + "proposed_by": "agent", + "confidence": 0.95, + "evidence": "同系列次日场;队名一致;Kalshi ticker 18:40 ET 与 ESPN 22:40Z 一致;胜负盘对胜负盘(slug mlb-lad-phi-2026-07-21 → event 706490 / market 2934540)", + "confirmed": false + } + }, + { + "id": "mlb-2026-07-20-NYY-PIT", + "category": "mlb", + "label": { "zh": "海盗 vs 扬基", "en": "Pirates vs Yankees" }, + "starts_at": "2026-07-20T23:05:00+00:00", + "outcomes": ["PIT", "NYY"], + "event_source": { + "provider": "espn", + "league": "baseball/mlb", + "event_id": "401816189" + }, + "venue_markets": [ + { + "venue": "kalshi", + "event_ticker": "KXMLBGAME-26JUL201905PITNYY", + "outcome_map": { + "PIT": "KXMLBGAME-26JUL201905PITNYY-PIT", + "NYY": "KXMLBGAME-26JUL201905PITNYY-NYY" + } + }, + { + "venue": "polymarket", + "event_id": "702857", + "market_id": "2922135", + "outcome_map": { + "PIT": "Pittsburgh Pirates", + "NYY": "New York Yankees" + } + } + ], + "pairing": { + "proposed_by": "agent", + "confidence": 0.97, + "evidence": "队名一致;开赛时间一致(19:05 ET / 23:05Z);两平台报价一致(NYY 53.5% vs 53.5%);胜负盘对胜负盘", + "confirmed": false + } + } + ] +} diff --git a/docs/configuration.md b/docs/configuration.md index 34c907a..5642dd9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -199,6 +199,42 @@ Setup. The event's four most-traded markets appear in the bottom ticker. Fixture-only flags, probability bar, and ESPN commentary are disabled until a matched fixture is selected again. +## Multi-venue aggregation (Kalshi + Polymarket) + +The probability bar can aggregate the same two outcomes across Kalshi and +Polymarket's read-only Gamma API (no API key on either side). Map the bar's +sides onto one Gamma market: + +```json +{ + "polymarket": {"enabled": true, "poll_seconds": 30}, + "probability_bar": { + "enabled": true, + "mode": "normalized_outcomes", + "market_ticker": "KXMLBGAME-26JUL201910LADPHI-LAD", + "right_market_ticker": "KXMLBGAME-26JUL201910LADPHI-PHI", + "polymarket": { + "market_id": "2922141", + "left_outcome": "Los Angeles Dodgers", + "right_outcome": "Philadelphia Phillies" + } + } +} +``` + +`market_id` is the numeric Gamma market id; the outcome strings must match the +market's `outcomes` labels exactly. The bar then shows a liquidity-weighted +mid across both venues (equal weight when a venue does not report liquidity). +When the venues disagree by 8 points or more, Stack-chan reads out an +informational "venues disagree" note, and a Kalshi suspected-goal signal that +Polymarket confirms within 90 seconds is upgraded to a dual-venue, +high-confidence callout. If Polymarket is unreachable the bar silently falls +back to Kalshi alone. Cross-venue pairings are proposed into +`config/pairing_registry.json` by the market-pairing helper and only ever +consumed after you confirm them; see +[venue-adapter-api.zh-CN.md](venue-adapter-api.zh-CN.md) for the adapter +contract. + ## HTTP and serial transport HTTP is the full workflow and uses Python's standard library. Phone setup, diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 1861afd..38e9900 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -204,3 +204,34 @@ watcher 还可每天提醒扫码选比赛: 没有比赛时,可在设备设置页粘贴 Kalshi event URL 或 ticker。watcher 会选择该事件 中成交最活跃的最多四个市场,在底部 ticker 中显示;比赛专属旗帜概率条和 ESPN 播报暂时关闭。重新选择比赛后恢复比赛模式。 + +## 多盘口源聚合(Kalshi + Polymarket) + +概率条可以把同一场比赛的两路结果在 Kalshi 与 Polymarket(只读 Gamma API, +两边都不需要 API key)之间聚合。把概率条左右两侧映射到一个 Gamma market: + +```json +{ + "polymarket": {"enabled": true, "poll_seconds": 30}, + "probability_bar": { + "enabled": true, + "mode": "normalized_outcomes", + "market_ticker": "KXMLBGAME-26JUL201910LADPHI-LAD", + "right_market_ticker": "KXMLBGAME-26JUL201910LADPHI-PHI", + "polymarket": { + "market_id": "2922141", + "left_outcome": "Los Angeles Dodgers", + "right_outcome": "Philadelphia Phillies" + } + } +} +``` + +`market_id` 是 Gamma 的数字市场 id,outcome 字符串必须与该市场 `outcomes` +标签逐字一致。概率条随后显示两平台的 liquidity 加权 mid(某一侧没有流动性 +数据时退化为等权平均)。两平台分歧达到 8 分时,Stack-chan 会用信息性话术 +播报"平台分歧";Kalshi 的疑似得分信号如果在 90 秒内被 Polymarket 同向跳动 +佐证,会升级为"双平台确认"的高置信播报。Polymarket 拉取失败时概率条静默 +退化为 Kalshi 单源,只记日志不报错。跨平台配对由 market-pairing 助手写入 +`config/pairing_registry.json` 提议,经你确认后才会被消费;适配器契约见 +[venue-adapter-api.zh-CN.md](venue-adapter-api.zh-CN.md)。 diff --git a/docs/development.md b/docs/development.md index 74604db..c3312cb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,12 +9,17 @@ - `host/` — required CoreS3 partition patch, optional CJK-font patch, and font preparation helper. These are build/resource changes; upstream runtime JS/C source remains unchanged. -- `tools/` — watcher, local setup service, macOS TTS server, replay tool, - serial helper, asset generator, and tests. Default HTTP needs only Python's - standard library; serial transport additionally needs `pyserial`. -- `config/` — example watcher configuration, flag-pack definition, and global - ESPN player catalog. +- `tools/` — watcher, venue adapters (`stackchan_venues.py`: Kalshi + + Polymarket normalized quotes and aggregation), local setup service, macOS + TTS server, replay tool, serial helper, asset generator, and tests. Default + HTTP needs only Python's standard library; serial transport additionally + needs `pyserial`. +- `config/` — example watcher configuration, flag-pack definition, global + ESPN player catalog, and the cross-venue pairing registry + (`pairing_registry.json`, agent-proposed and human-confirmed). - `docs/` — versioned user, API, development, product, and release guides. +- `agent-skills/` — repo-local agent skills; `market-pairing` fetches + Kalshi/Polymarket/ESPN candidates and proposes pairing-registry entries. ## Test suites diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index cd37fc3..0d06fd1 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -8,11 +8,12 @@ | 目录 | 责任 | 不应承担的责任 | | --- | --- | --- | -| `tools/` | 数据获取、解析、状态、文案、设置服务、TTS、回放和测试 | 设备 UI 与 host runtime | +| `tools/` | 数据获取、盘口源适配(`stackchan_venues.py`)、解析、状态、文案、设置服务、TTS、回放和测试 | 设备 UI 与 host runtime | | `mod/` | 手机设置中继、设备显示、语音、动作、灯效和资源 | 推断 ESPN 事实或生成专业文案 | | `host/` | CoreS3 分区补丁、可选 CJK 字体补丁与准备脚本 | 修改上游 runtime JS/C 源码 | -| `config/` | 示例 watcher 配置、旗帜定义、全局球员目录 | 运行时秘密或真实账户信息 | +| `config/` | 示例 watcher 配置、旗帜定义、全局球员目录、跨平台配对注册表 | 运行时秘密或真实账户信息 | | `docs/` | 版本化安装、配置、接口、PRD 和升级说明 | 环境相关且易变的排障记录 | +| `agent-skills/` | 仓库内 agent 技能(market-pairing 配对提议) | watcher 运行时依赖 | 构建参数、分区偏移、接口和配置行为必须保存在仓库文档并随代码评审。GitHub Wiki 适合 xsbug、mDNS、防火墙和串口抓日志等环境经验,但不应成为版本绑定事实的唯一来源。 diff --git a/docs/getting-started.md b/docs/getting-started.md index 719fcc8..c8ffdf1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,8 +8,10 @@ building the Matchday mod, starting the watcher, and optionally enabling LAN speech. Already running Matchday? Read the version-specific -[1.6.0](releases/1.6.0.md), [1.5.0](releases/1.5.0.md), and -[1.4.0](releases/1.4.0.md) notes before rebuilding. Spoiler protection on the +[1.7.0](releases/1.7.0.md), [1.6.0](releases/1.6.0.md), +[1.5.0](releases/1.5.0.md), and +[1.4.0](releases/1.4.0.md) notes before rebuilding. The 1.7.0 multi-venue +aggregation is a watcher-only update. Spoiler protection on the device phone page requires both the 1.6.0 watcher and mod, but not a host reflash. The 1.5.0 support/position wording and global player catalog remain watcher-only updates. diff --git a/docs/multi-venue-roadmap-prd.zh-CN.md b/docs/multi-venue-roadmap-prd.zh-CN.md new file mode 100644 index 0000000..f5ed184 --- /dev/null +++ b/docs/multi-venue-roadmap-prd.zh-CN.md @@ -0,0 +1,261 @@ +# Stack-chan Matchday 多品类·多盘口演进路线 PRD + +- 更新日期:2026-07-15 +- 状态:规划中 +- 范围:世界杯结束后的架构演进——品类扩展(MLB、网球、英超、NBA、NFL、 + 政治、选秀)、多盘口源聚合(Kalshi + Polymarket)、AI 辅助配对发现 + +## 1. 背景 + +当前系统把"足球世界杯"和"Kalshi 晋级盘"两个假设写进了核心路径: +`espn.league` 默认 `fifa.world`,赛程发现绑定 `KXWCADVANCE` series, +概率条假设"双方归一化"两路结果。世界杯 7 月 19 日结束后,这套管线 +将失去输入。 + +同时,外部条件已经变化: + +- Kalshi 已覆盖约 17 个运动(NFL、NBA、MLB、网球、英超、欧冠等), + 提供实时 in-play 盘口,平台按 categories → series → events → markets + 四层组织,`/series` 与 `/events` 可用于程序化发现。 +- Polymarket Gamma API(`gamma-api.polymarket.com`)只读、无需鉴权, + 约 60 req/min,覆盖体育、政治、娱乐等品类。 + +## 2. 目标 + +- 品类可插拔:新增一个运动或非体育品类,只需实现一个品类适配器, + 不改核心 watcher。 +- 盘口源可插拔:新增一个盘口平台,只需实现一个盘口源适配器; + 概率条与提醒消费聚合后的归一化报价。 +- 自动发现:扫描盘口平台的开放事件,按热度排序推荐给用户, + 而不是依赖单一 series 的手工配置。 +- 配对可信:跨平台实体匹配由 AI 配对助手提议、人工确认、 + 注册表持久化;watcher 运行时不依赖 LLM。 +- 现有足球体验(解说三档、剧透保护、球星应援、手机设置)全部保留。 + +## 3. 非目标(继承并延续) + +- 不自动下单、不读取任何平台账户、不提供投注建议或套利策略。 +- 不把任何 API key 写入设备固件;Kalshi 与 Polymarket 读取路径 + 均不需要鉴权。 +- 不做公网服务;所有组件保持本地优先、可信局域网内运行。 +- 呈现跨平台报价分歧属于信息展示,不得使用"该买哪边"式话术。 + +## 4. 架构:两条正交扩展轴 + +```text +盘口源适配器 (VenueAdapter) 品类适配器 (CategoryAdapter) + Kalshi ──┐ 足球 (ESPN soccer) + Polymarket ─┤ MLB / 网球 (ESPN) + 未来平台 ──┘ 政治 / 选秀 (无逐字直播) + │ │ + ▼ ▼ + 归一化报价 ──► 聚合器 ──► 配对注册表 ◄── 事件解析/解说 + │ + ▼ + watcher 核心(队列、优先级、显示、语音、动作) +``` + +品类适配器回答"现实世界发生了什么";盘口源适配器回答"市场认为 +概率是多少"。两轴在配对注册表处汇合:注册表声明"现实中的同一件事" +分别对应哪个平台的哪个 market、哪个赛事源的哪个 event。 + +### 4.1 盘口源适配器(VenueAdapter) + +接口职责: + +- `discover(category, window)` — 列出开放事件(含成交量、临近收盘、 + 近期波动等热度信号)。 +- `quotes(market_refs)` — 返回归一化报价。 +- `metadata(market_ref)` — 标题、结果列表、收盘时间、结算状态。 + +归一化报价模型(所有平台统一): + +```python +@dataclass +class VenueQuote: + venue: str # "kalshi" | "polymarket" + market_id: str + outcome: str # 规范化结果名,对应注册表 outcomes + prob_mid: float # 0.0–1.0 + bid: float | None + ask: float | None + volume_usd: float | None + liquidity_usd: float | None + status: str # open | paused | closed | settled + close_time: datetime | None + fetched_at: datetime +``` + +单位注意:Kalshi 报价为 cents、量为合约数;Polymarket 为 0–1 份额、 +量为 USDC。适配器内部完成换算,聚合层只见统一模型。 + +### 4.2 聚合器 + +- 主输出:每个规范化结果一个聚合概率。首版采用 + **liquidity 加权 mid**(盘口越紧、深度越好权重越高), + 而非累计成交量加权——累计量反映历史热度,不反映当下报价质量。 +- 分歧信号:任意两平台对同一结果的 mid 差超过阈值(建议 8 分)时, + 产出一条"市场分歧"事实,交由解说层用信息性话术播报。 +- 多源确认:现有 `goal_signal`(盘口跳动=疑似事件)升级为多源版本—— + 单一平台跳动维持现有"等待确认"话术;**两个平台同向同时跳动** + 提升优先级与置信度。该信号与品类无关,是新品类在没有逐字直播 + 解析器之前的默认反应层。 +- 降级:单一平台不可用时聚合器退化为单源,界面不报错、 + 仅在日志记录。 + +### 4.3 配对注册表(pairing registry) + +新配置文件 `config/pairing_registry.json`,watcher 只消费 +`confirmed: true` 的条目: + +```json +{ + "canonical_events": [ + { + "id": "mlb-2026-07-21-NYY-BOS", + "category": "mlb", + "label": { "zh": "扬基 vs 红袜", "en": "Yankees vs Red Sox" }, + "starts_at": "2026-07-21T23:10:00+00:00", + "outcomes": ["NYY", "BOS"], + "event_source": { + "provider": "espn", + "league": "baseball/mlb", + "event_id": "401234567" + }, + "venue_markets": [ + { + "venue": "kalshi", + "event_ticker": "KXMLBGAME-26JUL21NYYBOS", + "outcome_map": { + "NYY": "KXMLBGAME-26JUL21NYYBOS-NYY", + "BOS": "KXMLBGAME-26JUL21NYYBOS-BOS" + } + }, + { + "venue": "polymarket", + "event_id": "yankees-red-sox-2026-07-21", + "outcome_map": { "NYY": "", "BOS": "" } + } + ], + "pairing": { + "proposed_by": "agent", + "confidence": 0.97, + "evidence": "队名、开赛时间(±1h)、收盘时间三项一致", + "confirmed": true + } + } + ] +} +``` + +设计原则: + +- `event_source` 可为空——纯盘口(standalone)事件是一等公民, + 政治、选秀等无逐字直播的品类默认走此路径。 +- `outcomes` 是数组而非左右两方——二元只是 N=2 的特例, + 为大选、选秀状元签等多路盘预留。 +- 配对写入与确认分离:AI 助手(见第 5 节)只提议, + 用户在手机设置页或对话中确认后 `confirmed` 才置真。 + 该模式延续现有"watcher 验证 ESPN/Kalshi 球队一致后才应用"的惯例。 + +### 4.4 品类适配器(CategoryAdapter) + +接口职责: + +- `discover()` — 列出该品类的可看事件(体育走 ESPN scoreboard, + 非体育品类可缺省,改由盘口发现驱动)。 +- `parse_events(payload)` — 把赛事源原始数据解析为结构化事实 + (沿用解说 PRD 的"共同事实"契约)。 +- `reaction_vocabulary()` — 事件类型 → 优先级、语音模板、 + 灯效/动作的映射。 +- `display_hints()` — 概率条模式、旗帜/头像资源等。 + +足球是第一个实现(从现有代码提取,行为不变,由既有测试套件守护)。 +每个新品类的实际工作量集中在 `parse_events`:ESPN 各运动 schema +互不相同且无文档,必须配套录制回放(复用 +`stackchan_match_replay.py` 模式)与合约测试。 + +### 4.5 显示模式 + +| 模式 | 适用 | 说明 | +| --- | --- | --- | +| `two_way_bar` | 足球、MLB、网球等二元盘 | 现有归一化概率条,不变 | +| `top_n` | 大选、选秀、夺冠赛道等多路盘 | 显示前 2–3 名 + "其他",可轮播 | +| `single_gauge` | 标量/单一二元盘 | 现有 `binary_complement` 的推广 | + +多路模式在 P5 之前不实现,但归一化报价与注册表从 P1 起就按 +N 路结果建模。 + +## 5. AI 配对助手(agent skill) + +跨平台实体匹配(Kalshi 结构化 ticker ↔ Polymarket 自由文本标题 ↔ +ESPN event)是全系统最易错的环节,也最不适合写成硬编码规则。 +方案:做成仓库内 agent skill(`.claude/skills/market-pairing/`), +由 AI agent 在开发机上按需执行,产出注册表**提议**。 + +流程契约: + +1. 用户给出品类与日期窗口(如"帮我配下周的 MLB")。 +2. agent 运行 `scripts/fetch_pairing_candidates.py` 分别拉取 + Kalshi `/events`、Polymarket Gamma `/events`、ESPN scoreboard + 的精简候选列表(脚本负责裁剪字段,避免原始响应浪费上下文)。 +3. agent 做模糊匹配:队名/人名规范化、开赛时间容差(±2 小时)、 + 收盘时间与赛期一致性、结果数量一致性。 +4. 每条提议附 `confidence` 与 `evidence`,写入注册表并保持 + `confirmed: false`;低置信候选列出而不写入。 +5. 用户确认后置 `confirmed: true`(手工或让 agent 代改)。 + +安全边界: + +- skill 只写 `config/pairing_registry.json`,不碰 watcher 其他配置。 +- watcher 对 `confirmed: true` 条目仍做启动校验(结果名与 + outcome_map 键一致、市场存在且未结算),校验失败降级为忽略该 + venue 并告警,而不是崩溃。 +- 运行时零 LLM 依赖:agent 不在比赛期间参与任何路径。 + +## 6. 分阶段路线图 + +| 阶段 | 范围 | 验收 | 明确不做 | +| --- | --- | --- | --- | +| P0 续命 | 现有足球管线指向英超/欧冠(config-only:`league`、`series_ticker`) | 8 月英超开赛可直接陪看 | 不改代码结构 | +| P1 盘口源重构 | 抽出 VenueAdapter + VenueQuote,Kalshi 为首个实现;纯重构 | 全部既有测试通过,行为零变化 | 不加新平台 | +| P2 Polymarket 只读 | Polymarket adapter,仅接 standalone 模式 | 任选一个两平台共有盘,设备显示聚合概率与分歧 | 不做 ESPN 配对 | +| P3 注册表 + 聚合 | 配对注册表接入主流程,概率条消费聚合概率;market-pairing skill 首版 | 一场真实比赛以双平台聚合完整陪看 | 不做自动确认 | +| P4 首个新品类 | MLB(或网球)CategoryAdapter:ESPN 解析、回放录制、反应词汇 | 完整陪看一场 MLB,含解说三档 | 不做多路显示 | +| P5 多路显示 | `top_n` 模式 + 多路聚合展示 | 一个真实多路盘(如选秀/夺冠盘)常驻显示 | 不做开票夜事件源 | +| P6 非体育品类 | 政治/选秀品类适配器:以定时发布、盘口跳动为"事件" | 大选或选秀夜完整陪看 | 视届时需求定 | + +日历对齐:世界杯 7/19 结束 → P1/P2 为纯软件活,正好填 7 月底至 +8 月初空窗 → 英超 8 月中开赛承接 P0/P3 实战 → MLB/网球整个夏天 +可用作 P4 练手 → NBA/NFL 赛季(9–10 月)时适配器接口已定型, +新增只剩解析器与词汇表 → 2026 美国中期选举(11 月)承接 P5/P6。 + +## 7. 数据源与限制 + +- **Kalshi**:public REST 无需鉴权;`/series`、`/events` 支持按 + category 发现;in-play 盘为实时概率条首选。沿用现有自适应轮询 + 与退避策略。 +- **Polymarket**:Gamma API 只读无鉴权,约 60 req/min——单场轮询 + 足够,但发现扫描需注意批量与缓存;报价 0–1、量为 USDC。 +- **ESPN**:各运动端点均无文档、随时可变;每个品类适配器必须 + 配录制回放与合约测试,解析失败时降级为纯盘口模式。 +- **显示**:CoreS3 屏幕小,`top_n` 最多前 3 名;旗帜资源模式需要 + 推广为"头像/徽标资源包"(复用 flag pack 生成器)。 + +## 8. 风险与对策 + +- 配对错误导致播错比赛 → 人工确认门槛 + watcher 启动校验 + + 聚合概率与 ESPN 比分严重矛盾时自动静默盘口提醒。 +- 两平台结果命名不一致(如 "Yankees" vs "New York Yankees")→ + 规范化结果名只存在于注册表,平台原始名只出现在 outcome_map。 +- 单平台流动性枯竭拖歪聚合 → liquidity 加权天然抑制; + spread 超过阈值的报价直接剔除。 +- ESPN schema 漂移 → 回放测试锁定已知格式,解析失败降级而非崩溃。 +- 品类适配器越来越多导致配置膨胀 → 品类默认值内置于适配器, + `kalshi_watchlist.json` 只保留用户偏好与当前事件。 + +## 9. 文档同步 + +本文档进入 `docs/` 后,按 development.md 的同步清单维护: +阶段完成时更新对应释出说明;P1 落地时把 VenueAdapter 契约固化为 +`docs/venue-adapter-api.md`;英文版在架构定型(P3)后补齐。 diff --git a/docs/releases/1.7.0.md b/docs/releases/1.7.0.md new file mode 100644 index 0000000..fa49023 --- /dev/null +++ b/docs/releases/1.7.0.md @@ -0,0 +1,89 @@ +# Matchday MOD 1.7.0 + +[English](#english) | [简体中文](#简体中文) + +## English + +The World Cup pipeline retires; the multi-venue architecture from the +[multi-venue roadmap PRD](../multi-venue-roadmap-prd.zh-CN.md) lands its first +two phases. No firmware changes: the device keeps receiving the same `pkbar`, +ticker, and alert commands. + +### P1 — Venue adapter extraction (pure refactor) + +- New `tools/stackchan_venues.py`: the `VenueQuote` normalized quote model + (probabilities 0–1, money in USD) and the `VenueAdapter` contract + (`discover` / `quotes` / `metadata`), with `KalshiVenueAdapter` as the first + implementation. Contract notes: [venue-adapter-api.zh-CN.md](../venue-adapter-api.zh-CN.md). +- The watcher's Kalshi fetch now goes through the adapter; snapshot parsing, + alert evaluation, and every existing test are unchanged (194 pre-existing + tests pass without modification). + +### P2 — Polymarket read-only aggregation + +- `PolymarketVenueAdapter` reads the Gamma API (no auth; polls capped at one + request per 15s minimum, 30s default) and decodes Gamma's JSON-encoded + outcome/price fields. +- The probability bar can pair its two sides with one Gamma market via + `probability_bar.polymarket`; it then renders a liquidity-weighted mid + across venues, falling back to equal weights when liquidity is unreported + and to single-venue rendering when either platform is down (log-only + degradation). +- New `venue_divergence` informational alert when venues disagree by ≥8 + points (10-minute cooldown), phrased as information, never as advice. +- Multi-source goal signal: a Kalshi jump corroborated by a same-direction + Polymarket jump within 90s is upgraded to a dual-venue, high-confidence + callout (priority above single-venue signals). +- 31 new tests cover the adapters, aggregation, divergence, corroboration, + and config parsing. + +### Configured fixtures + +- `config/kalshi_watchlist.json` now watches MLB Dodgers @ Phillies + (Jul 20, 23:10 UTC — the morning of Jul 21 in Beijing) with dual-venue + aggregation; ESPN commentary stays off until the MLB category adapter (P4). +- `config/pairing_registry.json` ships three agent-proposed, human-unconfirmed + cross-venue pairings (two Dodgers–Phillies games, Pirates–Yankees). +- Match Setup fixture discovery now points at `KXEPLGAME` for the Premier + League season starting mid-August. Known gap tracked for P0 completion: + the fixture pairing logic must learn to skip Kalshi's draw market before + EPL kickoff. + +## 简体中文 + +世界杯管线退役;[多品类·多盘口 PRD](../multi-venue-roadmap-prd.zh-CN.md) +的前两个阶段落地。固件零改动:设备收到的仍是同样的 `pkbar`、ticker +和提醒指令。 + +### P1 —— 盘口源适配器抽取(纯重构) + +- 新增 `tools/stackchan_venues.py`:归一化报价模型 `VenueQuote` + (概率 0–1、金额美元口径)与 `VenueAdapter` 契约 + (`discover` / `quotes` / `metadata`),`KalshiVenueAdapter` 为首个实现。 + 契约文档:[venue-adapter-api.zh-CN.md](../venue-adapter-api.zh-CN.md)。 +- watcher 的 Kalshi 拉取改走适配器;快照解析、提醒评估与全部既有测试 + 行为零变化(既有 194 个测试原样通过)。 + +### P2 —— Polymarket 只读聚合 + +- `PolymarketVenueAdapter` 接入 Gamma API(免鉴权;轮询下限 15 秒、 + 默认 30 秒),并处理 Gamma 把结果/价格字段编码成 JSON 字符串的怪癖。 +- 概率条可通过 `probability_bar.polymarket` 把左右两路映射到一个 Gamma + market,显示两平台的 liquidity 加权 mid;无流动性数据时等权,任一平台 + 故障时静默退化为单源(只记日志)。 +- 新增 `venue_divergence` 信息性提醒:两平台分歧 ≥8 分时播报 + (10 分钟冷却),只陈述事实,不做任何"该买哪边"式话术。 +- 多源确认信号:Kalshi 盘口跳动在 90 秒内被 Polymarket 同向跳动佐证时, + 升级为"双平台确认"的高置信播报(优先级高于单平台信号)。 +- 新增 31 个测试覆盖适配器、聚合、分歧、佐证与配置解析。 + +### 已配置赛事 + +- `config/kalshi_watchlist.json` 指向 MLB 道奇 @ 费城人 + (7/20 23:10 UTC,北京时间 7/21 早上 7:10),双平台聚合模式; + ESPN 逐字解说等 MLB 品类适配器(P4)落地后再开。 +- `config/pairing_registry.json` 收录三条 agent 提议、待人工确认的 + 跨平台配对(道奇-费城人两场、海盗-扬基一场)。 +- 手机 Match Setup 的赛程发现已指向英超 `KXEPLGAME`,迎接 8 月中开赛。 + 已知缺口(P0 收尾项):赛程配对逻辑需在英超开赛前学会跳过 Kalshi + 的平局市场。 diff --git a/docs/venue-adapter-api.zh-CN.md b/docs/venue-adapter-api.zh-CN.md new file mode 100644 index 0000000..c70cb87 --- /dev/null +++ b/docs/venue-adapter-api.zh-CN.md @@ -0,0 +1,87 @@ +# VenueAdapter 契约(盘口源适配器) + +状态:随 P1/P2 落地(2026-07)。英文版按路线图在 P3 架构定型后补齐。 +背景与设计动机见 [multi-venue-roadmap-prd.zh-CN.md](multi-venue-roadmap-prd.zh-CN.md) 第 4 节。 + +模块:`tools/stackchan_venues.py`(仅标准库,HTTP 通过构造参数注入, +调用方负责重试与退避;测试注入 stub,不 patch urllib)。 + +## 归一化报价模型 + +所有平台的报价统一换算成 `VenueQuote`,聚合层不见任何平台原生单位: + +| 字段 | 类型 | 约定 | +| --- | --- | --- | +| `venue` | str | `"kalshi"` / `"polymarket"` | +| `market_id` | str | 平台原生市场标识(Kalshi ticker / Gamma market id) | +| `outcome` | str | 规范化结果名,由调用方给定(如 `"left"`/`"right"` 或注册表结果名) | +| `prob_mid` | float\|None | 0.0–1.0。有订单簿取 bid/ask 中值;否则退回 last price;已结算取结算值 | +| `bid` / `ask` | float\|None | 0.0–1.0 概率空间 | +| `volume_usd` | float\|None | 美元口径。Kalshi 合约以 $1 结算,24h 合约量按 1:1 近似 | +| `liquidity_usd` | float\|None | 美元口径;未知填 None | +| `status` | str | `open` / `paused` / `closed` / `settled`(平台原生状态在适配器内收敛) | +| `close_time` | datetime\|None | UTC | +| `fetched_at` | datetime | UTC,取样时间 | + +单位换算责任在适配器内部:Kalshi 报价是 dollars-per-contract(0–1), +Polymarket 是 0–1 份额价格、量与流动性为 USDC 数值字段。 + +## 适配器接口 + +```python +class VenueAdapter(Protocol): + venue: str + def discover(self, category: str, days: int) -> list[dict]: ... + def quotes(self, market_refs: Sequence[Any]) -> list[VenueQuote]: ... + def metadata(self, market_ref: Any) -> VenueMarketMeta | None: ... +``` + +- `discover(category, days)`:列出开放事件(含标题、收盘时间、市场列表), + 给配对助手和未来的发现流程用;watcher 运行时不调用。 +- `quotes(market_refs)`:按平台原生引用批量取归一化报价。 +- `metadata(market_ref)`:标题、结果列表、状态、收盘时间,启动校验用。 + +### KalshiVenueAdapter + +- `market_ref` 是 market ticker 字符串。 +- 额外提供 `raw_markets(tickers)`:保留给 watcher 的 + `fetch_markets`(`MarketSnapshot` 解析路径原样不动,P1 重构零行为变化)。 +- 状态收敛:`active→open`;`finalized/settled/determined` 或带 + `result` → `settled`;`closed/inactive→closed`。 + +### PolymarketVenueAdapter + +- `market_ref` 是 `PolymarketMarketRef(market_id, outcomes)`: + `outcomes` 把规范化结果名映射到 Gamma `outcomes` 数组里的原文标签 + (如 `{"left": "Los Angeles Dodgers"}`);留空则按原生标签全量报价。 +- Gamma 的 `outcomes` / `outcomePrices` / `clobTokenIds` 是 JSON 编码的 + 字符串,适配器负责解码。 +- `bestBid`/`bestAsk` 描述第一个 outcome 的订单簿;二元盘的另一侧按 + `1 - ask, 1 - bid` 镜像,多路盘其余 outcome 不给 bid/ask。 +- 速率约算 60 req/min:单场轮询预算充裕,发现扫描要注意批量与缓存。 + +## 聚合函数 + +- `aggregate_probability(quotes)`:同一结果的跨平台聚合概率。 + 规则:已结算报价直接胜出 → 只留 `open` 报价 → 有更紧订单簿时剔除 + spread 超过 `AGGREGATION_SPREAD_CAP`(0.15)的报价 → 所有幸存报价都带 + 流动性时做 liquidity 加权 mid,否则等权平均(避免已知/未知深度混权 + 造成隐性偏置)。单源自然退化为单源值。 +- `max_divergence(quotes, threshold=0.08)`:同一结果跨平台最大分歧, + 超过阈值(默认 8 分)返回 `VenueDivergence`,供解说层做"市场分歧" + 信息性播报。 +- `same_direction_jump(delta_a, delta_b, min_abs)`:多源确认信号—— + 两个平台同向、各自超过阈值的同时跳动,是 goal signal 的高置信升级。 + +## watcher 集成点(P2 现状) + +- `probability_bar.polymarket`(配置)把概率条的左右两路映射到一个 + Gamma market;watcher 每 `polymarket.poll_seconds`(默认 30s,下限 + 15s)拉一次报价,概率条显示聚合值。 +- Polymarket 拉取失败时退化为 Kalshi 单源:界面不报错、仅记日志, + 报价清空直到下次成功。 +- 分歧 ≥8 分产出 `venue_divergence` 信息性提醒(10 分钟冷却); + Kalshi goal signal 在 90 秒窗口内被 Polymarket 同向跳动佐证时, + 升级为"双平台确认"话术与更高优先级。 +- 运行时零 LLM 依赖;配对注册表(`config/pairing_registry.json`)由 + market-pairing skill 离线提议、人工确认,watcher P3 起消费。 diff --git a/mod/http-server-safe.js b/mod/http-server-safe.js index 8ed5e44..522fd14 100644 --- a/mod/http-server-safe.js +++ b/mod/http-server-safe.js @@ -228,6 +228,17 @@ class HttpServerService { this.#active++ let settled = false + let closed = false + // Exactly one close per connection. The watchdog close rejects the + // in-flight #handle promises, whose rejection path would otherwise close + // the same connection a second time — a double free/close on the socket. + const closeOnce = () => { + if (closed) return + closed = true + try { + connection.close() + } catch (_closeError) {} + } const finish = () => { if (settled) return settled = true @@ -237,19 +248,13 @@ class HttpServerService { const watchdog = Timer.set(() => { if (settled) return trace('[matchday] HTTP request timed out; closing connection\n') - // Closing rejects the listener's pending request/response promises, so - // the in-flight #handle chain settles through its rejection path. - try { - connection.close() - } catch (_closeError) {} + closeOnce() finish() }, REQUEST_TIMEOUT_MS) this.#handle(connection).then(finish, (error) => { trace(`[matchday] HTTP response closed: ${error}\n`) - try { - connection.close() - } catch (_closeError) {} + closeOnce() finish() }) } diff --git a/tools/stackchan_kalshi_watch.py b/tools/stackchan_kalshi_watch.py index 02335a4..1d92062 100755 --- a/tools/stackchan_kalshi_watch.py +++ b/tools/stackchan_kalshi_watch.py @@ -49,6 +49,17 @@ load_default_player_catalog, resolve_player_profile, ) + from stackchan_venues import ( + POLYMARKET_BASE_URL, + KalshiVenueAdapter, + PolymarketMarketRef, + PolymarketVenueAdapter, + VenueDivergence, + VenueQuote, + aggregate_probability, + max_divergence, + same_direction_jump, + ) except ModuleNotFoundError: # pragma: no cover - supports importlib-based tests. sys.path.insert(0, str(Path(__file__).parent)) from stackchan_i18n import ( @@ -67,6 +78,17 @@ load_default_player_catalog, resolve_player_profile, ) + from stackchan_venues import ( + POLYMARKET_BASE_URL, + KalshiVenueAdapter, + PolymarketMarketRef, + PolymarketVenueAdapter, + VenueDivergence, + VenueQuote, + aggregate_probability, + max_divergence, + same_direction_jump, + ) DEFAULT_BASE_URL = "https://external-api.kalshi.com/trade-api/v2" @@ -163,6 +185,18 @@ class ProbabilityBarConfig: left_color: str = "#0055A4" right_flag: str = "ma" right_color: str = "#C1272D" + # Optional Polymarket pairing for the same two outcomes (standalone mode, + # PRD P2): one Gamma market whose outcome labels map onto the bar's sides. + polymarket_market_id: str = "" + polymarket_left_outcome: str = "" + polymarket_right_outcome: str = "" + + +@dataclass +class PolymarketConfig: + enabled: bool = True + base_url: str = POLYMARKET_BASE_URL + poll_seconds: int = 30 @dataclass @@ -250,6 +284,7 @@ class WatchConfig: spoiler_free_mode: bool = False quiet_hours: QuietHours = field(default_factory=QuietHours) probability_bar: ProbabilityBarConfig = field(default_factory=ProbabilityBarConfig) + polymarket: PolymarketConfig = field(default_factory=PolymarketConfig) setup_server: SetupServerConfig = field(default_factory=SetupServerConfig) adaptive_polling: AdaptivePollingConfig = field(default_factory=AdaptivePollingConfig) espn: ESPNConfig = field(default_factory=ESPNConfig) @@ -276,6 +311,7 @@ class MarketSnapshot: close_time: datetime | None result: str = "" settlement_value_cents: int | None = None + liquidity_usd: float | None = None def bid(self, side: str) -> int | None: return self.yes_bid_cents if side == "yes" else self.no_bid_cents @@ -559,6 +595,9 @@ def load_config(path: Path, language_override: str | None = None) -> WatchConfig bar_raw = raw.get("probability_bar") or {} if not isinstance(bar_raw, dict): raise ConfigError("probability_bar must be an object") + bar_poly_raw = bar_raw.get("polymarket") or {} + if not isinstance(bar_poly_raw, dict): + raise ConfigError("probability_bar.polymarket must be an object") probability_bar = ProbabilityBarConfig( enabled=bool(bar_raw.get("enabled", False)), mode=str(bar_raw.get("mode", "binary_complement")).strip().lower(), @@ -569,6 +608,20 @@ def load_config(path: Path, language_override: str | None = None) -> WatchConfig left_color=str(bar_raw.get("left_color", "#0055A4")).strip(), right_flag=str(bar_raw.get("right_flag", "ma")).strip().lower(), right_color=str(bar_raw.get("right_color", "#C1272D")).strip(), + polymarket_market_id=str(bar_poly_raw.get("market_id", "")).strip(), + polymarket_left_outcome=str(bar_poly_raw.get("left_outcome", "")).strip(), + polymarket_right_outcome=str(bar_poly_raw.get("right_outcome", "")).strip(), + ) + + polymarket_raw = raw.get("polymarket") or {} + if not isinstance(polymarket_raw, dict): + raise ConfigError("polymarket must be an object") + polymarket = PolymarketConfig( + enabled=bool(polymarket_raw.get("enabled", True)), + base_url=str(polymarket_raw.get("base_url", POLYMARKET_BASE_URL)).strip().rstrip("/"), + # Gamma allows ~60 req/min shared across everything we do; one quote + # request each 15s+ keeps plenty of headroom for discovery scans. + poll_seconds=max(15, int(polymarket_raw.get("poll_seconds", 30))), ) setup_raw = raw.get("setup_server") or {} @@ -793,6 +846,7 @@ def load_config(path: Path, language_override: str | None = None) -> WatchConfig ), quiet_hours=quiet, probability_bar=probability_bar, + polymarket=polymarket, setup_server=setup_server, adaptive_polling=adaptive_polling, espn=espn, @@ -853,6 +907,16 @@ def validate_config(config: WatchConfig, dry_run: bool = False) -> None: raise ConfigError(f"probability_bar.{name} must be a lowercase flag code") parse_hex_color(config.probability_bar.left_color) parse_hex_color(config.probability_bar.right_color) + bar = config.probability_bar + if bar.polymarket_market_id: + if bar.mode != "normalized_outcomes": + raise ConfigError( + "probability_bar.polymarket needs normalized_outcomes mode" + ) + if not bar.polymarket_left_outcome or not bar.polymarket_right_outcome: + raise ConfigError( + "probability_bar.polymarket needs both left_outcome and right_outcome" + ) for name, color in config.espn.team_colors.items(): try: parse_hex_color(color) @@ -872,6 +936,15 @@ def command_hex_color(value: str) -> str: return value.strip().removeprefix("#").upper() +def _optional_float(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + def dollars_to_cents(value: Any) -> int | None: if value in (None, ""): return None @@ -969,13 +1042,11 @@ def http_json(url: str) -> dict[str, Any]: def fetch_markets(config: WatchConfig) -> tuple[dict[str, MarketSnapshot], list[str]]: tickers = [market.ticker for market in config.markets] - query = urllib.parse.urlencode({"tickers": ",".join(tickers), "limit": str(max(100, len(tickers)))}) - url = f"{config.kalshi_base_url}/markets?{query}" - payload = http_json(url) + adapter = KalshiVenueAdapter(config.kalshi_base_url, fetch=http_json) by_label = {market.ticker: market.label for market in config.markets} snapshots: dict[str, MarketSnapshot] = {} - for market in payload.get("markets", []): + for market in adapter.raw_markets(tickers): ticker = str(market.get("ticker", "")).upper() if ticker not in by_label: continue @@ -995,6 +1066,9 @@ def fetch_markets(config: WatchConfig) -> tuple[dict[str, MarketSnapshot], list[ settlement_value_cents=dollars_to_cents( market.get("settlement_value_dollars") ), + liquidity_usd=_optional_float( + market.get("liquidity_dollars") or market.get("liquidity") + ), ) missing = [ticker for ticker in tickers if ticker not in snapshots] @@ -4183,6 +4257,96 @@ def clause_before(source: str, index: int) -> str: ) +VENUE_DISPLAY_NAMES = {"kalshi": "Kalshi", "polymarket": "Polymarket"} +# A second venue jumping within this window of a goal signal counts as +# confirmation; beyond it the moves are probably unrelated drift. +GOAL_SIGNAL_CORROBORATION_WINDOW_SECONDS = 90.0 +VENUE_DIVERGENCE_COOLDOWN_SECONDS = 600.0 + + +def venue_divergence_alert( + config: WatchConfig, + divergence: VenueDivergence, + label: str, +) -> Alert: + """Informational fact: two venues disagree on the same outcome. + + Deliberately neutral wording — surfacing the gap is fine, steering the + user toward either side is not (PRD non-goal). + """ + quote_a, quote_b = divergence.quote_a, divergence.quote_b + name_a = VENUE_DISPLAY_NAMES.get(quote_a.venue, quote_a.venue) + name_b = VENUE_DISPLAY_NAMES.get(quote_b.venue, quote_b.venue) + percent_a = int(round(quote_a.prob_mid * 100)) + percent_b = int(round(quote_b.prob_mid * 100)) + return Alert( + ticker=quote_a.market_id, + label=label, + kind="venue_divergence", + priority=60, + face="surprise", + balloon=pick( + config.language, + f"{label} | 平台分歧 | {name_a} {percent_a}% vs {name_b} {percent_b}%", + f"{label} | Venue split | {name_a} {percent_a}% vs {name_b} {percent_b}%", + ), + speech=pick( + config.language, + f"两个平台对{label}的看法出现分歧:{name_a}给到百分之{percent_a}," + f"{name_b}给到百分之{percent_b}。市场还没统一意见。", + f"The venues disagree on {label}: {name_a} says {percent_a} percent, " + f"{name_b} says {percent_b} percent. The market hasn't made up its mind.", + ), + detail=( + f"venue divergence {quote_a.venue}={quote_a.prob_mid:.2f} " + f"{quote_b.venue}={quote_b.prob_mid:.2f} gap={divergence.gap:.2f}" + ), + spoiler_sensitive=True, + ) + + +def corroborate_goal_signal_alert(alert: Alert, config: WatchConfig) -> Alert: + """Upgrade a single-venue goal signal after a second venue moved with it. + + One venue jumping keeps the existing "awaiting confirmation" wording; + two venues jumping the same way at the same time is treated as a + high-confidence event (PRD section 4.2, multi-source confirmation). + """ + boost = pick( + config.language, + "两个平台同时跳动,这个信号可信度很高!", + "Both venues jumped together—this signal looks solid!", + ) + speech = join_sentences(config.language, alert.speech, boost) if alert.speech else boost + return replace( + alert, + priority=960, + balloon=pick( + config.language, + f"双平台确认 | {alert.balloon}", + f"Dual-venue | {alert.balloon}", + ), + speech=speech, + detail=f"{alert.detail}; corroborated by polymarket", + ) + + +def bar_polymarket_ref(config: WatchConfig) -> PolymarketMarketRef | None: + """The Gamma market paired with the probability bar, if fully configured.""" + bar = config.probability_bar + if not (config.polymarket.enabled and bar.enabled and bar.polymarket_market_id): + return None + if not bar.polymarket_left_outcome or not bar.polymarket_right_outcome: + return None + return PolymarketMarketRef( + market_id=bar.polymarket_market_id, + outcomes={ + "left": bar.polymarket_left_outcome, + "right": bar.polymarket_right_outcome, + }, + ) + + def can_alert(state: MarketState, config: MarketConfig, now_ts: float) -> bool: return now_ts - state.last_alert_at >= config.min_seconds_between_alerts @@ -5046,26 +5210,62 @@ def ticker_text(config: WatchConfig, snapshots: dict[str, MarketSnapshot]) -> st ) -def probability_bar_command(config: WatchConfig, snapshots: dict[str, MarketSnapshot]) -> str: +def venue_quote_from_snapshot(snapshot: MarketSnapshot, outcome: str) -> VenueQuote: + """Bridge the watcher's Kalshi snapshot into the aggregation model.""" + status_lower = snapshot.status.lower() + if status_lower in {"settled", "finalized", "determined"} or snapshot.result: + status = "settled" + elif status_lower in {"closed", "inactive"}: + status = "closed" + elif status_lower == "paused": + status = "paused" + else: + status = "open" + implied = snapshot.implied_probability("yes") + return VenueQuote( + venue="kalshi", + market_id=snapshot.ticker, + outcome=outcome, + prob_mid=implied / 100 if implied is not None else None, + bid=snapshot.yes_bid_cents / 100 if snapshot.yes_bid_cents is not None else None, + ask=snapshot.yes_ask_cents / 100 if snapshot.yes_ask_cents is not None else None, + volume_usd=_optional_float(snapshot.volume_24h), + liquidity_usd=snapshot.liquidity_usd, + status=status, + close_time=snapshot.close_time, + fetched_at=datetime.now(timezone.utc), + ) + + +def probability_bar_command( + config: WatchConfig, + snapshots: dict[str, MarketSnapshot], + venue_quotes: dict[str, list[VenueQuote]] | None = None, +) -> str: bar = config.probability_bar if not bar.enabled: return "" + extra = venue_quotes or {} snapshot = snapshots.get(bar.market_ticker) - if snapshot is None: - return "" if bar.mode == "normalized_outcomes": + left_quotes = [venue_quote_from_snapshot(snapshot, "left")] if snapshot else [] + left_quotes.extend(extra.get("left") or []) right_snapshot = snapshots.get(bar.right_market_ticker) - left_midpoint = snapshot.implied_probability("yes") - right_midpoint = ( - right_snapshot.implied_probability("yes") if right_snapshot else None + right_quotes = ( + [venue_quote_from_snapshot(right_snapshot, "right")] if right_snapshot else [] ) - if left_midpoint is None or right_midpoint is None: + right_quotes.extend(extra.get("right") or []) + left_prob = aggregate_probability(left_quotes) + right_prob = aggregate_probability(right_quotes) + if left_prob is None or right_prob is None: return "" - total = left_midpoint + right_midpoint + total = left_prob + right_prob if total <= 0: return "" - left_percent = int(math.floor((left_midpoint * 100 / total) + 0.5)) + left_percent = int(math.floor((left_prob * 100 / total) + 0.5)) else: + if snapshot is None: + return "" midpoint = snapshot.implied_probability(bar.side) if midpoint is None: return "" @@ -5085,8 +5285,12 @@ def probability_bar_command(config: WatchConfig, snapshots: dict[str, MarketSnap ) -def persistent_display_command(config: WatchConfig, snapshots: dict[str, MarketSnapshot]) -> str: - probability_command = probability_bar_command(config, snapshots) +def persistent_display_command( + config: WatchConfig, + snapshots: dict[str, MarketSnapshot], + venue_quotes: dict[str, list[VenueQuote]] | None = None, +) -> str: + probability_command = probability_bar_command(config, snapshots, venue_quotes) if probability_command: return probability_command if not config.ticker_enabled: @@ -5095,8 +5299,13 @@ def persistent_display_command(config: WatchConfig, snapshots: dict[str, MarketS return f"ticker {message}" if message else "" -def send_ticker(config: WatchConfig, snapshots: dict[str, MarketSnapshot], dry_run: bool) -> str: - command = persistent_display_command(config, snapshots) +def send_ticker( + config: WatchConfig, + snapshots: dict[str, MarketSnapshot], + dry_run: bool, + venue_quotes: dict[str, list[VenueQuote]] | None = None, +) -> str: + command = persistent_display_command(config, snapshots, venue_quotes) if not command: return "" send_stackchan_commands(config, [command], dry_run=dry_run) @@ -5248,10 +5457,22 @@ def run_watch(args: argparse.Namespace) -> int: delivery_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="stackchan-delivery") delivery_future: Future[bool] | None = None delivery_item: QueuedAlert | None = None + venue_quotes_by_side: dict[str, list[VenueQuote]] = {} + poly_prev_left_prob: float | None = None + poly_last_jump_direction = 0 + poly_last_jump_at = 0.0 + poly_failures = 0 + next_poly_poll_at = 0.0 + last_divergence_alert_at = 0.0 espn_description = f"; ESPN event={config.espn.event_id}" if config.espn.enabled else "" + poly_description = ( + f"; Polymarket market={config.probability_bar.polymarket_market_id}" + if bar_polymarket_ref(config) is not None + else "" + ) print( f"watching {len(config.markets)} Kalshi markets; Kalshi poll={config.poll_seconds}s" - f"; ESPN poll={config.espn.poll_seconds}s{espn_description}", + f"; ESPN poll={config.espn.poll_seconds}s{espn_description}{poly_description}", flush=True, ) try: @@ -5344,6 +5565,13 @@ def run_watch(args: argparse.Namespace) -> int: pending_device_style_sync = None pending_device_spoiler_sync = None last_poll_tier = "" + venue_quotes_by_side = {} + poly_prev_left_prob = None + poly_last_jump_direction = 0 + poly_last_jump_at = 0.0 + poly_failures = 0 + next_poly_poll_at = 0.0 + last_divergence_alert_at = 0.0 quiet = in_quiet_hours(config.quiet_hours) try: send_stackchan_commands(config, ["setup hide"], dry_run=args.dry_run) @@ -5681,6 +5909,88 @@ def run_watch(args: argparse.Namespace) -> int: file=sys.stderr, ) + poly_ref = bar_polymarket_ref(config) + if poly_ref is not None and cycle_monotonic >= next_poly_poll_at: + bar_market = next( + ( + market + for market in config.markets + if market.ticker == config.probability_bar.market_ticker + ), + None, + ) + try: + poly_adapter = PolymarketVenueAdapter( + config.polymarket.base_url, + fetch=http_json, + ) + poly_quotes = poly_adapter.quotes([poly_ref]) + poly_failures = 0 + next_poly_poll_at = cycle_monotonic + config.polymarket.poll_seconds + venue_quotes_by_side = {} + for quote in poly_quotes: + venue_quotes_by_side.setdefault(quote.outcome, []).append(quote) + left_quote = next( + ( + quote + for quote in venue_quotes_by_side.get("left", []) + if quote.prob_mid is not None + ), + None, + ) + if left_quote is not None and left_quote.status == "open": + if poly_prev_left_prob is not None: + delta = left_quote.prob_mid - poly_prev_left_prob + move_threshold = ( + bar_market.goal_signal_move_cents if bar_market else 5 + ) / 100 + if abs(delta) >= move_threshold: + poly_last_jump_direction = 1 if delta > 0 else -1 + poly_last_jump_at = cycle_monotonic + poly_prev_left_prob = left_quote.prob_mid + bar_snapshot = snapshots.get(config.probability_bar.market_ticker) + if ( + bar_snapshot is not None + and left_quote is not None + and cycle_monotonic - last_divergence_alert_at + >= VENUE_DIVERGENCE_COOLDOWN_SECONDS + ): + divergence = max_divergence( + [venue_quote_from_snapshot(bar_snapshot, "left"), left_quote] + ) + if divergence is not None: + label = bar_market.label if bar_market else bar_snapshot.label + pending.append( + ( + venue_divergence_alert(config, divergence, label), + None, + None, + None, + ) + ) + last_divergence_alert_at = cycle_monotonic + except ( + urllib.error.URLError, + TimeoutError, + OSError, + json.JSONDecodeError, + ValueError, + ) as error: + # Degrade to single-source aggregation: the bar keeps + # rendering from Kalshi alone and the device never hears + # about it (PRD: log-only degradation). + poly_failures += 1 + venue_quotes_by_side = {} + retry_seconds = min( + 300, + config.polymarket.poll_seconds * (2 ** min(4, poly_failures)), + ) + next_poly_poll_at = cycle_monotonic + retry_seconds + print( + f"warning: Polymarket fetch failed ({error}); retry in {retry_seconds}s", + file=sys.stderr, + ) + if config.espn.enabled and cycle_monotonic >= next_espn_poll_at: poll_started_at = cycle_monotonic espn_state.last_polled_at = cycle_monotonic @@ -5744,6 +6054,32 @@ def run_watch(args: argparse.Namespace) -> int: file=sys.stderr, ) + if ( + poly_last_jump_direction + and cycle_monotonic - poly_last_jump_at + <= GOAL_SIGNAL_CORROBORATION_WINDOW_SECONDS + ): + bar_ticker = config.probability_bar.market_ticker + corroborated: list[PendingAlertContext] = [] + for item in pending: + alert, item_snapshot, item_market, item_state = item + if ( + alert.kind == "market_goal_signal" + and alert.ticker == bar_ticker + and item_market is not None + ): + # Poly tracks the bar's left outcome in yes-space; + # flip the kalshi direction when the alert watched the + # no side so both deltas compare in the same space. + yes_direction = 1 if alert.clip_id == "odds-up" else -1 + if item_market.side_i_care == "no": + yes_direction = -yes_direction + if yes_direction == poly_last_jump_direction: + alert = corroborate_goal_signal_alert(alert, config) + item = (alert, item_snapshot, item_market, item_state) + corroborated.append(item) + pending = corroborated + if config.spoiler_free_mode: pending = [ item for item in pending if not item[0].spoiler_sensitive @@ -5751,7 +6087,11 @@ def run_watch(args: argparse.Namespace) -> int: alert_queue = purge_spoiler_sensitive_alerts(alert_queue) alert_queue = merge_alert_queue(alert_queue, pending, cycle_monotonic) - current_display_command = persistent_display_command(config, snapshots) + current_display_command = persistent_display_command( + config, + snapshots, + venue_quotes_by_side, + ) display_stale = ( cycle_monotonic - last_display_sent_at >= config.display_refresh_seconds ) @@ -5761,7 +6101,12 @@ def run_watch(args: argparse.Namespace) -> int: and (current_display_command != last_display_command or display_stale) ): try: - last_display_command = send_ticker(config, snapshots, dry_run=args.dry_run) + last_display_command = send_ticker( + config, + snapshots, + dry_run=args.dry_run, + venue_quotes=venue_quotes_by_side, + ) last_display_sent_at = cycle_monotonic except (urllib.error.URLError, OSError, RuntimeError) as error: print(f"warning: stackchan display failed: {error}", file=sys.stderr) diff --git a/tools/stackchan_match_setup.py b/tools/stackchan_match_setup.py index b5c7d87..cd6b3d4 100644 --- a/tools/stackchan_match_setup.py +++ b/tools/stackchan_match_setup.py @@ -240,6 +240,14 @@ def event_markets(payload: dict[str, Any]) -> tuple[dict[str, Any], list[dict[st event = payload.get("event") or {} markets = event.get("markets") or payload.get("markets") or [] parsed = [market for market in markets if isinstance(market, dict) and market.get("ticker")] + # A tournament-winner event (e.g. KXMENWORLDCUP) lists every team as its own + # market. Once the bracket is down to the final, the eliminated teams settle + # and only the two finalists stay "active" — that is the two-way market we + # want, so narrow to the live markets before requiring exactly two. + if len(parsed) > 2: + active = [market for market in parsed if str(market.get("status") or "").lower() == "active"] + if len(active) == 2: + parsed = active if len(parsed) != 2: raise ValueError("目前只支持恰好包含两个互斥球队盘口的淘汰赛事件") teams = [market_team_name(market) for market in parsed] diff --git a/tools/stackchan_venues.py b/tools/stackchan_venues.py new file mode 100644 index 0000000..00ed16c --- /dev/null +++ b/tools/stackchan_venues.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +"""Venue adapters: normalized prediction-market quotes across platforms. + +Implements the VenueAdapter axis of docs/multi-venue-roadmap-prd.zh-CN.md +(section 4.1/4.2). Each adapter converts one platform's native units into +the shared VenueQuote model (probabilities 0.0-1.0, money in USD) so the +aggregation layer never sees platform-specific cents or share prices. + +Standard library only; HTTP is injected so callers own retries and tests +stub the network without patching urllib. +""" + +from __future__ import annotations + +import json +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable, Protocol, Sequence + +KALSHI_BASE_URL = "https://external-api.kalshi.com/trade-api/v2" +POLYMARKET_BASE_URL = "https://gamma-api.polymarket.com" +REQUEST_TIMEOUT_SECONDS = 12 +USER_AGENT = "stackchan-matchday-watch/0.1" + +# Quotes whose book is wider than this are dropped from aggregation when a +# tighter source exists (PRD section 8: wide spreads mean stale/thin books). +AGGREGATION_SPREAD_CAP = 0.15 +# Two venues disagreeing on the same outcome by at least this much is a +# reportable "market divergence" fact (PRD section 4.2 suggests 8 points). +DIVERGENCE_THRESHOLD = 0.08 + +FetchJson = Callable[[str], Any] + + +def default_fetch_json(url: str) -> Any: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: + return json.loads(response.read().decode("utf-8")) + + +@dataclass +class VenueQuote: + venue: str + market_id: str + outcome: str + prob_mid: float | None + bid: float | None + ask: float | None + volume_usd: float | None + liquidity_usd: float | None + status: str # open | paused | closed | settled + close_time: datetime | None + fetched_at: datetime + + def spread(self) -> float | None: + if self.bid is None or self.ask is None: + return None + return max(0.0, self.ask - self.bid) + + +@dataclass +class VenueMarketMeta: + venue: str + market_id: str + title: str + outcomes: list[str] + status: str + close_time: datetime | None + + +class VenueAdapter(Protocol): + venue: str + + def discover(self, category: str, days: int) -> list[dict[str, Any]]: ... + + def quotes(self, market_refs: Sequence[Any]) -> list[VenueQuote]: ... + + def metadata(self, market_ref: Any) -> VenueMarketMeta | None: ... + + +def parse_iso_datetime(value: Any) -> datetime | None: + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _as_float(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +# --- Kalshi ---------------------------------------------------------------- + +_KALSHI_SETTLED_STATUSES = {"settled", "finalized", "determined"} +_KALSHI_CLOSED_STATUSES = {"closed", "inactive"} + + +def _kalshi_status(raw_status: str, result: str) -> str: + status = raw_status.strip().lower() + if status in _KALSHI_SETTLED_STATUSES or result: + return "settled" + if status in _KALSHI_CLOSED_STATUSES: + return "closed" + if status == "paused": + return "paused" + return "open" + + +class KalshiVenueAdapter: + """Kalshi trade-api v2. Prices arrive in dollars-per-contract (0-1).""" + + venue = "kalshi" + + def __init__(self, base_url: str = KALSHI_BASE_URL, fetch: FetchJson = default_fetch_json): + self.base_url = base_url.rstrip("/") + self._fetch = fetch + + def raw_markets(self, tickers: Sequence[str]) -> list[dict[str, Any]]: + query = urllib.parse.urlencode( + {"tickers": ",".join(tickers), "limit": str(max(100, len(tickers)))} + ) + payload = self._fetch(f"{self.base_url}/markets?{query}") + return payload.get("markets", []) + + def quotes(self, market_refs: Sequence[str]) -> list[VenueQuote]: + fetched_at = datetime.now(timezone.utc) + wanted = {str(ref).upper() for ref in market_refs} + quotes = [] + for market in self.raw_markets(sorted(wanted)): + ticker = str(market.get("ticker", "")).upper() + if ticker not in wanted: + continue + quotes.append(self._quote_from_raw(market, fetched_at)) + return quotes + + def _quote_from_raw(self, market: dict[str, Any], fetched_at: datetime) -> VenueQuote: + bid = _as_float(market.get("yes_bid_dollars")) + ask = _as_float(market.get("yes_ask_dollars")) + last = _as_float(market.get("last_price_dollars")) + settlement = _as_float(market.get("settlement_value_dollars")) + result = str(market.get("result") or "").strip().lower() + status = _kalshi_status(str(market.get("status", "")), result) + if status == "settled": + if settlement is not None: + prob = settlement + elif result == "yes": + prob = 1.0 + elif result == "no": + prob = 0.0 + else: + prob = last + elif bid is not None and ask is not None: + prob = (bid + ask) / 2 + else: + prob = last + # Contracts settle at $1, so 24h contract volume doubles as an upper + # bound on USD notional; good enough as a popularity signal. + return VenueQuote( + venue=self.venue, + market_id=str(market.get("ticker", "")).upper(), + outcome="yes", + prob_mid=prob, + bid=bid, + ask=ask, + volume_usd=_as_float(market.get("volume_24h_fp") or market.get("volume_24h")), + liquidity_usd=_as_float(market.get("liquidity_dollars") or market.get("liquidity")), + status=status, + close_time=parse_iso_datetime(market.get("close_time")), + fetched_at=fetched_at, + ) + + def metadata(self, market_ref: str) -> VenueMarketMeta | None: + payload = self._fetch(f"{self.base_url}/markets/{urllib.parse.quote(str(market_ref))}") + market = payload.get("market") if isinstance(payload, dict) else None + if not market: + return None + result = str(market.get("result") or "").strip().lower() + return VenueMarketMeta( + venue=self.venue, + market_id=str(market.get("ticker", "")).upper(), + title=str(market.get("title") or market.get("yes_sub_title") or ""), + outcomes=["yes", "no"], + status=_kalshi_status(str(market.get("status", "")), result), + close_time=parse_iso_datetime(market.get("close_time")), + ) + + def discover(self, category: str, days: int) -> list[dict[str, Any]]: + params = {"status": "open", "limit": "200", "with_nested_markets": "true"} + if category: + params["series_ticker"] = category + payload = self._fetch(f"{self.base_url}/events?" + urllib.parse.urlencode(params)) + now = datetime.now(timezone.utc) + events = [] + for event in payload.get("events", []): + markets = event.get("markets") or [] + close_times = [parse_iso_datetime(m.get("close_time")) for m in markets] + close_times = [t for t in close_times if t is not None] + soonest = min(close_times) if close_times else None + if soonest is not None and (soonest - now).days > days: + continue + events.append( + { + "venue": self.venue, + "event_id": event.get("event_ticker"), + "title": event.get("title"), + "sub_title": event.get("sub_title"), + "close_time": soonest.isoformat() if soonest else None, + "markets": [m.get("ticker") for m in markets], + } + ) + events.sort(key=lambda item: item.get("close_time") or "9999") + return events + + +# --- Polymarket ------------------------------------------------------------ + + +@dataclass +class PolymarketMarketRef: + """One Gamma market plus which of its outcomes we want quotes for. + + ``outcomes`` maps the canonical outcome name used by the caller to the + outcome label as it appears in the Gamma ``outcomes`` array. Empty means + "quote every outcome under its native label". + """ + + market_id: str + outcomes: dict[str, str] | None = None + + +def _decode_gamma_list(value: Any) -> list[Any]: + # Gamma returns list fields (outcomes, outcomePrices, clobTokenIds) as + # JSON-encoded strings. + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + return [] + return value if isinstance(value, list) else [] + + +def _polymarket_status(market: dict[str, Any]) -> str: + if market.get("umaResolutionStatus") == "resolved": + return "settled" + if market.get("closed"): + return "closed" + if market.get("active") is False: + return "paused" + return "open" + + +class PolymarketVenueAdapter: + """Polymarket Gamma API: read-only, no auth, ~60 req/min budget.""" + + venue = "polymarket" + + def __init__( + self, + base_url: str = POLYMARKET_BASE_URL, + fetch: FetchJson = default_fetch_json, + ): + self.base_url = base_url.rstrip("/") + self._fetch = fetch + + def _raw_markets(self, market_ids: Sequence[str]) -> list[dict[str, Any]]: + if not market_ids: + return [] + query = "&".join( + "id=" + urllib.parse.quote(str(market_id)) for market_id in market_ids + ) + payload = self._fetch(f"{self.base_url}/markets?{query}") + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + return payload.get("markets", []) or payload.get("data", []) + return [] + + def quotes(self, market_refs: Sequence[PolymarketMarketRef]) -> list[VenueQuote]: + refs_by_id = {str(ref.market_id): ref for ref in market_refs} + fetched_at = datetime.now(timezone.utc) + quotes: list[VenueQuote] = [] + for market in self._raw_markets(list(refs_by_id)): + ref = refs_by_id.get(str(market.get("id", ""))) + if ref is None: + continue + quotes.extend(self._market_quotes(market, ref, fetched_at)) + return quotes + + def _market_quotes( + self, + market: dict[str, Any], + ref: PolymarketMarketRef, + fetched_at: datetime, + ) -> list[VenueQuote]: + outcomes = [str(name) for name in _decode_gamma_list(market.get("outcomes"))] + prices = [_as_float(p) for p in _decode_gamma_list(market.get("outcomePrices"))] + status = _polymarket_status(market) + volume = _as_float(market.get("volume24hr") or market.get("volumeNum") or market.get("volume")) + liquidity = _as_float(market.get("liquidityNum") or market.get("liquidity")) + close_time = parse_iso_datetime(market.get("endDate")) + best_bid = _as_float(market.get("bestBid")) + best_ask = _as_float(market.get("bestAsk")) + + if ref.outcomes: + gamma_to_canonical = { + gamma_name.casefold(): canonical + for canonical, gamma_name in ref.outcomes.items() + } + else: + gamma_to_canonical = {name.casefold(): name for name in outcomes} + + quotes = [] + for index, name in enumerate(outcomes): + canonical = gamma_to_canonical.get(name.casefold()) + if canonical is None: + continue + price = prices[index] if index < len(prices) else None + # bestBid/bestAsk describe the first outcome's book; mirror them + # for the complementary side of a binary market only. + bid = ask = None + if index == 0: + bid, ask = best_bid, best_ask + elif len(outcomes) == 2 and best_bid is not None and best_ask is not None: + bid, ask = 1.0 - best_ask, 1.0 - best_bid + quotes.append( + VenueQuote( + venue=self.venue, + market_id=str(market.get("id", "")), + outcome=canonical, + prob_mid=price, + bid=bid, + ask=ask, + volume_usd=volume, + liquidity_usd=liquidity, + status=status, + close_time=close_time, + fetched_at=fetched_at, + ) + ) + return quotes + + def metadata(self, market_ref: PolymarketMarketRef | str) -> VenueMarketMeta | None: + market_id = ( + market_ref.market_id + if isinstance(market_ref, PolymarketMarketRef) + else str(market_ref) + ) + markets = self._raw_markets([market_id]) + if not markets: + return None + market = markets[0] + return VenueMarketMeta( + venue=self.venue, + market_id=str(market.get("id", "")), + title=str(market.get("question") or ""), + outcomes=[str(name) for name in _decode_gamma_list(market.get("outcomes"))], + status=_polymarket_status(market), + close_time=parse_iso_datetime(market.get("endDate")), + ) + + def discover(self, category: str, days: int) -> list[dict[str, Any]]: + params = { + "closed": "false", + "limit": "100", + "order": "volume24hr", + "ascending": "false", + } + if category: + params["tag_slug"] = category + payload = self._fetch(f"{self.base_url}/events?" + urllib.parse.urlencode(params)) + events_raw = payload if isinstance(payload, list) else payload.get("events", []) + events = [] + for event in events_raw: + events.append( + { + "venue": self.venue, + "event_id": event.get("id"), + "title": event.get("title"), + "sub_title": event.get("slug"), + "close_time": event.get("endDate"), + "markets": [m.get("id") for m in event.get("markets") or []], + } + ) + return events + + +# --- Aggregation ----------------------------------------------------------- + + +def aggregate_probability(quotes: Sequence[VenueQuote]) -> float | None: + """Liquidity-weighted mid across venues for one canonical outcome. + + Settled quotes are ground truth and win outright. Open quotes with a + spread wider than AGGREGATION_SPREAD_CAP are dropped when at least one + tighter book exists. Liquidity weighting applies only when every + surviving quote reports liquidity; otherwise all sources weigh equally + (mixing known and unknown depth would silently bias the mix). + """ + priced = [q for q in quotes if q.prob_mid is not None] + if not priced: + return None + settled = [q for q in priced if q.status == "settled"] + if settled: + return settled[-1].prob_mid + live = [q for q in priced if q.status == "open"] + if not live: + return priced[-1].prob_mid + tight = [q for q in live if q.spread() is None or q.spread() <= AGGREGATION_SPREAD_CAP] + if tight: + live = tight + weights = [q.liquidity_usd for q in live] + if all(w is not None and w > 0 for w in weights): + total = sum(weights) + return sum(q.prob_mid * w for q, w in zip(live, weights)) / total + return sum(q.prob_mid for q in live) / len(live) + + +@dataclass +class VenueDivergence: + outcome: str + quote_a: VenueQuote + quote_b: VenueQuote + gap: float + + +def max_divergence( + quotes: Sequence[VenueQuote], + threshold: float = DIVERGENCE_THRESHOLD, +) -> VenueDivergence | None: + """Largest cross-venue gap on the same outcome, if it clears threshold.""" + open_quotes = [q for q in quotes if q.status == "open" and q.prob_mid is not None] + worst: VenueDivergence | None = None + for i, quote_a in enumerate(open_quotes): + for quote_b in open_quotes[i + 1 :]: + if quote_a.venue == quote_b.venue or quote_a.outcome != quote_b.outcome: + continue + gap = abs(quote_a.prob_mid - quote_b.prob_mid) + if gap >= threshold and (worst is None or gap > worst.gap): + worst = VenueDivergence(quote_a.outcome, quote_a, quote_b, gap) + return worst + + +def same_direction_jump(delta_a: float | None, delta_b: float | None, min_abs: float) -> bool: + """True when two venues moved the same way by at least min_abs each. + + This is the multi-source upgrade of the goal signal: one venue jumping is + "possible event, wait for confirmation"; two venues jumping together is a + high-confidence event regardless of category. + """ + if delta_a is None or delta_b is None: + return False + if abs(delta_a) < min_abs or abs(delta_b) < min_abs: + return False + return (delta_a > 0) == (delta_b > 0) diff --git a/tools/test_stackchan_match_setup.py b/tools/test_stackchan_match_setup.py index 18861ea..bc0bb56 100644 --- a/tools/test_stackchan_match_setup.py +++ b/tools/test_stackchan_match_setup.py @@ -81,6 +81,42 @@ def test_extracts_event_ticker_from_kalshi_url(self): self.assertEqual(value, "KXWCADVANCE-26JUL10ESPBEL") + def test_event_markets_narrows_winner_event_to_active_finalists(self): + # A tournament-winner event lists every team; once it is down to the + # final, only the two finalists stay active while eliminated teams + # settle. event_markets must resolve that to the two-way final. + winner_event = { + "event": { + "event_ticker": "KXMENWORLDCUP-26", + "title": "Men's World Cup Winner", + "markets": [ + {"ticker": "KXMENWORLDCUP-26-AR", "yes_sub_title": "Argentina", "status": "active"}, + {"ticker": "KXMENWORLDCUP-26-ES", "yes_sub_title": "Spain", "status": "active"}, + {"ticker": "KXMENWORLDCUP-26-FR", "yes_sub_title": "France", "status": "finalized"}, + {"ticker": "KXMENWORLDCUP-26-EN", "yes_sub_title": "England", "status": "finalized"}, + ], + } + } + _event, markets = setup.event_markets(winner_event) + self.assertEqual(len(markets), 2) + self.assertEqual( + {setup.market_team_name(market) for market in markets}, + {"Argentina", "Spain"}, + ) + + def test_event_markets_rejects_more_than_two_live_markets(self): + three_active = { + "event": { + "markets": [ + {"ticker": "T-A", "yes_sub_title": "A", "status": "active"}, + {"ticker": "T-B", "yes_sub_title": "B", "status": "active"}, + {"ticker": "T-C", "yes_sub_title": "C", "status": "active"}, + ] + } + } + with self.assertRaises(ValueError): + setup.event_markets(three_active) + def test_scoreboard_localizes_teams_and_preserves_utc_start(self): matches = setup.parse_scoreboard( SCOREBOARD, diff --git a/tools/test_stackchan_venues.py b/tools/test_stackchan_venues.py new file mode 100644 index 0000000..c81a9c2 --- /dev/null +++ b/tools/test_stackchan_venues.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + +import stackchan_venues as venues +from stackchan_venues import ( + KalshiVenueAdapter, + PolymarketMarketRef, + PolymarketVenueAdapter, + VenueQuote, + aggregate_probability, + max_divergence, + same_direction_jump, +) + +NOW = datetime(2026, 7, 19, 12, 0, tzinfo=timezone.utc) + + +def quote(**overrides) -> VenueQuote: + base = dict( + venue="kalshi", + market_id="M", + outcome="NYY", + prob_mid=0.5, + bid=None, + ask=None, + volume_usd=None, + liquidity_usd=None, + status="open", + close_time=None, + fetched_at=NOW, + ) + base.update(overrides) + return VenueQuote(**base) + + +class RecordingFetch: + def __init__(self, payload): + self.payload = payload + self.urls: list[str] = [] + + def __call__(self, url: str): + self.urls.append(url) + return self.payload + + +class KalshiAdapterTest(unittest.TestCase): + def test_raw_markets_preserves_legacy_url_shape(self) -> None: + fetch = RecordingFetch({"markets": []}) + adapter = KalshiVenueAdapter("https://example.test/v2", fetch=fetch) + adapter.raw_markets(["AAA", "BBB"]) + self.assertEqual( + fetch.urls, + ["https://example.test/v2/markets?tickers=AAA%2CBBB&limit=100"], + ) + + def test_quotes_normalizes_dollars_to_probability(self) -> None: + fetch = RecordingFetch( + { + "markets": [ + { + "ticker": "KXTEST-A", + "status": "active", + "yes_bid_dollars": "0.61", + "yes_ask_dollars": "0.65", + "last_price_dollars": "0.62", + "volume_24h_fp": "1500", + "liquidity_dollars": "5200", + "close_time": "2026-07-21T23:10:00Z", + } + ] + } + ) + adapter = KalshiVenueAdapter(fetch=fetch) + (result,) = adapter.quotes(["kxtest-a"]) + self.assertEqual(result.venue, "kalshi") + self.assertEqual(result.market_id, "KXTEST-A") + self.assertEqual(result.status, "open") + self.assertAlmostEqual(result.prob_mid, 0.63) + self.assertAlmostEqual(result.bid, 0.61) + self.assertAlmostEqual(result.ask, 0.65) + self.assertAlmostEqual(result.liquidity_usd, 5200.0) + self.assertEqual(result.close_time.year, 2026) + + def test_quotes_settled_market_uses_result(self) -> None: + fetch = RecordingFetch( + { + "markets": [ + {"ticker": "KXTEST-B", "status": "finalized", "result": "yes"}, + ] + } + ) + (result,) = KalshiVenueAdapter(fetch=fetch).quotes(["KXTEST-B"]) + self.assertEqual(result.status, "settled") + self.assertEqual(result.prob_mid, 1.0) + + def test_quotes_falls_back_to_last_price_without_book(self) -> None: + fetch = RecordingFetch( + { + "markets": [ + { + "ticker": "KXTEST-C", + "status": "active", + "last_price_dollars": "0.44", + } + ] + } + ) + (result,) = KalshiVenueAdapter(fetch=fetch).quotes(["KXTEST-C"]) + self.assertAlmostEqual(result.prob_mid, 0.44) + self.assertIsNone(result.spread()) + + +class PolymarketAdapterTest(unittest.TestCase): + GAMMA_MARKET = { + "id": "516871", + "question": "Yankees vs. Red Sox", + "outcomes": '["Yankees", "Red Sox"]', + "outcomePrices": '["0.58", "0.42"]', + "bestBid": 0.57, + "bestAsk": 0.59, + "volume24hr": 120000.5, + "liquidityNum": 30000.25, + "active": True, + "closed": False, + "endDate": "2026-07-22T03:00:00Z", + } + + def test_quotes_maps_canonical_outcomes(self) -> None: + fetch = RecordingFetch([self.GAMMA_MARKET]) + adapter = PolymarketVenueAdapter(fetch=fetch) + ref = PolymarketMarketRef( + market_id="516871", + outcomes={"NYY": "Yankees", "BOS": "Red Sox"}, + ) + results = adapter.quotes([ref]) + self.assertEqual(fetch.urls, ["https://gamma-api.polymarket.com/markets?id=516871"]) + by_outcome = {q.outcome: q for q in results} + self.assertEqual(set(by_outcome), {"NYY", "BOS"}) + self.assertAlmostEqual(by_outcome["NYY"].prob_mid, 0.58) + self.assertAlmostEqual(by_outcome["BOS"].prob_mid, 0.42) + self.assertAlmostEqual(by_outcome["NYY"].bid, 0.57) + self.assertAlmostEqual(by_outcome["BOS"].bid, 1.0 - 0.59) + self.assertAlmostEqual(by_outcome["NYY"].liquidity_usd, 30000.25) + self.assertEqual(by_outcome["NYY"].status, "open") + + def test_quotes_reports_closed_status(self) -> None: + market = dict(self.GAMMA_MARKET, closed=True) + adapter = PolymarketVenueAdapter(fetch=RecordingFetch([market])) + results = adapter.quotes([PolymarketMarketRef(market_id="516871")]) + self.assertTrue(all(q.status == "closed" for q in results)) + + def test_metadata_decodes_outcomes(self) -> None: + adapter = PolymarketVenueAdapter(fetch=RecordingFetch([self.GAMMA_MARKET])) + meta = adapter.metadata("516871") + self.assertEqual(meta.title, "Yankees vs. Red Sox") + self.assertEqual(meta.outcomes, ["Yankees", "Red Sox"]) + + +class AggregationTest(unittest.TestCase): + def test_liquidity_weighted_mid(self) -> None: + quotes = [ + quote(venue="kalshi", prob_mid=0.60, liquidity_usd=1000.0), + quote(venue="polymarket", prob_mid=0.50, liquidity_usd=3000.0), + ] + self.assertAlmostEqual(aggregate_probability(quotes), 0.525) + + def test_equal_weight_when_any_liquidity_unknown(self) -> None: + quotes = [ + quote(venue="kalshi", prob_mid=0.60, liquidity_usd=None), + quote(venue="polymarket", prob_mid=0.50, liquidity_usd=3000.0), + ] + self.assertAlmostEqual(aggregate_probability(quotes), 0.55) + + def test_wide_spread_quote_is_dropped_when_tighter_book_exists(self) -> None: + quotes = [ + quote(venue="kalshi", prob_mid=0.60, bid=0.40, ask=0.80), + quote(venue="polymarket", prob_mid=0.50, bid=0.49, ask=0.51), + ] + self.assertAlmostEqual(aggregate_probability(quotes), 0.50) + + def test_settled_quote_wins(self) -> None: + quotes = [ + quote(venue="kalshi", prob_mid=1.0, status="settled"), + quote(venue="polymarket", prob_mid=0.95), + ] + self.assertEqual(aggregate_probability(quotes), 1.0) + + def test_empty_and_unpriced_quotes(self) -> None: + self.assertIsNone(aggregate_probability([])) + self.assertIsNone(aggregate_probability([quote(prob_mid=None)])) + + def test_single_source_degrades_gracefully(self) -> None: + self.assertAlmostEqual(aggregate_probability([quote(prob_mid=0.7)]), 0.7) + + +class DivergenceTest(unittest.TestCase): + def test_reports_cross_venue_gap_over_threshold(self) -> None: + result = max_divergence( + [ + quote(venue="kalshi", prob_mid=0.62), + quote(venue="polymarket", prob_mid=0.51), + ] + ) + self.assertIsNotNone(result) + self.assertAlmostEqual(result.gap, 0.11) + self.assertEqual(result.outcome, "NYY") + + def test_ignores_small_gaps_same_venue_and_other_outcomes(self) -> None: + self.assertIsNone( + max_divergence( + [ + quote(venue="kalshi", prob_mid=0.62), + quote(venue="polymarket", prob_mid=0.57), + ] + ) + ) + self.assertIsNone( + max_divergence( + [ + quote(venue="kalshi", prob_mid=0.10), + quote(venue="kalshi", prob_mid=0.90), + ] + ) + ) + self.assertIsNone( + max_divergence( + [ + quote(venue="kalshi", outcome="NYY", prob_mid=0.10), + quote(venue="polymarket", outcome="BOS", prob_mid=0.90), + ] + ) + ) + + +class SameDirectionJumpTest(unittest.TestCase): + def test_requires_both_venues_over_threshold_same_sign(self) -> None: + self.assertTrue(same_direction_jump(0.06, 0.05, 0.05)) + self.assertTrue(same_direction_jump(-0.08, -0.05, 0.05)) + self.assertFalse(same_direction_jump(0.06, -0.06, 0.05)) + self.assertFalse(same_direction_jump(0.06, 0.03, 0.05)) + self.assertFalse(same_direction_jump(None, 0.06, 0.05)) + + +MODULE_PATH = Path(__file__).with_name("stackchan_kalshi_watch.py") +SPEC = importlib.util.spec_from_file_location("stackchan_kalshi_watch", MODULE_PATH) +assert SPEC and SPEC.loader +watcher = importlib.util.module_from_spec(SPEC) +sys.modules.setdefault(SPEC.name, watcher) +SPEC.loader.exec_module(watcher) + + +def two_way_bar_config(**bar_overrides) -> "watcher.WatchConfig": + france = watcher.MarketConfig("FRA", "法国晋级") + morocco = watcher.MarketConfig("MAR", "摩洛哥晋级") + bar = watcher.ProbabilityBarConfig( + enabled=True, + mode="normalized_outcomes", + market_ticker="FRA", + right_market_ticker="MAR", + left_flag="fr", + left_color="#0055A4", + right_flag="ma", + right_color="#C1272D", + **bar_overrides, + ) + return watcher.WatchConfig(probability_bar=bar, markets=[france, morocco]) + + +def live_snapshots() -> dict: + return { + "FRA": watcher.MarketSnapshot("FRA", "法国晋级", "active", "E", 77, 79, 21, 23, 78, "", None), + "MAR": watcher.MarketSnapshot("MAR", "摩洛哥晋级", "active", "E", 23, 25, 75, 77, 24, "", None), + } + + +class AggregatedProbabilityBarTest(unittest.TestCase): + def test_without_venue_quotes_matches_single_source_behavior(self) -> None: + command = watcher.persistent_display_command(two_way_bar_config(), live_snapshots()) + self.assertEqual(command, "pkbar fr 76 0055A4 ma 24 C1272D") + + def test_polymarket_quote_shifts_aggregate(self) -> None: + # Kalshi says 78, Polymarket says 50 with unknown liquidity on the + # Kalshi side -> equal weight mean 64 vs right 24 -> 73%. + extra = {"left": [quote(venue="polymarket", prob_mid=0.50)]} + command = watcher.persistent_display_command( + two_way_bar_config(), live_snapshots(), extra + ) + self.assertEqual(command, "pkbar fr 73 0055A4 ma 27 C1272D") + + def test_polymarket_only_renders_when_kalshi_missing(self) -> None: + extra = { + "left": [quote(venue="polymarket", outcome="left", prob_mid=0.60)], + "right": [quote(venue="polymarket", outcome="right", prob_mid=0.40)], + } + command = watcher.persistent_display_command(two_way_bar_config(), {}, extra) + self.assertEqual(command, "pkbar fr 60 0055A4 ma 40 C1272D") + + +class VenueQuoteFromSnapshotTest(unittest.TestCase): + def test_open_snapshot_maps_book_to_probability_space(self) -> None: + snapshot = watcher.MarketSnapshot( + "FRA", "法国晋级", "active", "E", 61, 65, 35, 39, 62, "1500", None, + liquidity_usd=5200.0, + ) + converted = watcher.venue_quote_from_snapshot(snapshot, "left") + self.assertEqual(converted.status, "open") + self.assertEqual(converted.outcome, "left") + self.assertAlmostEqual(converted.prob_mid, 0.63) + self.assertAlmostEqual(converted.bid, 0.61) + self.assertAlmostEqual(converted.liquidity_usd, 5200.0) + + def test_settled_snapshot_reports_settlement(self) -> None: + snapshot = watcher.MarketSnapshot( + "FRA", "法国晋级", "finalized", "E", 0, 100, 0, 100, 99, "", None, + result="yes", settlement_value_cents=100, + ) + converted = watcher.venue_quote_from_snapshot(snapshot, "left") + self.assertEqual(converted.status, "settled") + self.assertEqual(converted.prob_mid, 1.0) + + +class VenueDivergenceAlertTest(unittest.TestCase): + def divergence(self) -> venues.VenueDivergence: + return venues.VenueDivergence( + outcome="left", + quote_a=quote(venue="kalshi", prob_mid=0.62), + quote_b=quote(venue="polymarket", prob_mid=0.51), + gap=0.11, + ) + + def test_chinese_alert_is_informational(self) -> None: + config = two_way_bar_config() + alert = watcher.venue_divergence_alert(config, self.divergence(), "法国晋级") + self.assertEqual(alert.kind, "venue_divergence") + self.assertEqual(alert.priority, 60) + self.assertTrue(alert.spoiler_sensitive) + self.assertIn("Kalshi", alert.balloon) + self.assertIn("Polymarket", alert.balloon) + self.assertIn("62", alert.speech) + self.assertIn("51", alert.speech) + for banned in ("买", "卖", "下注", "套利"): + self.assertNotIn(banned, alert.speech) + + def test_english_alert_names_both_venues(self) -> None: + config = two_way_bar_config() + config.language = "en" + alert = watcher.venue_divergence_alert(config, self.divergence(), "France to advance") + self.assertIn("Kalshi says 62 percent", alert.speech) + self.assertIn("Polymarket says 51 percent", alert.speech) + + +class CorroborateGoalSignalTest(unittest.TestCase): + def test_upgrade_boosts_priority_and_wording(self) -> None: + config = two_way_bar_config() + base = watcher.Alert( + ticker="FRA", + label="法国晋级", + kind="market_goal_signal", + priority=930, + face="happy", + balloon="盘口突变 +6", + speech="盘口突然拉升!", + detail="rapid yes move 60c -> 66c", + clip_id="odds-up", + spoiler_sensitive=True, + ) + upgraded = watcher.corroborate_goal_signal_alert(base, config) + self.assertEqual(upgraded.priority, 960) + self.assertTrue(upgraded.balloon.startswith("双平台确认")) + self.assertIn("可信度很高", upgraded.speech) + self.assertIn("corroborated by polymarket", upgraded.detail) + # The original stays untouched (replace, not mutation). + self.assertEqual(base.priority, 930) + + +class BarPolymarketRefTest(unittest.TestCase): + def test_none_without_mapping(self) -> None: + self.assertIsNone(watcher.bar_polymarket_ref(two_way_bar_config())) + + def test_none_when_outcomes_incomplete(self) -> None: + config = two_way_bar_config( + polymarket_market_id="516871", + polymarket_left_outcome="Yankees", + ) + self.assertIsNone(watcher.bar_polymarket_ref(config)) + + def test_ref_when_fully_configured(self) -> None: + config = two_way_bar_config( + polymarket_market_id="516871", + polymarket_left_outcome="Yankees", + polymarket_right_outcome="Red Sox", + ) + ref = watcher.bar_polymarket_ref(config) + self.assertEqual(ref.market_id, "516871") + self.assertEqual(ref.outcomes, {"left": "Yankees", "right": "Red Sox"}) + + def test_none_when_polymarket_disabled(self) -> None: + config = two_way_bar_config( + polymarket_market_id="516871", + polymarket_left_outcome="Yankees", + polymarket_right_outcome="Red Sox", + ) + config.polymarket.enabled = False + self.assertIsNone(watcher.bar_polymarket_ref(config)) + + +class VenueConfigLoadingTest(unittest.TestCase): + BASE_CONFIG = { + "language": "zh", + "markets": [ + {"ticker": "KXNYY", "label": {"zh": "扬基获胜", "en": "Yankees win"}}, + {"ticker": "KXBOS", "label": {"zh": "红袜获胜", "en": "Red Sox win"}}, + ], + "probability_bar": { + "enabled": True, + "mode": "normalized_outcomes", + "market_ticker": "KXNYY", + "right_market_ticker": "KXBOS", + "left_flag": "us", + "right_flag": "us", + "polymarket": { + "market_id": "516871", + "left_outcome": "Yankees", + "right_outcome": "Red Sox", + }, + }, + "polymarket": {"enabled": True, "poll_seconds": 20}, + } + + def load(self, config_dict) -> "watcher.WatchConfig": + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: + json.dump(config_dict, handle) + path = Path(handle.name) + try: + return watcher.load_config(path) + finally: + path.unlink() + + def test_parses_polymarket_sections(self) -> None: + config = self.load(self.BASE_CONFIG) + self.assertEqual(config.probability_bar.polymarket_market_id, "516871") + self.assertEqual(config.probability_bar.polymarket_left_outcome, "Yankees") + self.assertEqual(config.probability_bar.polymarket_right_outcome, "Red Sox") + self.assertTrue(config.polymarket.enabled) + self.assertEqual(config.polymarket.poll_seconds, 20) + watcher.validate_config(config, dry_run=True) + + def test_poll_seconds_floor_protects_rate_limit(self) -> None: + raw = json.loads(json.dumps(self.BASE_CONFIG)) + raw["polymarket"]["poll_seconds"] = 1 + config = self.load(raw) + self.assertEqual(config.polymarket.poll_seconds, 15) + + def test_validate_rejects_incomplete_outcome_mapping(self) -> None: + raw = json.loads(json.dumps(self.BASE_CONFIG)) + raw["probability_bar"]["polymarket"].pop("right_outcome") + config = self.load(raw) + with self.assertRaises(watcher.ConfigError): + watcher.validate_config(config, dry_run=True) + + +if __name__ == "__main__": + unittest.main()