From 3242f1c78757a8634651f37d20ffc04fd30fc4c7 Mon Sep 17 00:00:00 2001 From: Taariq Lewis <701864+taariq@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:09:56 -0800 Subject: [PATCH] fix(polymarket): auto-fetch live markets in quote mode --- .../scripts/agent.py | 221 +++++++++++++++++- .../tests/test_smoke.py | 50 ++++ .../scripts/agent.py | 221 +++++++++++++++++- .../tests/test_smoke.py | 50 ++++ polymarket/maker-rebate-bot/scripts/agent.py | 150 +++++++++++- .../maker-rebate-bot/tests/test_smoke.py | 64 +++++ .../scripts/agent.py | 221 +++++++++++++++++- .../tests/test_smoke.py | 50 ++++ 8 files changed, 997 insertions(+), 30 deletions(-) diff --git a/polymarket/high-throughput-paired-basis-maker/scripts/agent.py b/polymarket/high-throughput-paired-basis-maker/scripts/agent.py index 4f6fb6d..7575c88 100644 --- a/polymarket/high-throughput-paired-basis-maker/scripts/agent.py +++ b/polymarket/high-throughput-paired-basis-maker/scripts/agent.py @@ -797,22 +797,215 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, } -def _load_trade_markets(config: dict[str, Any], markets_file: str | None) -> list[dict[str, Any]]: - if markets_file: - payload = load_json(Path(markets_file)) - else: - payload = config.get("markets", []) +def _extract_live_mid_price(payload: dict[str, Any]) -> float: + for key in ( + "mid_price", + "midPrice", + "midpoint", + "price", + "lastTradePrice", + "last_trade_price", + ): + candidate = _normalize_probability(payload.get(key)) + if 0.0 <= candidate <= 1.0: + return candidate + outcome_prices = _json_to_list(payload.get("outcomePrices")) + if outcome_prices: + candidate = _normalize_probability(outcome_prices[0]) + if 0.0 <= candidate <= 1.0: + return candidate + return -1.0 + +def _coerce_trade_rows(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, dict): rows = payload.get("markets", []) elif isinstance(payload, list): rows = payload else: rows = [] - return [row for row in rows if isinstance(row, dict)] +def _build_live_trade_pair( + primary: dict[str, Any], + secondary: dict[str, Any], + *, + event_id: str, + now_ts: int, + p: StrategyParams, +) -> dict[str, Any]: + end_ts = min( + _safe_int(primary.get("end_ts"), now_ts + 86400), + _safe_int(secondary.get("end_ts"), now_ts + 86400), + ) + basis_volatility_bps = abs( + (_safe_float(primary.get("mid_price"), 0.0) - _safe_float(secondary.get("mid_price"), 0.0)) + * 10000.0 + ) + return { + "market_id": _safe_str(primary.get("market_id"), "unknown"), + "pair_market_id": _safe_str(secondary.get("market_id"), "unknown"), + "question": _safe_str(primary.get("question"), _safe_str(primary.get("market_id"), "unknown")), + "pair_question": _safe_str(secondary.get("question"), _safe_str(secondary.get("market_id"), "unknown")), + "event_id": event_id, + "end_ts": end_ts, + "seconds_to_resolution": max(0, end_ts - now_ts), + "rebate_bps": ( + _safe_float(primary.get("rebate_bps"), p.maker_rebate_bps) + + _safe_float(secondary.get("rebate_bps"), p.maker_rebate_bps) + ) + / 2.0, + "mid_price": round(_safe_float(primary.get("mid_price"), 0.0), 6), + "pair_mid_price": round(_safe_float(secondary.get("mid_price"), 0.0), 6), + "basis_volatility_bps": round(basis_volatility_bps, 3), + "source": "live-api", + } + + +def _pair_live_trade_candidates( + candidates: list[dict[str, Any]], + *, + now_ts: int, + p: StrategyParams, +) -> list[dict[str, Any]]: + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in candidates: + grouped[_safe_str(row.get("event_id"), "misc")].append(row) + + pairs: list[dict[str, Any]] = [] + for event_id, group in grouped.items(): + if len(group) < 2: + continue + group_sorted = sorted(group, key=lambda row: _safe_float(row.get("volume24hr"), 0.0), reverse=True) + for i in range(len(group_sorted) - 1): + pairs.append( + _build_live_trade_pair( + primary=group_sorted[i], + secondary=group_sorted[i + 1], + event_id=event_id, + now_ts=now_ts, + p=p, + ) + ) + + if pairs: + return pairs + + fallback_sorted = sorted(candidates, key=lambda row: _safe_float(row.get("volume24hr"), 0.0), reverse=True) + for i in range(0, len(fallback_sorted) - 1, 2): + pairs.append( + _build_live_trade_pair( + primary=fallback_sorted[i], + secondary=fallback_sorted[i + 1], + event_id="fallback", + now_ts=now_ts, + p=p, + ) + ) + return pairs + + +def _fetch_live_trade_pairs(config: dict[str, Any]) -> list[dict[str, Any]]: + p = to_strategy_params(config) + bt = to_backtest_params(config) + now_ts = int(time.time()) + offset = 0 + candidates: list[dict[str, Any]] = [] + seen_token_ids: set[str] = set() + pages = 0 + + while True: + pages += 1 + if pages > 200: + break + query = urlencode( + { + "active": "true", + "closed": "false", + "limit": bt.markets_fetch_page_size, + "offset": offset, + "order": "volume24hr", + "ascending": "false", + } + ) + raw = _http_get_json(f"{bt.gamma_markets_url}?{query}") + if not isinstance(raw, list) or not raw: + break + + added_on_page = 0 + for market in raw: + if not isinstance(market, dict): + continue + liquidity = _safe_float(market.get("liquidity"), 0.0) + if liquidity < bt.min_liquidity_usd: + continue + + end_ts = ( + _parse_iso_ts(market.get("endDate")) + or _parse_iso_ts(market.get("endDateIso")) + or _safe_int(market.get("end_ts"), now_ts + 86400) + ) + seconds_to_resolution = max(0, end_ts - now_ts) + if seconds_to_resolution < p.min_seconds_to_resolution: + continue + + token_ids = _json_to_list(market.get("clobTokenIds")) + if not token_ids: + continue + token_id = _safe_str(token_ids[0], "") + if not token_id or token_id in seen_token_ids: + continue + seen_token_ids.add(token_id) + + mid_price = _extract_live_mid_price(market) + if not (0.01 < mid_price < 0.99): + continue + + events = _json_to_list(market.get("events")) + event_id = "" + if events and isinstance(events[0], dict): + event_id = _safe_str(events[0].get("id"), "") + if not event_id: + event_id = _safe_str(market.get("seriesSlug"), "") + if not event_id: + event_id = _safe_str(market.get("category"), "misc") + + market_id = _safe_str(market.get("id"), _safe_str(market.get("conditionId"), token_id)) + candidates.append( + { + "market_id": market_id, + "question": _safe_str(market.get("question"), market_id), + "token_id": token_id, + "event_id": event_id, + "end_ts": end_ts, + "seconds_to_resolution": seconds_to_resolution, + "rebate_bps": _safe_float(market.get("rebate_bps"), p.maker_rebate_bps), + "volume24hr": _safe_float(market.get("volume24hr"), 0.0), + "mid_price": mid_price, + } + ) + added_on_page += 1 + + if added_on_page == 0: + break + offset += len(raw) + if len(raw) < bt.markets_fetch_page_size: + break + + candidates_with_cap = candidates[: bt.max_markets] if bt.max_markets > 0 else candidates + return _pair_live_trade_candidates(candidates_with_cap, now_ts=now_ts, p=p) + + +def _load_trade_markets(config: dict[str, Any], markets_file: str | None) -> list[dict[str, Any]]: + if markets_file: + return _coerce_trade_rows(load_json(Path(markets_file))) + configured_markets = _coerce_trade_rows(config.get("markets", [])) + if configured_markets: + return configured_markets + return _fetch_live_trade_pairs(config) + + def _build_pair_trade(market: dict[str, Any], leg_exposure: dict[str, float], total_notional: float, p: StrategyParams) -> dict[str, Any]: market_id = _safe_str(market.get("market_id"), "unknown") pair_market_id = _safe_str(market.get("pair_market_id"), f"{market_id}-pair") @@ -902,7 +1095,21 @@ def run_trade(config: dict[str, Any], markets_file: str | None, yes_live: bool) p = to_strategy_params(config) exposure = config.get("state", {}).get("leg_exposure", {}) leg_exposure = {str(k): _safe_float(v, 0.0) for k, v in exposure.items()} - markets = _load_trade_markets(config, markets_file) + try: + markets = _load_trade_markets(config, markets_file) + except Exception as exc: # pragma: no cover - defensive runtime path + return { + "status": "error", + "skill": "high-throughput-paired-basis-maker", + "error_code": "trade_market_load_failed", + "message": str(exc), + "hint": ( + "Provide --markets-file with a saved trade market snapshot if " + "live market discovery is unavailable." + ), + "dry_run": True, + "disclaimer": DISCLAIMER, + } trades: list[dict[str, Any]] = [] skips: list[dict[str, Any]] = [] diff --git a/polymarket/high-throughput-paired-basis-maker/tests/test_smoke.py b/polymarket/high-throughput-paired-basis-maker/tests/test_smoke.py index fa09a5a..e525589 100644 --- a/polymarket/high-throughput-paired-basis-maker/tests/test_smoke.py +++ b/polymarket/high-throughput-paired-basis-maker/tests/test_smoke.py @@ -101,3 +101,53 @@ def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None assert output["status"] == "ok" assert output["results"]["starting_bankroll_usd"] == 1000 assert output["results"]["return_pct"] >= 20.0 + + +def test_trade_mode_fetches_live_pairs_when_config_markets_is_empty(monkeypatch) -> None: + module = _load_agent_module() + now_ts = int(time.time()) + fetched_urls: list[str] = [] + + def fake_http_get_json(url: str, timeout: int = 30): + fetched_urls.append(url) + return [ + { + "id": "LIVE-HT-1A", + "question": "Will event HT A resolve YES?", + "events": [{"id": "EVENT-HT-1"}], + "clobTokenIds": ["TOKEN-HT-1A"], + "outcomePrices": ["0.64", "0.36"], + "liquidity": 26000, + "volume24hr": 16000, + "rebate_bps": 2.3, + "endDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ts + 86400)), + }, + { + "id": "LIVE-HT-1B", + "question": "Will event HT A resolve NO?", + "events": [{"id": "EVENT-HT-1"}], + "clobTokenIds": ["TOKEN-HT-1B"], + "outcomePrices": ["0.39", "0.61"], + "liquidity": 24000, + "volume24hr": 13000, + "rebate_bps": 2.3, + "endDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ts + 86400)), + }, + ] + + monkeypatch.setattr(module, "_http_get_json", fake_http_get_json) + config = { + "execution": {"dry_run": True, "live_mode": False}, + "backtest": {"min_liquidity_usd": 0, "markets_fetch_page_size": 10, "max_markets": 2}, + "strategy": {"pairs_max": 1, "min_seconds_to_resolution": 60}, + "markets": [], + } + + result = module.run_trade(config=config, markets_file=None, yes_live=False) + + assert result["status"] == "ok" + assert result["strategy_summary"]["pairs_considered"] == 1 + assert result["strategy_summary"]["pairs_quoted"] == 1 + assert result["pair_trades"][0]["market_id"] == "LIVE-HT-1A" + assert result["pair_trades"][0]["pair_market_id"] == "LIVE-HT-1B" + assert any("/publishers/polymarket-data/markets?" in url for url in fetched_urls) diff --git a/polymarket/liquidity-paired-basis-maker/scripts/agent.py b/polymarket/liquidity-paired-basis-maker/scripts/agent.py index ccff357..cdcc6b2 100644 --- a/polymarket/liquidity-paired-basis-maker/scripts/agent.py +++ b/polymarket/liquidity-paired-basis-maker/scripts/agent.py @@ -859,22 +859,215 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, } -def _load_trade_markets(config: dict[str, Any], markets_file: str | None) -> list[dict[str, Any]]: - if markets_file: - payload = load_json(Path(markets_file)) - else: - payload = config.get("markets", []) +def _extract_live_mid_price(payload: dict[str, Any]) -> float: + for key in ( + "mid_price", + "midPrice", + "midpoint", + "price", + "lastTradePrice", + "last_trade_price", + ): + candidate = _normalize_probability(payload.get(key)) + if 0.0 <= candidate <= 1.0: + return candidate + outcome_prices = _json_to_list(payload.get("outcomePrices")) + if outcome_prices: + candidate = _normalize_probability(outcome_prices[0]) + if 0.0 <= candidate <= 1.0: + return candidate + return -1.0 + +def _coerce_trade_rows(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, dict): rows = payload.get("markets", []) elif isinstance(payload, list): rows = payload else: rows = [] - return [row for row in rows if isinstance(row, dict)] +def _build_live_trade_pair( + primary: dict[str, Any], + secondary: dict[str, Any], + *, + event_id: str, + now_ts: int, + p: StrategyParams, +) -> dict[str, Any]: + end_ts = min( + _safe_int(primary.get("end_ts"), now_ts + 86400), + _safe_int(secondary.get("end_ts"), now_ts + 86400), + ) + basis_volatility_bps = abs( + (_safe_float(primary.get("mid_price"), 0.0) - _safe_float(secondary.get("mid_price"), 0.0)) + * 10000.0 + ) + return { + "market_id": _safe_str(primary.get("market_id"), "unknown"), + "pair_market_id": _safe_str(secondary.get("market_id"), "unknown"), + "question": _safe_str(primary.get("question"), _safe_str(primary.get("market_id"), "unknown")), + "pair_question": _safe_str(secondary.get("question"), _safe_str(secondary.get("market_id"), "unknown")), + "event_id": event_id, + "end_ts": end_ts, + "seconds_to_resolution": max(0, end_ts - now_ts), + "rebate_bps": ( + _safe_float(primary.get("rebate_bps"), p.maker_rebate_bps) + + _safe_float(secondary.get("rebate_bps"), p.maker_rebate_bps) + ) + / 2.0, + "mid_price": round(_safe_float(primary.get("mid_price"), 0.0), 6), + "pair_mid_price": round(_safe_float(secondary.get("mid_price"), 0.0), 6), + "basis_volatility_bps": round(basis_volatility_bps, 3), + "source": "live-seren-publisher", + } + + +def _pair_live_trade_candidates( + candidates: list[dict[str, Any]], + *, + now_ts: int, + p: StrategyParams, +) -> list[dict[str, Any]]: + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in candidates: + grouped[_safe_str(row.get("event_id"), "misc")].append(row) + + pairs: list[dict[str, Any]] = [] + for event_id, group in grouped.items(): + if len(group) < 2: + continue + group_sorted = sorted(group, key=lambda row: _safe_float(row.get("volume24hr"), 0.0), reverse=True) + for i in range(len(group_sorted) - 1): + pairs.append( + _build_live_trade_pair( + primary=group_sorted[i], + secondary=group_sorted[i + 1], + event_id=event_id, + now_ts=now_ts, + p=p, + ) + ) + + if pairs: + return pairs + + fallback_sorted = sorted(candidates, key=lambda row: _safe_float(row.get("volume24hr"), 0.0), reverse=True) + for i in range(0, len(fallback_sorted) - 1, 2): + pairs.append( + _build_live_trade_pair( + primary=fallback_sorted[i], + secondary=fallback_sorted[i + 1], + event_id="fallback", + now_ts=now_ts, + p=p, + ) + ) + return pairs + + +def _fetch_live_trade_pairs(config: dict[str, Any]) -> list[dict[str, Any]]: + p = to_strategy_params(config) + bt = to_backtest_params(config) + now_ts = int(time.time()) + offset = 0 + candidates: list[dict[str, Any]] = [] + seen_token_ids: set[str] = set() + pages = 0 + + while True: + pages += 1 + if pages > 200: + break + query = urlencode( + { + "active": "true", + "closed": "false", + "limit": bt.markets_fetch_page_size, + "offset": offset, + "order": "volume24hr", + "ascending": "false", + } + ) + raw = _http_get_json(f"{bt.gamma_markets_url}?{query}") + if not isinstance(raw, list) or not raw: + break + + added_on_page = 0 + for market in raw: + if not isinstance(market, dict): + continue + liquidity = _safe_float(market.get("liquidity"), 0.0) + if liquidity < bt.min_liquidity_usd: + continue + + end_ts = ( + _parse_iso_ts(market.get("endDate")) + or _parse_iso_ts(market.get("endDateIso")) + or _safe_int(market.get("end_ts"), now_ts + 86400) + ) + seconds_to_resolution = max(0, end_ts - now_ts) + if seconds_to_resolution < p.min_seconds_to_resolution: + continue + + token_ids = _json_to_list(market.get("clobTokenIds")) + if not token_ids: + continue + token_id = _safe_str(token_ids[0], "") + if not token_id or token_id in seen_token_ids: + continue + seen_token_ids.add(token_id) + + mid_price = _extract_live_mid_price(market) + if not (0.01 < mid_price < 0.99): + continue + + events = _json_to_list(market.get("events")) + event_id = "" + if events and isinstance(events[0], dict): + event_id = _safe_str(events[0].get("id"), "") + if not event_id: + event_id = _safe_str(market.get("seriesSlug"), "") + if not event_id: + event_id = _safe_str(market.get("category"), "misc") + + market_id = _safe_str(market.get("id"), _safe_str(market.get("conditionId"), token_id)) + candidates.append( + { + "market_id": market_id, + "question": _safe_str(market.get("question"), market_id), + "token_id": token_id, + "event_id": event_id, + "end_ts": end_ts, + "seconds_to_resolution": seconds_to_resolution, + "rebate_bps": _safe_float(market.get("rebate_bps"), p.maker_rebate_bps), + "volume24hr": _safe_float(market.get("volume24hr"), 0.0), + "mid_price": mid_price, + } + ) + added_on_page += 1 + + if added_on_page == 0: + break + offset += len(raw) + if len(raw) < bt.markets_fetch_page_size: + break + + candidates_with_cap = candidates[: bt.max_markets] if bt.max_markets > 0 else candidates + return _pair_live_trade_candidates(candidates_with_cap, now_ts=now_ts, p=p) + + +def _load_trade_markets(config: dict[str, Any], markets_file: str | None) -> list[dict[str, Any]]: + if markets_file: + return _coerce_trade_rows(load_json(Path(markets_file))) + configured_markets = _coerce_trade_rows(config.get("markets", [])) + if configured_markets: + return configured_markets + return _fetch_live_trade_pairs(config) + + def _build_pair_trade(market: dict[str, Any], leg_exposure: dict[str, float], total_notional: float, p: StrategyParams) -> dict[str, Any]: market_id = _safe_str(market.get("market_id"), "unknown") pair_market_id = _safe_str(market.get("pair_market_id"), f"{market_id}-pair") @@ -964,7 +1157,21 @@ def run_trade(config: dict[str, Any], markets_file: str | None, yes_live: bool) p = to_strategy_params(config) exposure = config.get("state", {}).get("leg_exposure", {}) leg_exposure = {str(k): _safe_float(v, 0.0) for k, v in exposure.items()} - markets = _load_trade_markets(config, markets_file) + try: + markets = _load_trade_markets(config, markets_file) + except Exception as exc: # pragma: no cover - defensive runtime path + return { + "status": "error", + "skill": "liquidity-paired-basis-maker", + "error_code": "trade_market_load_failed", + "message": str(exc), + "hint": ( + "Provide --markets-file with a saved trade market snapshot if " + "live market discovery is unavailable." + ), + "dry_run": True, + "disclaimer": DISCLAIMER, + } trades: list[dict[str, Any]] = [] skips: list[dict[str, Any]] = [] diff --git a/polymarket/liquidity-paired-basis-maker/tests/test_smoke.py b/polymarket/liquidity-paired-basis-maker/tests/test_smoke.py index 56e0fdb..f13fa9a 100644 --- a/polymarket/liquidity-paired-basis-maker/tests/test_smoke.py +++ b/polymarket/liquidity-paired-basis-maker/tests/test_smoke.py @@ -101,3 +101,53 @@ def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None assert output["status"] == "ok" assert output["results"]["starting_bankroll_usd"] == 1000 assert output["results"]["return_pct"] >= 20.0 + + +def test_trade_mode_fetches_live_pairs_when_config_markets_is_empty(monkeypatch) -> None: + module = _load_agent_module() + now_ts = int(time.time()) + fetched_urls: list[str] = [] + + def fake_http_get_json(url: str, timeout: int = 30): + fetched_urls.append(url) + return [ + { + "id": "LIVE-LIQ-1A", + "question": "Will liquidity event A resolve YES?", + "events": [{"id": "EVENT-LIQ-1"}], + "clobTokenIds": ["TOKEN-LIQ-1A"], + "outcomePrices": ["0.63", "0.37"], + "liquidity": 25000, + "volume24hr": 14000, + "rebate_bps": 2.3, + "endDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ts + 86400)), + }, + { + "id": "LIVE-LIQ-1B", + "question": "Will liquidity event A resolve NO?", + "events": [{"id": "EVENT-LIQ-1"}], + "clobTokenIds": ["TOKEN-LIQ-1B"], + "outcomePrices": ["0.4", "0.6"], + "liquidity": 22000, + "volume24hr": 11000, + "rebate_bps": 2.3, + "endDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ts + 86400)), + }, + ] + + monkeypatch.setattr(module, "_http_get_json", fake_http_get_json) + config = { + "execution": {"dry_run": True, "live_mode": False}, + "backtest": {"min_liquidity_usd": 0, "markets_fetch_page_size": 10, "max_markets": 2}, + "strategy": {"pairs_max": 1, "min_seconds_to_resolution": 60}, + "markets": [], + } + + result = module.run_trade(config=config, markets_file=None, yes_live=False) + + assert result["status"] == "ok" + assert result["strategy_summary"]["pairs_considered"] == 1 + assert result["strategy_summary"]["pairs_quoted"] == 1 + assert result["pair_trades"][0]["market_id"] == "LIVE-LIQ-1A" + assert result["pair_trades"][0]["pair_market_id"] == "LIVE-LIQ-1B" + assert any("/publishers/polymarket-data/markets?" in url for url in fetched_urls) diff --git a/polymarket/maker-rebate-bot/scripts/agent.py b/polymarket/maker-rebate-bot/scripts/agent.py index 975d7e8..e8c8188 100644 --- a/polymarket/maker-rebate-bot/scripts/agent.py +++ b/polymarket/maker-rebate-bot/scripts/agent.py @@ -111,15 +111,23 @@ def load_config(config_path: str) -> dict[str, Any]: return load_json_file(Path(config_path)) +def _coerce_market_rows(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, dict): + rows = payload.get("markets", []) + elif isinstance(payload, list): + rows = payload + else: + rows = [] + return [row for row in rows if isinstance(row, dict)] + + def load_markets(config: dict[str, Any], markets_file: str | None) -> list[dict[str, Any]]: if markets_file: - payload = load_json_file(Path(markets_file)) - if isinstance(payload, dict) and isinstance(payload.get("markets"), list): - return payload["markets"] - if isinstance(payload, list): - return payload - return [] - return list(config.get("markets", [])) + return _coerce_market_rows(load_json_file(Path(markets_file))) + configured_markets = _coerce_market_rows(config.get("markets", [])) + if configured_markets: + return configured_markets + return _fetch_live_quote_markets(config) def _safe_float(value: Any, default: float = 0.0) -> float: @@ -146,6 +154,45 @@ def _safe_str(value: Any, default: str = "") -> str: return str(value) +def _extract_live_mid_price(payload: dict[str, Any]) -> float: + for key in ( + "mid_price", + "midPrice", + "midpoint", + "price", + "lastTradePrice", + "last_trade_price", + ): + candidate = _normalize_probability(payload.get(key)) + if 0.0 <= candidate <= 1.0: + return candidate + outcome_prices = _json_to_list(payload.get("outcomePrices")) + if outcome_prices: + candidate = _normalize_probability(outcome_prices[0]) + if 0.0 <= candidate <= 1.0: + return candidate + return -1.0 + + +def _extract_live_book(payload: dict[str, Any], mid_price: float) -> tuple[float, float]: + bid = _normalize_probability(payload.get("best_bid")) + if not (0.0 <= bid <= 1.0): + bid = _normalize_probability(payload.get("bestBid")) + + ask = _normalize_probability(payload.get("best_ask")) + if not (0.0 <= ask <= 1.0): + ask = _normalize_probability(payload.get("bestAsk")) + + if not (0.0 <= bid <= 1.0): + bid = mid_price + if not (0.0 <= ask <= 1.0): + ask = mid_price + if bid > ask: + bid = mid_price + ask = mid_price + return bid, ask + + def _canonicalize_history_url(url: str) -> str: parsed = urlparse(url) if parsed.path.endswith("/prices-history"): @@ -618,6 +665,74 @@ def _fetch_live_markets( return selected +def _fetch_live_quote_markets(config: dict[str, Any]) -> list[dict[str, Any]]: + strategy_params = to_params(config) + backtest_params = to_backtest_params(config) + now_ts = int(time.time()) + query = urlencode( + { + "active": "true", + "closed": "false", + "limit": backtest_params.markets_fetch_limit, + "order": "volume24hr", + "ascending": "false", + } + ) + raw = _http_get_json(f"{backtest_params.gamma_markets_url}?{query}") + if not isinstance(raw, list): + return [] + + markets: list[dict[str, Any]] = [] + for market in raw: + if not isinstance(market, dict): + continue + liquidity = _safe_float(market.get("liquidity"), 0.0) + if liquidity < backtest_params.min_liquidity_usd: + continue + + end_ts = ( + _parse_iso_ts(market.get("endDate")) + or _parse_iso_ts(market.get("endDateIso")) + or _safe_int(market.get("end_ts"), 0) + ) + seconds_to_resolution = max(0, end_ts - now_ts) if end_ts else 0 + if seconds_to_resolution < strategy_params.min_seconds_to_resolution: + continue + + token_ids = _json_to_list(market.get("clobTokenIds")) + if not token_ids: + continue + token_id = _safe_str(token_ids[0], "") + if not token_id: + continue + + mid_price = _extract_live_mid_price(market) + if not (0.01 < mid_price < 0.99): + continue + + best_bid, best_ask = _extract_live_book(market, mid_price) + volatility_bps = max(abs(best_ask - best_bid) * 10000.0, strategy_params.min_spread_bps) + market_id = _safe_str(market.get("id"), _safe_str(market.get("conditionId"), token_id)) + markets.append( + { + "market_id": market_id, + "question": _safe_str(market.get("question"), market_id), + "token_id": token_id, + "mid_price": round(mid_price, 6), + "best_bid": round(best_bid, 6), + "best_ask": round(best_ask, 6), + "seconds_to_resolution": seconds_to_resolution, + "volatility_bps": round(volatility_bps, 3), + "rebate_bps": _safe_float(market.get("rebate_bps"), strategy_params.default_rebate_bps), + "source": "live-seren-publisher", + } + ) + if len(markets) >= strategy_params.markets_max: + break + + return markets + + def _max_drawdown(equity_curve: list[float]) -> float: peak = float("-inf") max_dd = 0.0 @@ -1004,6 +1119,24 @@ def run_once( } +def run_quote(config: dict[str, Any], markets_file: str | None, yes_live: bool) -> dict[str, Any]: + try: + markets = load_markets(config=config, markets_file=markets_file) + except Exception as exc: # pragma: no cover - defensive runtime path + return { + "status": "error", + "skill": "polymarket-maker-rebate-bot", + "error_code": "quote_market_load_failed", + "message": str(exc), + "hint": ( + "Provide --markets-file with a saved market snapshot if " + "live market discovery is unavailable." + ), + "dry_run": True, + } + return run_once(config=config, markets=markets, yes_live=yes_live) + + def main() -> int: args = parse_args() config = load_config(args.config) @@ -1014,8 +1147,7 @@ def main() -> int: backtest_days_override=args.backtest_days, ) else: - markets = load_markets(config=config, markets_file=args.markets_file) - result = run_once(config=config, markets=markets, yes_live=args.yes_live) + result = run_quote(config=config, markets_file=args.markets_file, yes_live=args.yes_live) print(json.dumps(result, sort_keys=True)) return 0 if result.get("status") == "ok" else 1 diff --git a/polymarket/maker-rebate-bot/tests/test_smoke.py b/polymarket/maker-rebate-bot/tests/test_smoke.py index 95d589c..6decb91 100644 --- a/polymarket/maker-rebate-bot/tests/test_smoke.py +++ b/polymarket/maker-rebate-bot/tests/test_smoke.py @@ -1,5 +1,6 @@ from __future__ import annotations +import importlib.util import json import subprocess import sys @@ -15,6 +16,15 @@ def _read_fixture(name: str) -> dict: return json.loads((FIXTURE_DIR / name).read_text(encoding="utf-8")) +def _load_agent_module(): + spec = importlib.util.spec_from_file_location("maker_rebate_bot_agent", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + def test_happy_path_fixture_is_successful() -> None: payload = _read_fixture("happy_path.json") assert payload["status"] == "ok" @@ -29,6 +39,60 @@ def test_negative_edge_fixture_skips_all_quotes() -> None: assert payload["strategy_summary"]["markets_skipped"] >= 1 +def test_quote_mode_fetches_live_markets_when_config_markets_is_empty(monkeypatch) -> None: + agent = _load_agent_module() + now_ts = int(time.time()) + fetched_urls: list[str] = [] + + def fake_http_get_json(url: str, timeout: int = 30): + fetched_urls.append(url) + return [ + { + "id": "LIVE-MKT-1", + "question": "Will event A happen?", + "clobTokenIds": ["TOKEN-1"], + "outcomePrices": ["0.48", "0.52"], + "bestBid": 0.47, + "bestAsk": 0.49, + "liquidity": 500000, + "volume24hr": 100000, + "rebate_bps": 2.5, + "endDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ts + 86400)), + }, + { + "id": "LIVE-MKT-2", + "question": "Will event B happen?", + "clobTokenIds": ["TOKEN-2"], + "outcomePrices": ["0.61", "0.39"], + "bestBid": 0.6, + "bestAsk": 0.62, + "liquidity": 450000, + "volume24hr": 90000, + "rebate_bps": 3.0, + "endDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ts + 172800)), + }, + ] + + monkeypatch.setattr(agent, "_http_get_json", fake_http_get_json) + config = { + "execution": {"dry_run": True, "live_mode": False}, + "backtest": {"min_liquidity_usd": 0, "markets_fetch_limit": 5}, + "strategy": { + "markets_max": 2, + "min_seconds_to_resolution": 60, + "min_spread_bps": 20, + }, + "markets": [], + } + + result = agent.run_quote(config=config, markets_file=None, yes_live=False) + + assert result["status"] == "ok" + assert result["strategy_summary"]["markets_considered"] == 2 + assert result["strategy_summary"]["markets_quoted"] == 2 + assert any("/publishers/polymarket-data/markets?" in url for url in fetched_urls) + + def test_live_guard_fixture_blocks_execution() -> None: payload = _read_fixture("live_guard.json") assert payload["status"] == "error" diff --git a/polymarket/paired-market-basis-maker/scripts/agent.py b/polymarket/paired-market-basis-maker/scripts/agent.py index 4dd27d1..2a3786b 100644 --- a/polymarket/paired-market-basis-maker/scripts/agent.py +++ b/polymarket/paired-market-basis-maker/scripts/agent.py @@ -797,22 +797,215 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, } -def _load_trade_markets(config: dict[str, Any], markets_file: str | None) -> list[dict[str, Any]]: - if markets_file: - payload = load_json(Path(markets_file)) - else: - payload = config.get("markets", []) +def _extract_live_mid_price(payload: dict[str, Any]) -> float: + for key in ( + "mid_price", + "midPrice", + "midpoint", + "price", + "lastTradePrice", + "last_trade_price", + ): + candidate = _normalize_probability(payload.get(key)) + if 0.0 <= candidate <= 1.0: + return candidate + outcome_prices = _json_to_list(payload.get("outcomePrices")) + if outcome_prices: + candidate = _normalize_probability(outcome_prices[0]) + if 0.0 <= candidate <= 1.0: + return candidate + return -1.0 + +def _coerce_trade_rows(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, dict): rows = payload.get("markets", []) elif isinstance(payload, list): rows = payload else: rows = [] - return [row for row in rows if isinstance(row, dict)] +def _build_live_trade_pair( + primary: dict[str, Any], + secondary: dict[str, Any], + *, + event_id: str, + now_ts: int, + p: StrategyParams, +) -> dict[str, Any]: + end_ts = min( + _safe_int(primary.get("end_ts"), now_ts + 86400), + _safe_int(secondary.get("end_ts"), now_ts + 86400), + ) + basis_volatility_bps = abs( + (_safe_float(primary.get("mid_price"), 0.0) - _safe_float(secondary.get("mid_price"), 0.0)) + * 10000.0 + ) + return { + "market_id": _safe_str(primary.get("market_id"), "unknown"), + "pair_market_id": _safe_str(secondary.get("market_id"), "unknown"), + "question": _safe_str(primary.get("question"), _safe_str(primary.get("market_id"), "unknown")), + "pair_question": _safe_str(secondary.get("question"), _safe_str(secondary.get("market_id"), "unknown")), + "event_id": event_id, + "end_ts": end_ts, + "seconds_to_resolution": max(0, end_ts - now_ts), + "rebate_bps": ( + _safe_float(primary.get("rebate_bps"), p.maker_rebate_bps) + + _safe_float(secondary.get("rebate_bps"), p.maker_rebate_bps) + ) + / 2.0, + "mid_price": round(_safe_float(primary.get("mid_price"), 0.0), 6), + "pair_mid_price": round(_safe_float(secondary.get("mid_price"), 0.0), 6), + "basis_volatility_bps": round(basis_volatility_bps, 3), + "source": "live-api", + } + + +def _pair_live_trade_candidates( + candidates: list[dict[str, Any]], + *, + now_ts: int, + p: StrategyParams, +) -> list[dict[str, Any]]: + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in candidates: + grouped[_safe_str(row.get("event_id"), "misc")].append(row) + + pairs: list[dict[str, Any]] = [] + for event_id, group in grouped.items(): + if len(group) < 2: + continue + group_sorted = sorted(group, key=lambda row: _safe_float(row.get("volume24hr"), 0.0), reverse=True) + for i in range(len(group_sorted) - 1): + pairs.append( + _build_live_trade_pair( + primary=group_sorted[i], + secondary=group_sorted[i + 1], + event_id=event_id, + now_ts=now_ts, + p=p, + ) + ) + + if pairs: + return pairs + + fallback_sorted = sorted(candidates, key=lambda row: _safe_float(row.get("volume24hr"), 0.0), reverse=True) + for i in range(0, len(fallback_sorted) - 1, 2): + pairs.append( + _build_live_trade_pair( + primary=fallback_sorted[i], + secondary=fallback_sorted[i + 1], + event_id="fallback", + now_ts=now_ts, + p=p, + ) + ) + return pairs + + +def _fetch_live_trade_pairs(config: dict[str, Any]) -> list[dict[str, Any]]: + p = to_strategy_params(config) + bt = to_backtest_params(config) + now_ts = int(time.time()) + offset = 0 + candidates: list[dict[str, Any]] = [] + seen_token_ids: set[str] = set() + pages = 0 + + while True: + pages += 1 + if pages > 200: + break + query = urlencode( + { + "active": "true", + "closed": "false", + "limit": bt.markets_fetch_page_size, + "offset": offset, + "order": "volume24hr", + "ascending": "false", + } + ) + raw = _http_get_json(f"{bt.gamma_markets_url}?{query}") + if not isinstance(raw, list) or not raw: + break + + added_on_page = 0 + for market in raw: + if not isinstance(market, dict): + continue + liquidity = _safe_float(market.get("liquidity"), 0.0) + if liquidity < bt.min_liquidity_usd: + continue + + end_ts = ( + _parse_iso_ts(market.get("endDate")) + or _parse_iso_ts(market.get("endDateIso")) + or _safe_int(market.get("end_ts"), now_ts + 86400) + ) + seconds_to_resolution = max(0, end_ts - now_ts) + if seconds_to_resolution < p.min_seconds_to_resolution: + continue + + token_ids = _json_to_list(market.get("clobTokenIds")) + if not token_ids: + continue + token_id = _safe_str(token_ids[0], "") + if not token_id or token_id in seen_token_ids: + continue + seen_token_ids.add(token_id) + + mid_price = _extract_live_mid_price(market) + if not (0.01 < mid_price < 0.99): + continue + + events = _json_to_list(market.get("events")) + event_id = "" + if events and isinstance(events[0], dict): + event_id = _safe_str(events[0].get("id"), "") + if not event_id: + event_id = _safe_str(market.get("seriesSlug"), "") + if not event_id: + event_id = _safe_str(market.get("category"), "misc") + + market_id = _safe_str(market.get("id"), _safe_str(market.get("conditionId"), token_id)) + candidates.append( + { + "market_id": market_id, + "question": _safe_str(market.get("question"), market_id), + "token_id": token_id, + "event_id": event_id, + "end_ts": end_ts, + "seconds_to_resolution": seconds_to_resolution, + "rebate_bps": _safe_float(market.get("rebate_bps"), p.maker_rebate_bps), + "volume24hr": _safe_float(market.get("volume24hr"), 0.0), + "mid_price": mid_price, + } + ) + added_on_page += 1 + + if added_on_page == 0: + break + offset += len(raw) + if len(raw) < bt.markets_fetch_page_size: + break + + candidates_with_cap = candidates[: bt.max_markets] if bt.max_markets > 0 else candidates + return _pair_live_trade_candidates(candidates_with_cap, now_ts=now_ts, p=p) + + +def _load_trade_markets(config: dict[str, Any], markets_file: str | None) -> list[dict[str, Any]]: + if markets_file: + return _coerce_trade_rows(load_json(Path(markets_file))) + configured_markets = _coerce_trade_rows(config.get("markets", [])) + if configured_markets: + return configured_markets + return _fetch_live_trade_pairs(config) + + def _build_pair_trade(market: dict[str, Any], leg_exposure: dict[str, float], total_notional: float, p: StrategyParams) -> dict[str, Any]: market_id = _safe_str(market.get("market_id"), "unknown") pair_market_id = _safe_str(market.get("pair_market_id"), f"{market_id}-pair") @@ -902,7 +1095,21 @@ def run_trade(config: dict[str, Any], markets_file: str | None, yes_live: bool) p = to_strategy_params(config) exposure = config.get("state", {}).get("leg_exposure", {}) leg_exposure = {str(k): _safe_float(v, 0.0) for k, v in exposure.items()} - markets = _load_trade_markets(config, markets_file) + try: + markets = _load_trade_markets(config, markets_file) + except Exception as exc: # pragma: no cover - defensive runtime path + return { + "status": "error", + "skill": "paired-market-basis-maker", + "error_code": "trade_market_load_failed", + "message": str(exc), + "hint": ( + "Provide --markets-file with a saved trade market snapshot if " + "live market discovery is unavailable." + ), + "dry_run": True, + "disclaimer": DISCLAIMER, + } trades: list[dict[str, Any]] = [] skips: list[dict[str, Any]] = [] diff --git a/polymarket/paired-market-basis-maker/tests/test_smoke.py b/polymarket/paired-market-basis-maker/tests/test_smoke.py index 705b3ff..eb7c7e3 100644 --- a/polymarket/paired-market-basis-maker/tests/test_smoke.py +++ b/polymarket/paired-market-basis-maker/tests/test_smoke.py @@ -101,3 +101,53 @@ def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None assert output["status"] == "ok" assert output["results"]["starting_bankroll_usd"] == 1000 assert output["results"]["return_pct"] >= 20.0 + + +def test_trade_mode_fetches_live_pairs_when_config_markets_is_empty(monkeypatch) -> None: + module = _load_agent_module() + now_ts = int(time.time()) + fetched_urls: list[str] = [] + + def fake_http_get_json(url: str, timeout: int = 30): + fetched_urls.append(url) + return [ + { + "id": "LIVE-PAIR-1A", + "question": "Will event A resolve YES?", + "events": [{"id": "EVENT-1"}], + "clobTokenIds": ["TOKEN-1A"], + "outcomePrices": ["0.62", "0.38"], + "liquidity": 25000, + "volume24hr": 15000, + "rebate_bps": 2.3, + "endDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ts + 86400)), + }, + { + "id": "LIVE-PAIR-1B", + "question": "Will event A resolve NO?", + "events": [{"id": "EVENT-1"}], + "clobTokenIds": ["TOKEN-1B"], + "outcomePrices": ["0.41", "0.59"], + "liquidity": 23000, + "volume24hr": 12000, + "rebate_bps": 2.3, + "endDate": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_ts + 86400)), + }, + ] + + monkeypatch.setattr(module, "_http_get_json", fake_http_get_json) + config = { + "execution": {"dry_run": True, "live_mode": False}, + "backtest": {"min_liquidity_usd": 0, "markets_fetch_page_size": 10, "max_markets": 2}, + "strategy": {"pairs_max": 1, "min_seconds_to_resolution": 60}, + "markets": [], + } + + result = module.run_trade(config=config, markets_file=None, yes_live=False) + + assert result["status"] == "ok" + assert result["strategy_summary"]["pairs_considered"] == 1 + assert result["strategy_summary"]["pairs_quoted"] == 1 + assert result["pair_trades"][0]["market_id"] == "LIVE-PAIR-1A" + assert result["pair_trades"][0]["pair_market_id"] == "LIVE-PAIR-1B" + assert any("/publishers/polymarket-data/markets?" in url for url in fetched_urls)