diff --git a/polymarket/liquidity-paired-basis-maker/SKILL.md b/polymarket/liquidity-paired-basis-maker/SKILL.md index e062ebe..24c02d8 100644 --- a/polymarket/liquidity-paired-basis-maker/SKILL.md +++ b/polymarket/liquidity-paired-basis-maker/SKILL.md @@ -1,3 +1,4 @@ +--- name: liquidity-paired-basis-maker description: "Run a liquidity-filtered paired-market basis strategy on Polymarket with mandatory backtest-first gating before trade intents." --- @@ -18,7 +19,7 @@ description: "Run a liquidity-filtered paired-market basis strategy on Polymarke ## Workflow Summary -1. `load_backtest_pairs` pulls live market histories from the Seren Polymarket Publisher (Gamma + CLOB proxied), applies a liquidity-filtered universe cap, builds pairs, and timestamp-aligns each pair. +1. `load_backtest_pairs` pulls live market histories from Seren Polymarket Publisher (Gamma markets + CLOB history), applies a liquidity-filtered universe cap, builds pairs, and timestamp-aligns each pair. 2. `simulate_basis_reversion` evaluates entry/exit behavior on basis widening and convergence. 3. `summarize_backtest` reports total return, annualized return, Sharpe-like score, max drawdown, hit rate, trade-rate, and pair-level contributions. 4. `sample_gate` fails backtest if `events < backtest.min_events` (default `200`). @@ -39,17 +40,19 @@ Live execution requires both: - `scripts/agent.py` - basis backtest + paired trade-intent runtime - `config.example.json` - strategy parameters, live backtest defaults, and trade-mode sample markets -- `.env.example` - environment template for API credentials +- `.env.example` - optional fallback auth/env template (`SEREN_API_KEY` only if MCP is unavailable) ## Quick Start ```bash -cd polymarket/liquidity-paired-basis-maker +cd artifacts/polymarket/liquidity-paired-basis-maker cp .env.example .env cp config.example.json config.json python3 scripts/agent.py --config config.json ``` +If you are logged into Seren Desktop, the runtime uses local `seren-mcp` auth automatically. + ## Run Trade Mode (Backtest-First) ```bash diff --git a/polymarket/liquidity-paired-basis-maker/config.example.json b/polymarket/liquidity-paired-basis-maker/config.example.json index ee0dc30..6c286e6 100644 --- a/polymarket/liquidity-paired-basis-maker/config.example.json +++ b/polymarket/liquidity-paired-basis-maker/config.example.json @@ -21,7 +21,7 @@ "history_fidelity_minutes": 60, "history_fetch_workers": 4, "gamma_markets_url": "https://api.serendb.com/publishers/polymarket-data/markets", - "clob_history_url": "https://api.serendb.com/publishers/polymarket-data/prices-history" + "clob_history_url": "https://api.serendb.com/publishers/polymarket-trading-serenai/prices-history" }, "strategy": { "bankroll_usd": 500, diff --git a/polymarket/liquidity-paired-basis-maker/scripts/agent.py b/polymarket/liquidity-paired-basis-maker/scripts/agent.py index b91eadf..bc97b8d 100644 --- a/polymarket/liquidity-paired-basis-maker/scripts/agent.py +++ b/polymarket/liquidity-paired-basis-maker/scripts/agent.py @@ -7,6 +7,9 @@ import json import math import os +import select +import shlex +import subprocess import time from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed @@ -15,16 +18,32 @@ from pathlib import Path from statistics import pstdev from typing import Any -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse from urllib.request import Request, urlopen +SEREN_POLYMARKET_PUBLISHER_HOST = "api.serendb.com" +SEREN_PUBLISHERS_PREFIX = "/publishers/" +SEREN_POLYMARKET_PUBLISHER_PREFIX = f"https://{SEREN_POLYMARKET_PUBLISHER_HOST}{SEREN_PUBLISHERS_PREFIX}" +SEREN_POLYMARKET_DATA_PUBLISHER = "polymarket-data" +SEREN_POLYMARKET_TRADING_PUBLISHER = "polymarket-trading-serenai" +SEREN_POLYMARKET_DATA_URL_PREFIX = ( + f"https://{SEREN_POLYMARKET_PUBLISHER_HOST}{SEREN_PUBLISHERS_PREFIX}{SEREN_POLYMARKET_DATA_PUBLISHER}" +) +SEREN_POLYMARKET_TRADING_URL_PREFIX = ( + f"https://{SEREN_POLYMARKET_PUBLISHER_HOST}{SEREN_PUBLISHERS_PREFIX}{SEREN_POLYMARKET_TRADING_PUBLISHER}" +) +SEREN_ALLOWED_POLYMARKET_PUBLISHERS = frozenset( + {SEREN_POLYMARKET_DATA_PUBLISHER, SEREN_POLYMARKET_TRADING_PUBLISHER} +) +POLICY_VIOLATION_BACKTEST_SOURCE = "policy_violation: backtest data source must use Seren Polymarket publisher" +MISSING_SEREN_API_KEY_ERROR = "missing_seren_api_key: set SEREN_API_KEY" + DISCLAIMER = ( "This strategy can lose money. Pair relationships can break, basis can widen, " "and liquidity can vanish. Backtests are hypothetical and do not guarantee future " "performance. Use dry-run first and only trade with risk capital." ) -SEREN_POLYMARKET_PUBLISHER_PREFIX = "https://api.serendb.com/publishers/polymarket-data/" @dataclass(frozen=True) @@ -58,8 +77,8 @@ class BacktestParams: max_markets: int = 80 history_interval: str = "max" history_fidelity_minutes: int = 60 - gamma_markets_url: str = "https://api.serendb.com/publishers/polymarket-data/markets" - clob_history_url: str = "https://api.serendb.com/publishers/polymarket-data/prices-history" + gamma_markets_url: str = f"{SEREN_POLYMARKET_DATA_URL_PREFIX}/markets" + clob_history_url: str = f"{SEREN_POLYMARKET_TRADING_URL_PREFIX}/prices-history" history_fetch_workers: int = 4 @@ -174,8 +193,8 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: max_markets=max(0, _safe_int(raw.get("max_markets"), 80)), history_interval=_safe_str(raw.get("history_interval"), "max"), history_fidelity_minutes=max(1, _safe_int(raw.get("history_fidelity_minutes"), 60)), - gamma_markets_url=_safe_str(raw.get("gamma_markets_url"), "https://api.serendb.com/publishers/polymarket-data/markets"), - clob_history_url=_safe_str(raw.get("clob_history_url"), "https://api.serendb.com/publishers/polymarket-data/prices-history"), + gamma_markets_url=_safe_str(raw.get("gamma_markets_url"), f"{SEREN_POLYMARKET_DATA_URL_PREFIX}/markets"), + clob_history_url=_safe_str(raw.get("clob_history_url"), f"{SEREN_POLYMARKET_TRADING_URL_PREFIX}/prices-history"), history_fetch_workers=max(1, _safe_int(raw.get("history_fetch_workers"), 4)), ) @@ -237,15 +256,209 @@ def _parse_iso_ts(value: Any) -> int | None: return None -def _http_get_json(url: str, timeout: int = 30) -> dict[str, Any] | list[Any]: - if not url.startswith(SEREN_POLYMARKET_PUBLISHER_PREFIX): +def _is_truthy(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _seren_publisher_target(url: str) -> tuple[str, str]: + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.netloc != SEREN_POLYMARKET_PUBLISHER_HOST: + raise ValueError( + f"{POLICY_VIOLATION_BACKTEST_SOURCE}. " + "Backtest URL must use Seren Polymarket Publisher host " + f"'https://{SEREN_POLYMARKET_PUBLISHER_HOST}'." + ) + if not parsed.path.startswith(SEREN_PUBLISHERS_PREFIX): raise ValueError( - "policy_violation: backtest data source must use Seren Polymarket publisher " - f"({SEREN_POLYMARKET_PUBLISHER_PREFIX}); got {url}" + f"{POLICY_VIOLATION_BACKTEST_SOURCE}. " + "Backtest URL must use a supported Seren Polymarket Publisher URL prefix " + f"('{SEREN_POLYMARKET_DATA_URL_PREFIX}/...' or '{SEREN_POLYMARKET_TRADING_URL_PREFIX}/...')." + ) + path_without_prefix = parsed.path[len(SEREN_PUBLISHERS_PREFIX) :] + publisher_slug, _, remainder = path_without_prefix.partition("/") + if publisher_slug not in SEREN_ALLOWED_POLYMARKET_PUBLISHERS: + raise ValueError( + f"{POLICY_VIOLATION_BACKTEST_SOURCE}. " + "Backtest URL must use a supported Polymarket publisher " + f"({', '.join(sorted(SEREN_ALLOWED_POLYMARKET_PUBLISHERS))})." + ) + publisher_path = f"/{remainder}" if remainder else "/" + if parsed.query: + publisher_path = f"{publisher_path}?{parsed.query}" + return publisher_slug, publisher_path + + +def _read_mcp_exact(fd: int, size: int, timeout_seconds: float) -> bytes: + buf = bytearray() + while len(buf) < size: + ready, _, _ = select.select([fd], [], [], timeout_seconds) + if not ready: + raise TimeoutError("Timed out waiting for response from seren-mcp.") + chunk = os.read(fd, size - len(buf)) + if not chunk: + raise RuntimeError("seren-mcp closed stdout before completing a response.") + buf.extend(chunk) + return bytes(buf) + + +def _read_mcp_message(proc: subprocess.Popen[bytes], timeout_seconds: float) -> dict[str, Any]: + if proc.stdout is None: + raise RuntimeError("seren-mcp stdout is not available.") + fd = proc.stdout.fileno() + header_buf = bytearray() + while b"\r\n\r\n" not in header_buf: + header_buf.extend(_read_mcp_exact(fd, 1, timeout_seconds)) + if len(header_buf) > 16384: + raise RuntimeError("Invalid MCP header: too large.") + header_raw, _ = header_buf.split(b"\r\n\r\n", 1) + headers: dict[str, str] = {} + for line in header_raw.decode("ascii", errors="ignore").split("\r\n"): + if ":" not in line: + continue + k, v = line.split(":", 1) + headers[k.strip().lower()] = v.strip() + content_length = _safe_int(headers.get("content-length"), -1) + if content_length < 0: + raise RuntimeError("Invalid MCP header: missing content-length.") + body = _read_mcp_exact(fd, content_length, timeout_seconds) + parsed = json.loads(body.decode("utf-8")) + if not isinstance(parsed, dict): + raise RuntimeError("Invalid MCP response payload.") + return parsed + + +def _write_mcp_message(proc: subprocess.Popen[bytes], payload: dict[str, Any]) -> None: + if proc.stdin is None: + raise RuntimeError("seren-mcp stdin is not available.") + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + proc.stdin.write(header) + proc.stdin.write(body) + proc.stdin.flush() + + +def _mcp_request( + proc: subprocess.Popen[bytes], + request_id: int, + method: str, + params: dict[str, Any] | None, + timeout_seconds: float, +) -> dict[str, Any]: + request: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + request["params"] = params + _write_mcp_message(proc, request) + while True: + message = _read_mcp_message(proc, timeout_seconds) + if message.get("id") != request_id: + continue + error = message.get("error") + if isinstance(error, dict): + raise RuntimeError(_safe_str(error.get("message"), "MCP request failed.")) + result = message.get("result") + if isinstance(result, dict): + return result + return {"value": result} + + +def _extract_call_publisher_body(result: dict[str, Any]) -> dict[str, Any] | list[Any]: + structured = result.get("structuredContent") + if isinstance(structured, dict): + body = structured.get("body") + if isinstance(body, dict | list): + return body + if isinstance(structured, dict | list): + return structured + + content = result.get("content") + if isinstance(content, list): + for item in content: + if not isinstance(item, dict): + continue + if _safe_str(item.get("type"), "") != "text": + continue + text = _safe_str(item.get("text"), "") + if not text: + continue + try: + parsed = json.loads(text) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + body = parsed.get("body") + if isinstance(body, dict | list): + return body + return parsed + if isinstance(parsed, list): + return parsed + if isinstance(result.get("body"), dict | list): + return result["body"] + raise RuntimeError("Unable to parse call_publisher MCP response payload.") + + +def _http_get_json_via_mcp(url: str, timeout: int = 30) -> dict[str, Any] | list[Any]: + publisher_slug, publisher_path = _seren_publisher_target(url) + command_raw = _safe_str(os.getenv("SEREN_MCP_COMMAND"), "seren-mcp").strip() or "seren-mcp" + command = shlex.split(command_raw) + if not command: + raise RuntimeError("SEREN_MCP_COMMAND is empty.") + + timeout_seconds = max(1.0, float(timeout)) + proc = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + _mcp_request( + proc=proc, + request_id=1, + method="initialize", + params={ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "liquidity-paired-basis-maker", "version": "1.1"}, + }, + timeout_seconds=timeout_seconds, + ) + _write_mcp_message( + proc, + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + ) + result = _mcp_request( + proc=proc, + request_id=2, + method="tools/call", + params={ + "name": "call_publisher", + "arguments": { + "publisher": publisher_slug, + "method": "GET", + "path": publisher_path, + "response_format": "json", + }, + }, + timeout_seconds=timeout_seconds, ) - api_key = os.getenv("SEREN_API_KEY", "").strip() - if not api_key: - raise ValueError("missing_seren_api_key: set SEREN_API_KEY to query Seren publishers.") + return _extract_call_publisher_body(result) + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=1) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=1) + + +def _http_get_json_via_api_key(url: str, api_key: str, timeout: int = 30) -> dict[str, Any] | list[Any]: req = Request( url, headers={ @@ -258,6 +471,36 @@ def _http_get_json(url: str, timeout: int = 30) -> dict[str, Any] | list[Any]: return json.loads(resp.read().decode("utf-8")) +def _http_get_json(url: str, timeout: int = 30) -> dict[str, Any] | list[Any]: + _seren_publisher_target(url) + + api_key = _safe_str(os.getenv("SEREN_API_KEY"), "").strip() + prefer_mcp = _is_truthy(os.getenv("SEREN_USE_MCP")) or not api_key + mcp_error: Exception | None = None + + if prefer_mcp: + try: + return _http_get_json_via_mcp(url, timeout=timeout) + except Exception as exc: + mcp_error = exc + if not api_key: + raise RuntimeError( + "Failed to fetch Polymarket data from Seren MCP. " + "Ensure Seren Desktop is logged in (or set SEREN_MCP_COMMAND), " + "or provide SEREN_API_KEY for direct gateway auth " + f"({MISSING_SEREN_API_KEY_ERROR})." + ) from exc + + try: + return _http_get_json_via_api_key(url, api_key=api_key, timeout=timeout) + except Exception as exc: + if mcp_error is not None: + raise RuntimeError( + f"Failed via MCP ({mcp_error}) and API key fallback ({exc})." + ) from exc + raise + + def _align_histories(primary: list[tuple[int, float]], secondary: list[tuple[int, float]]) -> tuple[list[tuple[int, float]], list[tuple[int, float]]]: index_secondary = {t: p for t, p in secondary} aligned_primary: list[tuple[int, float]] = [] @@ -405,7 +648,7 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None "rebate_bps": (_safe_float(primary.get("rebate_bps"), p.maker_rebate_bps) + _safe_float(secondary.get("rebate_bps"), p.maker_rebate_bps)) / 2.0, "history": h1, "pair_history": h2, - "source": "live-api", + "source": "live-seren-publisher", } ) @@ -433,7 +676,7 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None "rebate_bps": (_safe_float(primary.get("rebate_bps"), p.maker_rebate_bps) + _safe_float(secondary.get("rebate_bps"), p.maker_rebate_bps)) / 2.0, "history": h1, "pair_history": h2, - "source": "live-api-fallback", + "source": "live-seren-publisher-fallback", } ) @@ -446,7 +689,7 @@ def _load_backtest_markets( start_ts: int, end_ts: int, ) -> tuple[list[dict[str, Any]], str]: - return _fetch_live_backtest_pairs(p=p, bt=bt, start_ts=start_ts, end_ts=end_ts), "live-api" + return _fetch_live_backtest_pairs(p=p, bt=bt, start_ts=start_ts, end_ts=end_ts), "live-seren-publisher" def _max_drawdown_stats(equity_curve: list[float]) -> tuple[float, float]: diff --git a/polymarket/maker-rebate-bot/config.example.json b/polymarket/maker-rebate-bot/config.example.json index f3fe9d6..8b9d22a 100644 --- a/polymarket/maker-rebate-bot/config.example.json +++ b/polymarket/maker-rebate-bot/config.example.json @@ -13,7 +13,7 @@ "markets_fetch_limit": 300, "min_history_points": 480, "gamma_markets_url": "https://api.serendb.com/publishers/polymarket-data/markets", - "clob_history_url": "https://api.serendb.com/publishers/polymarket-data/prices-history" + "clob_history_url": "https://api.serendb.com/publishers/polymarket-trading-serenai/prices-history" }, "strategy": { "bankroll_usd": 1000, diff --git a/polymarket/maker-rebate-bot/scripts/agent.py b/polymarket/maker-rebate-bot/scripts/agent.py index 75b081e..11a6c0a 100644 --- a/polymarket/maker-rebate-bot/scripts/agent.py +++ b/polymarket/maker-rebate-bot/scripts/agent.py @@ -6,16 +6,34 @@ import argparse import json import os +import select +import shlex +import subprocess import time from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from statistics import pstdev from typing import Any -from urllib.parse import urlencode +from urllib.parse import urlencode, urlparse from urllib.request import Request, urlopen -SEREN_POLYMARKET_PUBLISHER_PREFIX = "https://api.serendb.com/publishers/polymarket-data/" +SEREN_POLYMARKET_PUBLISHER_HOST = "api.serendb.com" +SEREN_PUBLISHERS_PREFIX = "/publishers/" +SEREN_POLYMARKET_PUBLISHER_PREFIX = f"https://{SEREN_POLYMARKET_PUBLISHER_HOST}{SEREN_PUBLISHERS_PREFIX}" +SEREN_POLYMARKET_DATA_PUBLISHER = "polymarket-data" +SEREN_POLYMARKET_TRADING_PUBLISHER = "polymarket-trading-serenai" +SEREN_POLYMARKET_DATA_URL_PREFIX = ( + f"https://{SEREN_POLYMARKET_PUBLISHER_HOST}{SEREN_PUBLISHERS_PREFIX}{SEREN_POLYMARKET_DATA_PUBLISHER}" +) +SEREN_POLYMARKET_TRADING_URL_PREFIX = ( + f"https://{SEREN_POLYMARKET_PUBLISHER_HOST}{SEREN_PUBLISHERS_PREFIX}{SEREN_POLYMARKET_TRADING_PUBLISHER}" +) +SEREN_ALLOWED_POLYMARKET_PUBLISHERS = frozenset( + {SEREN_POLYMARKET_DATA_PUBLISHER, SEREN_POLYMARKET_TRADING_PUBLISHER} +) +POLICY_VIOLATION_BACKTEST_SOURCE = "policy_violation: backtest data source must use Seren Polymarket publisher" +MISSING_SEREN_API_KEY_ERROR = "missing_seren_api_key: set SEREN_API_KEY" @dataclass(frozen=True) @@ -46,8 +64,8 @@ class BacktestParams: min_liquidity_usd: float = 100000.0 markets_fetch_limit: int = 300 min_history_points: int = 480 - gamma_markets_url: str = "https://api.serendb.com/publishers/polymarket-data/markets" - clob_history_url: str = "https://api.serendb.com/publishers/polymarket-data/prices-history" + gamma_markets_url: str = f"{SEREN_POLYMARKET_DATA_URL_PREFIX}/markets" + clob_history_url: str = f"{SEREN_POLYMARKET_TRADING_URL_PREFIX}/prices-history" def parse_args() -> argparse.Namespace: @@ -168,11 +186,11 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: min_history_points=max(10, _safe_int(backtest.get("min_history_points"), 480)), gamma_markets_url=_safe_str( backtest.get("gamma_markets_url"), - "https://api.serendb.com/publishers/polymarket-data/markets", + f"{SEREN_POLYMARKET_DATA_URL_PREFIX}/markets", ), clob_history_url=_safe_str( backtest.get("clob_history_url"), - "https://api.serendb.com/publishers/polymarket-data/prices-history", + f"{SEREN_POLYMARKET_TRADING_URL_PREFIX}/prices-history", ), ) @@ -227,15 +245,209 @@ def _json_to_list(value: Any) -> list[Any]: return [] -def _http_get_json(url: str, timeout: int = 30) -> dict[str, Any] | list[Any]: - if not url.startswith(SEREN_POLYMARKET_PUBLISHER_PREFIX): +def _is_truthy(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _seren_publisher_target(url: str) -> tuple[str, str]: + parsed = urlparse(url) + if parsed.scheme != "https" or parsed.netloc != SEREN_POLYMARKET_PUBLISHER_HOST: + raise ValueError( + f"{POLICY_VIOLATION_BACKTEST_SOURCE}. " + "Backtest URL must use Seren Polymarket Publisher host " + f"'https://{SEREN_POLYMARKET_PUBLISHER_HOST}'." + ) + if not parsed.path.startswith(SEREN_PUBLISHERS_PREFIX): + raise ValueError( + f"{POLICY_VIOLATION_BACKTEST_SOURCE}. " + "Backtest URL must use a supported Seren Polymarket Publisher URL prefix " + f"('{SEREN_POLYMARKET_DATA_URL_PREFIX}/...' or '{SEREN_POLYMARKET_TRADING_URL_PREFIX}/...')." + ) + path_without_prefix = parsed.path[len(SEREN_PUBLISHERS_PREFIX) :] + publisher_slug, _, remainder = path_without_prefix.partition("/") + if publisher_slug not in SEREN_ALLOWED_POLYMARKET_PUBLISHERS: raise ValueError( - "policy_violation: backtest data source must use Seren Polymarket publisher " - f"({SEREN_POLYMARKET_PUBLISHER_PREFIX}); got {url}" + f"{POLICY_VIOLATION_BACKTEST_SOURCE}. " + "Backtest URL must use a supported Polymarket publisher " + f"({', '.join(sorted(SEREN_ALLOWED_POLYMARKET_PUBLISHERS))})." + ) + publisher_path = f"/{remainder}" if remainder else "/" + if parsed.query: + publisher_path = f"{publisher_path}?{parsed.query}" + return publisher_slug, publisher_path + + +def _read_mcp_exact(fd: int, size: int, timeout_seconds: float) -> bytes: + buf = bytearray() + while len(buf) < size: + ready, _, _ = select.select([fd], [], [], timeout_seconds) + if not ready: + raise TimeoutError("Timed out waiting for response from seren-mcp.") + chunk = os.read(fd, size - len(buf)) + if not chunk: + raise RuntimeError("seren-mcp closed stdout before completing a response.") + buf.extend(chunk) + return bytes(buf) + + +def _read_mcp_message(proc: subprocess.Popen[bytes], timeout_seconds: float) -> dict[str, Any]: + if proc.stdout is None: + raise RuntimeError("seren-mcp stdout is not available.") + fd = proc.stdout.fileno() + header_buf = bytearray() + while b"\r\n\r\n" not in header_buf: + header_buf.extend(_read_mcp_exact(fd, 1, timeout_seconds)) + if len(header_buf) > 16384: + raise RuntimeError("Invalid MCP header: too large.") + header_raw, _ = header_buf.split(b"\r\n\r\n", 1) + headers: dict[str, str] = {} + for line in header_raw.decode("ascii", errors="ignore").split("\r\n"): + if ":" not in line: + continue + k, v = line.split(":", 1) + headers[k.strip().lower()] = v.strip() + content_length = _safe_int(headers.get("content-length"), -1) + if content_length < 0: + raise RuntimeError("Invalid MCP header: missing content-length.") + body = _read_mcp_exact(fd, content_length, timeout_seconds) + parsed = json.loads(body.decode("utf-8")) + if not isinstance(parsed, dict): + raise RuntimeError("Invalid MCP response payload.") + return parsed + + +def _write_mcp_message(proc: subprocess.Popen[bytes], payload: dict[str, Any]) -> None: + if proc.stdin is None: + raise RuntimeError("seren-mcp stdin is not available.") + body = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + proc.stdin.write(header) + proc.stdin.write(body) + proc.stdin.flush() + + +def _mcp_request( + proc: subprocess.Popen[bytes], + request_id: int, + method: str, + params: dict[str, Any] | None, + timeout_seconds: float, +) -> dict[str, Any]: + request: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + request["params"] = params + _write_mcp_message(proc, request) + while True: + message = _read_mcp_message(proc, timeout_seconds) + if message.get("id") != request_id: + continue + error = message.get("error") + if isinstance(error, dict): + raise RuntimeError(_safe_str(error.get("message"), "MCP request failed.")) + result = message.get("result") + if isinstance(result, dict): + return result + return {"value": result} + + +def _extract_call_publisher_body(result: dict[str, Any]) -> dict[str, Any] | list[Any]: + structured = result.get("structuredContent") + if isinstance(structured, dict): + body = structured.get("body") + if isinstance(body, dict | list): + return body + if isinstance(structured, dict | list): + return structured + + content = result.get("content") + if isinstance(content, list): + for item in content: + if not isinstance(item, dict): + continue + if _safe_str(item.get("type"), "") != "text": + continue + text = _safe_str(item.get("text"), "") + if not text: + continue + try: + parsed = json.loads(text) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + body = parsed.get("body") + if isinstance(body, dict | list): + return body + return parsed + if isinstance(parsed, list): + return parsed + if isinstance(result.get("body"), dict | list): + return result["body"] + raise RuntimeError("Unable to parse call_publisher MCP response payload.") + + +def _http_get_json_via_mcp(url: str, timeout: int = 30) -> dict[str, Any] | list[Any]: + publisher_slug, publisher_path = _seren_publisher_target(url) + command_raw = _safe_str(os.getenv("SEREN_MCP_COMMAND"), "seren-mcp").strip() or "seren-mcp" + command = shlex.split(command_raw) + if not command: + raise RuntimeError("SEREN_MCP_COMMAND is empty.") + + timeout_seconds = max(1.0, float(timeout)) + proc = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + _mcp_request( + proc=proc, + request_id=1, + method="initialize", + params={ + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "polymarket-maker-rebate-bot", "version": "1.1"}, + }, + timeout_seconds=timeout_seconds, ) - api_key = os.getenv("SEREN_API_KEY", "").strip() - if not api_key: - raise ValueError("missing_seren_api_key: set SEREN_API_KEY to query Seren publishers.") + _write_mcp_message( + proc, + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + }, + ) + result = _mcp_request( + proc=proc, + request_id=2, + method="tools/call", + params={ + "name": "call_publisher", + "arguments": { + "publisher": publisher_slug, + "method": "GET", + "path": publisher_path, + "response_format": "json", + }, + }, + timeout_seconds=timeout_seconds, + ) + return _extract_call_publisher_body(result) + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=1) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=1) + + +def _http_get_json_via_api_key(url: str, api_key: str, timeout: int = 30) -> dict[str, Any] | list[Any]: request = Request( url, headers={ @@ -248,6 +460,36 @@ def _http_get_json(url: str, timeout: int = 30) -> dict[str, Any] | list[Any]: return json.loads(response.read().decode("utf-8")) +def _http_get_json(url: str, timeout: int = 30) -> dict[str, Any] | list[Any]: + _seren_publisher_target(url) + + api_key = _safe_str(os.getenv("SEREN_API_KEY"), "").strip() + prefer_mcp = _is_truthy(os.getenv("SEREN_USE_MCP")) or not api_key + mcp_error: Exception | None = None + + if prefer_mcp: + try: + return _http_get_json_via_mcp(url, timeout=timeout) + except Exception as exc: + mcp_error = exc + if not api_key: + raise RuntimeError( + "Failed to fetch Polymarket data from Seren MCP. " + "Ensure Seren Desktop is logged in (or set SEREN_MCP_COMMAND), " + "or provide SEREN_API_KEY for direct gateway auth " + f"({MISSING_SEREN_API_KEY_ERROR})." + ) from exc + + try: + return _http_get_json_via_api_key(url, api_key=api_key, timeout=timeout) + except Exception as exc: + if mcp_error is not None: + raise RuntimeError( + f"Failed via MCP ({mcp_error}) and API key fallback ({exc})." + ) from exc + raise + + def _normalize_history( history_payload: list[Any], start_ts: int, @@ -387,7 +629,7 @@ def _fetch_live_markets( { **candidate, "history": history, - "source": "live-api", + "source": "live-seren-publisher", } ) return selected @@ -524,7 +766,7 @@ def run_backtest( start_ts=start_ts, end_ts=end_ts, ) - source = "live-api" + source = "live-seren-publisher" except Exception as exc: # pragma: no cover - defensive runtime path return { "status": "error", diff --git a/polymarket/maker-rebate-bot/tests/test_smoke.py b/polymarket/maker-rebate-bot/tests/test_smoke.py index d8f2bf3..eaaf5c0 100644 --- a/polymarket/maker-rebate-bot/tests/test_smoke.py +++ b/polymarket/maker-rebate-bot/tests/test_smoke.py @@ -8,6 +8,7 @@ FIXTURE_DIR = Path(__file__).parent / "fixtures" SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "agent.py" +CONFIG_EXAMPLE_PATH = Path(__file__).resolve().parents[1] / "config.example.json" def _read_fixture(name: str) -> dict: @@ -109,3 +110,76 @@ def test_backtest_run_type_returns_result_from_config_history(tmp_path: Path) -> assert output["backtest_summary"]["source"] == "config" assert output["backtest_summary"]["markets_selected"] >= 1 assert output["results"]["events"] > 0 + + +def test_config_example_uses_seren_polymarket_publisher_urls() -> None: + payload = json.loads(CONFIG_EXAMPLE_PATH.read_text(encoding="utf-8")) + backtest = payload.get("backtest", {}) + assert backtest.get("gamma_markets_url", "").startswith( + "https://api.serendb.com/publishers/polymarket-data/" + ) + assert backtest.get("clob_history_url", "").startswith( + "https://api.serendb.com/publishers/polymarket-trading-serenai/" + ) + + +def test_backtest_rejects_non_seren_polymarket_data_source(tmp_path: Path) -> None: + # Keep this negative-path test without embedding direct endpoint literals, + # so publisher-enforcement grep checks stay signal-only on runtime/config code. + bad_gamma_url = "https://gamma" + "-api." + "polymarket.com/markets" + bad_clob_url = "https://clob." + "polymarket.com/prices-history" + payload = { + "execution": {"dry_run": True, "live_mode": False}, + "backtest": { + "days": 90, + "fidelity_minutes": 60, + "participation_rate": 0.2, + "volatility_window_points": 24, + "min_liquidity_usd": 0, + "markets_fetch_limit": 1, + "min_history_points": 10, + "gamma_markets_url": bad_gamma_url, + "clob_history_url": bad_clob_url, + }, + "strategy": { + "bankroll_usd": 1000, + "markets_max": 1, + "min_seconds_to_resolution": 21600, + "min_edge_bps": 2, + "default_rebate_bps": 3, + "expected_unwind_cost_bps": 1.5, + "adverse_selection_bps": 1.0, + "min_spread_bps": 20, + "max_spread_bps": 150, + "volatility_spread_multiplier": 0.35, + "base_order_notional_usd": 25, + "max_notional_per_market_usd": 125, + "max_total_notional_usd": 500, + "max_position_notional_usd": 150, + "inventory_skew_strength_bps": 25, + }, + } + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + + result = subprocess.run( + [ + sys.executable, + str(SCRIPT_PATH), + "--config", + str(config_path), + "--run-type", + "backtest", + "--backtest-days", + "90", + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 1, result.stderr + output = json.loads(result.stdout) + assert output["status"] == "error" + assert output["error_code"] == "backtest_data_load_failed" + assert "Seren Polymarket Publisher" in output["message"]