From 23888b06bce1e59ddd535e243a17ea57bffdd07b Mon Sep 17 00:00:00 2001 From: Xiaoyi He Date: Wed, 15 Jul 2026 18:36:31 -0700 Subject: [PATCH 1/5] setup: resolve tournament-winner markets down to the two live finalists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The World Cup final's Kalshi market is the outright winner event (KXMENWORLDCUP-26), which lists all 48 teams as separate markets — so event_markets rejected it with "only supports exactly two mutually-exclusive team markets". Once the bracket reaches the final, the eliminated teams settle and only the two finalists stay "active", so narrow markets to the live ones before the two-market check. Verified end-to-end: resolving the KXMENWORLDCUP URL now yields Spain vs Argentina and matches ESPN event 760517. Normal two-market advance events are unaffected (len==2 skips the filter). Co-Authored-By: Claude Fable 5 --- tools/stackchan_match_setup.py | 8 +++++++ tools/test_stackchan_match_setup.py | 36 +++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) 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/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, From f1b9705305f32c5fa487f0c75aa9e2f38e3165dd Mon Sep 17 00:00:00 2001 From: Xiaoyi He Date: Thu, 16 Jul 2026 10:17:18 -0700 Subject: [PATCH 2/5] http: close each connection exactly once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watchdog timeout closed the connection and then the rejection it caused in the in-flight #handle chain closed the same connection again — a double close on one socket. Guard with a closeOnce() flag so a connection is closed at most once regardless of which path (watchdog vs handler rejection) fires first. Found while auditing our own socket usage before attributing the recurring tcpReceive UAF to the SDK. (The vendored listener is byte-identical to the stock Moddable listen.js apart from the promise-observation guards.) Co-Authored-By: Claude Fable 5 --- mod/http-server-safe.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) 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() }) } From 9bbc281a3719a774f7647a785b7531a886b423d7 Mon Sep 17 00:00:00 2001 From: Xiaoyi He Date: Sun, 19 Jul 2026 20:34:12 -0700 Subject: [PATCH 3/5] venues: extract VenueAdapter and aggregate Kalshi + Polymarket quotes (P1+P2) P1: new stackchan_venues.py holds the VenueQuote normalized model (probabilities 0-1, USD money) and the VenueAdapter contract, with KalshiVenueAdapter as the first implementation. The watcher's Kalshi fetch now goes through the adapter; snapshot parsing and all 194 existing tests are unchanged. P2: PolymarketVenueAdapter reads the Gamma API (no auth, 15s minimum poll). The probability bar renders a liquidity-weighted mid across venues when probability_bar.polymarket maps its sides onto a Gamma market, falling back to equal weights without liquidity data and to single-venue rendering when a platform is down (log-only). Venues disagreeing by 8+ points emit an informational venue_divergence alert; a Kalshi goal signal corroborated by a same-direction Polymarket jump within 90s upgrades to a dual-venue, high-confidence callout. Co-Authored-By: Claude Fable 5 --- tools/stackchan_kalshi_watch.py | 385 ++++++++++++++++++++++++-- tools/stackchan_venues.py | 466 +++++++++++++++++++++++++++++++ tools/test_stackchan_venues.py | 470 ++++++++++++++++++++++++++++++++ 3 files changed, 1301 insertions(+), 20 deletions(-) create mode 100644 tools/stackchan_venues.py create mode 100644 tools/test_stackchan_venues.py diff --git a/tools/stackchan_kalshi_watch.py b/tools/stackchan_kalshi_watch.py index e9e3bc0..48dbd36 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 {} @@ -790,6 +843,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, @@ -850,6 +904,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) @@ -869,6 +933,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 @@ -966,13 +1039,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 @@ -992,6 +1063,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] @@ -4180,6 +4254,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 @@ -5040,26 +5204,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 "" @@ -5079,8 +5279,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: @@ -5089,8 +5293,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) @@ -5242,10 +5451,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: @@ -5338,6 +5559,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) @@ -5675,6 +5903,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 @@ -5738,6 +6048,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 @@ -5745,7 +6081,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 ) @@ -5755,7 +6095,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_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_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() From 9a6ebaaac1d035b8a0d2e1bc61f0c85a39173b3d Mon Sep 17 00:00:00 2001 From: Xiaoyi He Date: Sun, 19 Jul 2026 20:34:22 -0700 Subject: [PATCH 4/5] config: seed the pairing registry and market-pairing skill; EPL example Adds the multi-venue roadmap PRD, the market-pairing agent skill (three-source candidate fetcher plus pairing workflow), and a first pairing_registry.json with three agent-proposed, human-unconfirmed MLB pairings (Dodgers-Phillies x2, Pirates-Yankees). The example watchlist documents the new polymarket and probability_bar.polymarket sections and points fixture discovery at KXEPLGAME for the Premier League season. Co-Authored-By: Claude Fable 5 --- agent-skills/market-pairing/SKILL.md | 136 +++++++++ .../scripts/fetch_pairing_candidates.py | 280 ++++++++++++++++++ config/kalshi_watchlist.example.json | 14 +- config/pairing_registry.json | 115 +++++++ docs/multi-venue-roadmap-prd.zh-CN.md | 261 ++++++++++++++++ 5 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 agent-skills/market-pairing/SKILL.md create mode 100644 agent-skills/market-pairing/scripts/fetch_pairing_candidates.py create mode 100644 config/pairing_registry.json create mode 100644 docs/multi-venue-roadmap-prd.zh-CN.md 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/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)后补齐。 From 0b0a746b377968341c0fb54de649762e5f75f09d Mon Sep 17 00:00:00 2001 From: Xiaoyi He Date: Sun, 19 Jul 2026 20:34:22 -0700 Subject: [PATCH 5/5] docs(1.7.0): venue adapter contract, multi-venue configuration, release notes Fixes the VenueAdapter contract as docs/venue-adapter-api.zh-CN.md per the roadmap's P1 exit criteria, documents multi-venue aggregation in both configuration guides, adds the bilingual 1.7.0 release notes, and refreshes README/getting-started/development cross-references. Co-Authored-By: Claude Fable 5 --- README.md | 6 ++- README.zh-CN.md | 4 +- docs/configuration.md | 36 +++++++++++++ docs/configuration.zh-CN.md | 31 ++++++++++++ docs/development.md | 15 ++++-- docs/development.zh-CN.md | 5 +- docs/getting-started.md | 6 ++- docs/releases/1.7.0.md | 89 +++++++++++++++++++++++++++++++++ docs/venue-adapter-api.zh-CN.md | 87 ++++++++++++++++++++++++++++++++ 9 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 docs/releases/1.7.0.md create mode 100644 docs/venue-adapter-api.zh-CN.md diff --git a/README.md b/README.md index b616aa8..90f4a49 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,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). @@ -148,7 +149,8 @@ not the primary QR flow. | [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 d413340..3896a08 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。 ## 系统设计 @@ -156,6 +157,7 @@ Kalshi + ESPN ──只读──> Python watcher ──HTTP──> Stack-chan Ma | 调用设备命令、状态与 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) | diff --git a/docs/configuration.md b/docs/configuration.md index 1799bc1..7270f78 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -196,6 +196,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 21848a8..96ab1c6 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -200,3 +200,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 6a11a30..3798a4f 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 5edfcd3..f870681 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 1cc25a4..bec4c91 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/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 起消费。