diff --git a/polymarket/_shared/pair_stateful_replay.py b/polymarket/_shared/pair_stateful_replay.py new file mode 100644 index 0000000..ef2f997 --- /dev/null +++ b/polymarket/_shared/pair_stateful_replay.py @@ -0,0 +1,651 @@ +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from pathlib import Path +from statistics import pstdev +from typing import Any + + +@dataclass(frozen=True) +class OrderBookSnapshot: + t: int + best_bid: float + best_ask: float + bid_size_usd: float + ask_size_usd: float + + +@dataclass(frozen=True) +class PairReplayParams: + bankroll_usd: float + min_seconds_to_resolution: int + min_edge_bps: float + maker_rebate_bps: float + expected_unwind_cost_bps: float + adverse_selection_bps: float + basis_entry_bps: float + basis_exit_bps: float + expected_convergence_ratio: float + base_pair_notional_usd: float + max_notional_per_pair_usd: float + max_total_notional_usd: float + max_leg_notional_usd: float + participation_rate: float + min_history_points: int + volatility_window_points: int + require_orderbook_history: bool = False + spread_decay_bps: float = 45.0 + join_best_queue_factor: float = 0.85 + off_best_queue_factor: float = 0.35 + synthetic_orderbook_half_spread_bps: float = 18.0 + synthetic_orderbook_depth_usd: float = 125.0 + telemetry_path: str = "" + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _safe_str(value: Any, default: str = "") -> str: + if value is None: + return default + return str(value) + + +def clamp(value: float, lo: float, hi: float) -> float: + return max(lo, min(hi, value)) + + +def _extract_size_usd(raw: dict[str, Any], price: float) -> float: + direct = _safe_float(raw.get("size_usd"), -1.0) + if direct >= 0.0: + return direct + size = _safe_float( + raw.get("size", raw.get("quantity", raw.get("amount", raw.get("shares", 0.0)))), + 0.0, + ) + if size <= 0.0: + return 0.0 + return size if price <= 0.0 else size * price + + +def _top_level_price(levels: Any) -> tuple[float, float]: + if isinstance(levels, list) and levels: + first = levels[0] + if isinstance(first, dict): + price = _safe_float(first.get("price"), -1.0) + return price, _extract_size_usd(first, price=max(price, 0.0)) + if isinstance(first, (list, tuple)) and len(first) >= 2: + price = _safe_float(first[0], -1.0) + size = _safe_float(first[1], 0.0) + return price, size if price <= 0.0 else size * price + return -1.0, 0.0 + + +def normalize_orderbook_snapshots( + raw_snapshots: Any, + history: list[tuple[int, float]], + params: PairReplayParams, +) -> tuple[dict[int, OrderBookSnapshot], str]: + snapshots: dict[int, OrderBookSnapshot] = {} + if isinstance(raw_snapshots, list): + for item in raw_snapshots: + if not isinstance(item, dict): + continue + ts = _safe_int(item.get("t"), -1) + if ts < 0: + continue + best_bid = _safe_float(item.get("best_bid"), -1.0) + best_ask = _safe_float(item.get("best_ask"), -1.0) + bid_size_usd = _safe_float(item.get("bid_size_usd"), -1.0) + ask_size_usd = _safe_float(item.get("ask_size_usd"), -1.0) + if best_bid < 0.0: + best_bid, inferred_bid_size = _top_level_price(item.get("bids")) + if bid_size_usd < 0.0: + bid_size_usd = inferred_bid_size + if best_ask < 0.0: + best_ask, inferred_ask_size = _top_level_price(item.get("asks")) + if ask_size_usd < 0.0: + ask_size_usd = inferred_ask_size + if best_bid < 0.0 or best_ask < 0.0 or best_bid > best_ask: + continue + snapshots[ts] = OrderBookSnapshot( + t=ts, + best_bid=best_bid, + best_ask=best_ask, + bid_size_usd=max(0.0, bid_size_usd), + ask_size_usd=max(0.0, ask_size_usd), + ) + if snapshots: + return snapshots, "historical" + + if params.require_orderbook_history: + raise RuntimeError( + "Stateful pair replay requires historical order-book snapshots. " + "Provide orderbooks in --backtest-file / backtest_markets or disable require_orderbook_history." + ) + + synthetic: dict[int, OrderBookSnapshot] = {} + half_spread = params.synthetic_orderbook_half_spread_bps / 10000.0 + for ts, mid in history: + synthetic[ts] = OrderBookSnapshot( + t=ts, + best_bid=clamp(mid - half_spread, 0.001, 0.999), + best_ask=clamp(mid + half_spread, 0.001, 0.999), + bid_size_usd=params.synthetic_orderbook_depth_usd, + ask_size_usd=params.synthetic_orderbook_depth_usd, + ) + return synthetic, "synthetic" + + +def snapshot_from_live_book( + payload: dict[str, Any] | list[Any] | None, + history: list[tuple[int, float]], + params: PairReplayParams, +) -> tuple[dict[int, OrderBookSnapshot], str]: + if not history: + return {}, "synthetic" + best_bid = -1.0 + best_ask = -1.0 + bid_size = 0.0 + ask_size = 0.0 + if isinstance(payload, dict): + best_bid = _safe_float(payload.get("best_bid", payload.get("bid")), -1.0) + best_ask = _safe_float(payload.get("best_ask", payload.get("ask")), -1.0) + bid_size = _safe_float(payload.get("bid_size_usd"), -1.0) + ask_size = _safe_float(payload.get("ask_size_usd"), -1.0) + if best_bid < 0.0: + best_bid, inferred_bid_size = _top_level_price(payload.get("bids")) + if bid_size < 0.0: + bid_size = inferred_bid_size + if best_ask < 0.0: + best_ask, inferred_ask_size = _top_level_price(payload.get("asks")) + if ask_size < 0.0: + ask_size = inferred_ask_size + if best_bid < 0.0 or best_ask < 0.0 or best_bid >= best_ask: + synthetic, mode = normalize_orderbook_snapshots([], history, params) + return synthetic, mode + + half_spread = max( + (best_ask - best_bid) / 2.0, + params.synthetic_orderbook_half_spread_bps / 10000.0, + ) + reference_bid_size = max(0.0, bid_size) or params.synthetic_orderbook_depth_usd + reference_ask_size = max(0.0, ask_size) or params.synthetic_orderbook_depth_usd + snapshots: dict[int, OrderBookSnapshot] = {} + for ts, mid in history: + snapshots[ts] = OrderBookSnapshot( + t=ts, + best_bid=clamp(mid - half_spread, 0.001, 0.999), + best_ask=clamp(mid + half_spread, 0.001, 0.999), + bid_size_usd=reference_bid_size, + ask_size_usd=reference_ask_size, + ) + return snapshots, "synthetic-from-live-book" + + +def write_telemetry_records(path: str, records: list[dict[str, Any]]) -> None: + if not path or not records: + return + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("w", encoding="utf-8") as handle: + for record in records: + handle.write(json.dumps(record, sort_keys=True)) + handle.write("\n") + + +def _pair_equity( + *, + cash_usd: float, + primary_shares: float, + pair_shares: float, + primary_price: float, + pair_price: float, + unwind_cost_bps: float, +) -> float: + primary_value = primary_shares * primary_price + pair_value = pair_shares * pair_price + liquidation_cost = (abs(primary_value) + abs(pair_value)) * unwind_cost_bps / 10000.0 + return cash_usd + primary_value + pair_value - liquidation_cost + + +def _apply_fill( + *, + side: str, + fill_notional: float, + fill_price: float, + rebate_bps: float, + cash_usd: float, + position_shares: float, +) -> tuple[float, float]: + shares = fill_notional / max(fill_price, 0.01) + if side == "buy": + cash_usd -= shares * fill_price + position_shares += shares + else: + cash_usd += shares * fill_price + position_shares -= shares + cash_usd += fill_notional * rebate_bps / 10000.0 + return cash_usd, position_shares + + +def _fill_fraction( + *, + side: str, + quote_price: float, + quote_notional: float, + current_book: OrderBookSnapshot, + next_book: OrderBookSnapshot, + next_mid: float, + spread_bps: float, + params: PairReplayParams, +) -> float: + if quote_notional <= 0.0: + return 0.0 + if side == "buy": + touched_price = min(next_mid, next_book.best_bid) + touched_distance_bps = max(0.0, (quote_price - touched_price) * 10000.0) + displayed_size = next_book.ask_size_usd + queue_factor = ( + params.join_best_queue_factor + if quote_price >= current_book.best_bid + else params.off_best_queue_factor + ) + else: + touched_price = max(next_mid, next_book.best_ask) + touched_distance_bps = max(0.0, (touched_price - quote_price) * 10000.0) + displayed_size = next_book.bid_size_usd + queue_factor = ( + params.join_best_queue_factor + if quote_price <= current_book.best_ask + else params.off_best_queue_factor + ) + if touched_distance_bps <= 0.0: + return 0.0 + half_spread_bps = max(spread_bps / 2.0, 1.0) + touch_ratio = clamp(touched_distance_bps / half_spread_bps, 0.0, 1.0) + spread_decay = math.exp(-max(0.0, spread_bps) / max(params.spread_decay_bps, 1.0)) + depth_factor = clamp(math.sqrt(max(displayed_size, 0.0) / max(quote_notional, 1e-9)), 0.0, 1.0) + return clamp( + params.participation_rate * touch_ratio * spread_decay * queue_factor * depth_factor, + 0.0, + 1.0, + ) + + +def simulate_pair_backtest( + market: dict[str, Any], + params: PairReplayParams, +) -> dict[str, Any]: + primary_history: list[tuple[int, float]] = market["history"] + pair_history: list[tuple[int, float]] = market["pair_history"] + index_pair = {t: p for t, p in pair_history} + primary_books = market.get("orderbooks", {}) + pair_books = market.get("pair_orderbooks", {}) + + aligned_primary: list[tuple[int, float]] = [] + aligned_pair: list[tuple[int, float]] = [] + for t, primary_price in primary_history: + pair_price = index_pair.get(t) + if pair_price is None: + continue + aligned_primary.append((t, primary_price)) + aligned_pair.append((t, pair_price)) + + window = max(3, params.volatility_window_points) + if len(aligned_primary) < max(params.min_history_points, window + 2): + return { + "market_id": market["market_id"], + "pair_market_id": market["pair_market_id"], + "considered_points": 0, + "quoted_points": 0, + "skipped_points": 0, + "fill_events": 0, + "filled_notional_usd": 0.0, + "pnl_usd": 0.0, + "equity_curve": [params.bankroll_usd], + "telemetry": [], + "event_pnls": [], + "orderbook_mode": _safe_str(market.get("orderbook_mode"), "unknown"), + } + + rebate_bps = _safe_float(market.get("rebate_bps"), params.maker_rebate_bps) + if rebate_bps <= 0.0: + rebate_bps = params.maker_rebate_bps + + primary_position_shares = 0.0 + pair_position_shares = 0.0 + cash_usd = params.bankroll_usd + considered = 0 + quoted = 0 + skipped = 0 + fill_events = 0 + filled_notional = 0.0 + telemetry: list[dict[str, Any]] = [] + event_pnls: list[float] = [] + equity_curve = [params.bankroll_usd] + basis_series_bps = [ + (aligned_primary[idx][1] - aligned_pair[idx][1]) * 10000.0 for idx in range(len(aligned_primary)) + ] + end_ts = _safe_int(market.get("end_ts"), 0) + + for idx in range(window, len(aligned_primary) - 1): + ts, primary_mid = aligned_primary[idx] + _, pair_mid = aligned_pair[idx] + next_ts, next_primary_mid = aligned_primary[idx + 1] + _, next_pair_mid = aligned_pair[idx + 1] + primary_book = primary_books.get(ts) + next_primary_book = primary_books.get(next_ts, primary_book) + pair_book = pair_books.get(ts) + next_pair_book = pair_books.get(next_ts, pair_book) + + considered += 1 + record: dict[str, Any] = { + "t": ts, + "market_id": market["market_id"], + "pair_market_id": market["pair_market_id"], + "primary_mid_price": round(primary_mid, 6), + "pair_mid_price": round(pair_mid, 6), + "next_primary_mid_price": round(next_primary_mid, 6), + "next_pair_mid_price": round(next_pair_mid, 6), + "inventory_primary_notional_before_usd": round(primary_position_shares * primary_mid, 6), + "inventory_pair_notional_before_usd": round(pair_position_shares * pair_mid, 6), + "orderbook_mode": _safe_str(market.get("orderbook_mode"), "unknown"), + } + + if primary_book is None or next_primary_book is None or pair_book is None or next_pair_book is None: + skipped += 1 + record["status"] = "skipped" + record["reason"] = "missing_orderbook_snapshot" + telemetry.append(record) + equity_curve.append( + _pair_equity( + cash_usd=cash_usd, + primary_shares=primary_position_shares, + pair_shares=pair_position_shares, + primary_price=next_primary_mid, + pair_price=next_pair_mid, + unwind_cost_bps=params.expected_unwind_cost_bps, + ) + ) + continue + + ttl = max(0, end_ts - ts) if end_ts else params.min_seconds_to_resolution + 1 + if ttl < params.min_seconds_to_resolution: + skipped += 1 + record["status"] = "skipped" + record["reason"] = "near_resolution" + telemetry.append(record) + equity_curve.append( + _pair_equity( + cash_usd=cash_usd, + primary_shares=primary_position_shares, + pair_shares=pair_position_shares, + primary_price=next_primary_mid, + pair_price=next_pair_mid, + unwind_cost_bps=params.expected_unwind_cost_bps, + ) + ) + continue + + if not (0.01 < primary_mid < 0.99 and 0.01 < pair_mid < 0.99): + skipped += 1 + record["status"] = "skipped" + record["reason"] = "invalid_mid_prices" + telemetry.append(record) + equity_curve.append( + _pair_equity( + cash_usd=cash_usd, + primary_shares=primary_position_shares, + pair_shares=pair_position_shares, + primary_price=next_primary_mid, + pair_price=next_pair_mid, + unwind_cost_bps=params.expected_unwind_cost_bps, + ) + ) + continue + + basis_bps = basis_series_bps[idx] + abs_basis_bps = abs(basis_bps) + expected_convergence_bps = abs_basis_bps * params.expected_convergence_ratio + edge_bps = expected_convergence_bps + rebate_bps - params.expected_unwind_cost_bps - params.adverse_selection_bps + basis_volatility_bps = pstdev(basis_series_bps[idx - window : idx]) if window > 1 else abs_basis_bps + current_primary_notional = primary_position_shares * primary_mid + current_pair_notional = pair_position_shares * pair_mid + outstanding_notional = abs(current_primary_notional) + abs(current_pair_notional) + + desired_primary_notional = current_primary_notional + desired_pair_notional = current_pair_notional + reason = "hold_inventory" + if abs_basis_bps >= params.basis_entry_bps and edge_bps >= params.min_edge_bps: + target_pair_notional = params.base_pair_notional_usd * min( + 1.8, + abs_basis_bps / max(params.basis_entry_bps, 1.0), + ) + target_pair_notional = min( + target_pair_notional, + params.max_notional_per_pair_usd, + params.max_leg_notional_usd, + ) + if basis_bps > 0.0: + desired_primary_notional = -target_pair_notional + desired_pair_notional = target_pair_notional + else: + desired_primary_notional = target_pair_notional + desired_pair_notional = -target_pair_notional + reason = "basis_entry" + elif abs(current_primary_notional) > 1e-9 or abs(current_pair_notional) > 1e-9: + if abs_basis_bps <= params.basis_exit_bps or edge_bps < params.min_edge_bps: + desired_primary_notional = 0.0 + desired_pair_notional = 0.0 + reason = "basis_exit" + else: + if abs_basis_bps < params.basis_entry_bps: + reason = "basis_below_entry_threshold" + elif edge_bps < params.min_edge_bps: + reason = "negative_or_thin_edge" + + delta_primary_notional = desired_primary_notional - current_primary_notional + delta_pair_notional = desired_pair_notional - current_pair_notional + primary_side = "buy" if delta_primary_notional > 1e-9 else "sell" if delta_primary_notional < -1e-9 else "" + pair_side = "buy" if delta_pair_notional > 1e-9 else "sell" if delta_pair_notional < -1e-9 else "" + + is_exit = desired_primary_notional == 0.0 and desired_pair_notional == 0.0 + quote_cap = params.base_pair_notional_usd * (1.25 if is_exit else min(1.8, abs_basis_bps / max(params.basis_entry_bps, 1.0))) + primary_quote_notional = min(abs(delta_primary_notional), quote_cap) if primary_side else 0.0 + pair_quote_notional = min(abs(delta_pair_notional), quote_cap) if pair_side else 0.0 + + increasing_primary = primary_quote_notional > 0.0 and abs(desired_primary_notional) > abs(current_primary_notional) + 1e-9 + increasing_pair = pair_quote_notional > 0.0 and abs(desired_pair_notional) > abs(current_pair_notional) + 1e-9 + growth_requested = 0.0 + if increasing_primary: + growth_requested += primary_quote_notional + if increasing_pair: + growth_requested += pair_quote_notional + remaining_total = max(0.0, params.max_total_notional_usd - outstanding_notional) + if growth_requested > 0.0: + if remaining_total <= 0.0: + if increasing_primary: + primary_quote_notional = 0.0 + if increasing_pair: + pair_quote_notional = 0.0 + elif growth_requested > remaining_total: + scale = remaining_total / growth_requested + if increasing_primary: + primary_quote_notional *= scale + if increasing_pair: + pair_quote_notional *= scale + + if primary_quote_notional <= 0.0 and pair_quote_notional <= 0.0: + skipped += 1 + record.update( + { + "status": "skipped", + "reason": reason, + "basis_bps": round(basis_bps, 6), + "basis_volatility_bps": round(basis_volatility_bps, 6), + "edge_bps": round(edge_bps, 6), + } + ) + telemetry.append(record) + equity_curve.append( + _pair_equity( + cash_usd=cash_usd, + primary_shares=primary_position_shares, + pair_shares=pair_position_shares, + primary_price=next_primary_mid, + pair_price=next_pair_mid, + unwind_cost_bps=params.expected_unwind_cost_bps, + ) + ) + continue + + quoted += 1 + primary_spread_bps = max((primary_book.best_ask - primary_book.best_bid) * 10000.0, 1.0) + pair_spread_bps = max((pair_book.best_ask - pair_book.best_bid) * 10000.0, 1.0) + primary_quote_price = primary_book.best_bid if primary_side == "buy" else primary_book.best_ask if primary_side == "sell" else 0.0 + pair_quote_price = pair_book.best_bid if pair_side == "buy" else pair_book.best_ask if pair_side == "sell" else 0.0 + equity_before = _pair_equity( + cash_usd=cash_usd, + primary_shares=primary_position_shares, + pair_shares=pair_position_shares, + primary_price=primary_mid, + pair_price=pair_mid, + unwind_cost_bps=params.expected_unwind_cost_bps, + ) + + primary_fill_fraction = 0.0 + pair_fill_fraction = 0.0 + primary_fill_notional = 0.0 + pair_fill_notional = 0.0 + + if primary_side: + primary_fill_fraction = _fill_fraction( + side=primary_side, + quote_price=primary_quote_price, + quote_notional=primary_quote_notional, + current_book=primary_book, + next_book=next_primary_book, + next_mid=next_primary_mid, + spread_bps=primary_spread_bps, + params=params, + ) + primary_fill_notional = primary_quote_notional * primary_fill_fraction + if pair_side: + pair_fill_fraction = _fill_fraction( + side=pair_side, + quote_price=pair_quote_price, + quote_notional=pair_quote_notional, + current_book=pair_book, + next_book=next_pair_book, + next_mid=next_pair_mid, + spread_bps=pair_spread_bps, + params=params, + ) + pair_fill_notional = pair_quote_notional * pair_fill_fraction + + if primary_fill_notional > 0.0: + cash_usd, primary_position_shares = _apply_fill( + side=primary_side, + fill_notional=primary_fill_notional, + fill_price=primary_quote_price, + rebate_bps=rebate_bps, + cash_usd=cash_usd, + position_shares=primary_position_shares, + ) + filled_notional += primary_fill_notional + fill_events += 1 + if pair_fill_notional > 0.0: + cash_usd, pair_position_shares = _apply_fill( + side=pair_side, + fill_notional=pair_fill_notional, + fill_price=pair_quote_price, + rebate_bps=rebate_bps, + cash_usd=cash_usd, + position_shares=pair_position_shares, + ) + filled_notional += pair_fill_notional + fill_events += 1 + + equity_after = _pair_equity( + cash_usd=cash_usd, + primary_shares=primary_position_shares, + pair_shares=pair_position_shares, + primary_price=next_primary_mid, + pair_price=next_pair_mid, + unwind_cost_bps=params.expected_unwind_cost_bps, + ) + equity_curve.append(equity_after) + if ( + primary_fill_notional > 0.0 + or pair_fill_notional > 0.0 + or abs(primary_position_shares) > 1e-9 + or abs(pair_position_shares) > 1e-9 + ): + event_pnls.append(equity_after - equity_before) + + record.update( + { + "status": "quoted", + "reason": reason, + "basis_bps": round(basis_bps, 6), + "basis_volatility_bps": round(basis_volatility_bps, 6), + "edge_bps": round(edge_bps, 6), + "primary_side": primary_side, + "pair_side": pair_side, + "primary_quote_price": round(primary_quote_price, 6), + "pair_quote_price": round(pair_quote_price, 6), + "primary_quote_notional_usd": round(primary_quote_notional, 6), + "pair_quote_notional_usd": round(pair_quote_notional, 6), + "primary_fill_fraction": round(primary_fill_fraction, 6), + "pair_fill_fraction": round(pair_fill_fraction, 6), + "primary_fill_notional_usd": round(primary_fill_notional, 6), + "pair_fill_notional_usd": round(pair_fill_notional, 6), + "inventory_primary_notional_after_usd": round(primary_position_shares * next_primary_mid, 6), + "inventory_pair_notional_after_usd": round(pair_position_shares * next_pair_mid, 6), + "equity_before_usd": round(equity_before, 6), + "equity_after_usd": round(equity_after, 6), + "event_pnl_usd": round(equity_after - equity_before, 6), + "cash_after_usd": round(cash_usd, 6), + } + ) + telemetry.append(record) + + ending_equity = _pair_equity( + cash_usd=cash_usd, + primary_shares=primary_position_shares, + pair_shares=pair_position_shares, + primary_price=aligned_primary[-1][1], + pair_price=aligned_pair[-1][1], + unwind_cost_bps=params.expected_unwind_cost_bps, + ) + if not equity_curve or ending_equity != equity_curve[-1]: + equity_curve.append(ending_equity) + + return { + "market_id": market["market_id"], + "pair_market_id": market["pair_market_id"], + "considered_points": considered, + "quoted_points": quoted, + "skipped_points": skipped, + "fill_events": fill_events, + "filled_notional_usd": round(filled_notional, 4), + "pnl_usd": round(ending_equity - params.bankroll_usd, 6), + "equity_curve": equity_curve, + "telemetry": telemetry, + "event_pnls": event_pnls, + "orderbook_mode": _safe_str(market.get("orderbook_mode"), "unknown"), + } diff --git a/polymarket/_shared/polymarket_live.py b/polymarket/_shared/polymarket_live.py index cb8d0e1..e9d55fc 100644 --- a/polymarket/_shared/polymarket_live.py +++ b/polymarket/_shared/polymarket_live.py @@ -120,7 +120,7 @@ def normalize_history( if isinstance(item, dict): t = safe_int(item.get("t"), -1) p = safe_float(item.get("p"), -1.0) - elif isinstance(item, list | tuple) and len(item) >= 2: + elif isinstance(item, (list, tuple)) and len(item) >= 2: t = safe_int(item[0], -1) p = safe_float(item[1], -1.0) if t < 0 or not (0.0 <= p <= 1.0) or t in seen: @@ -162,7 +162,7 @@ def best_price(levels: Any, fallback: float = 0.0) -> float: level = levels[0] if isinstance(level, dict): return safe_float(level.get("price"), fallback) - if isinstance(level, list | tuple) and level: + if isinstance(level, (list, tuple)) and level: return safe_float(level[0], fallback) return fallback @@ -361,7 +361,7 @@ def _extract_call_publisher_body(result: dict[str, Any]) -> Any: structured = result.get("structuredContent") if isinstance(structured, dict): body = structured.get("body") - if isinstance(body, dict | list): + if isinstance(body, (dict, list)): return body return structured content = result.get("content") @@ -380,13 +380,13 @@ def _extract_call_publisher_body(result: dict[str, Any]) -> Any: continue if isinstance(parsed, dict): body = parsed.get("body") - if isinstance(body, dict | list): + if isinstance(body, (dict, list)): return body return parsed if isinstance(parsed, list): return parsed body = result.get("body") - if isinstance(body, dict | list): + if isinstance(body, (dict, list)): return body return result.get("value") @@ -954,6 +954,99 @@ def get_positions(self) -> Any: return self._call("GET", "/positions") +class DirectClobTrader: + """Direct Polymarket CLOB client for local py-clob-client execution.""" + + def __init__( + self, + *, + skill_root: Path, + client_name: str, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + ) -> None: + maybe_load_dotenv(skill_root) + self.client_name = client_name + self.timeout_seconds = timeout_seconds + + try: + from py_clob_client.client import ClobClient + from py_clob_client.clob_types import ApiCreds + except ImportError as exc: + raise RuntimeError( + "Direct CLOB trading requires `py-clob-client`. " + "Create and activate a virtual environment first, then run " + "`python -m pip install -r requirements.txt`." + ) from exc + + private_key = safe_str( + os.getenv("POLY_PRIVATE_KEY") or os.getenv("WALLET_PRIVATE_KEY"), + "", + ).strip() + api_key = safe_str(os.getenv("POLY_API_KEY"), "").strip() + api_passphrase = safe_str(os.getenv("POLY_PASSPHRASE"), "").strip() + api_secret = safe_str(os.getenv("POLY_SECRET"), "").strip() + if not private_key: + raise RuntimeError( + "Direct CLOB trading requires `POLY_PRIVATE_KEY` or `WALLET_PRIVATE_KEY`." + ) + if not api_key or not api_passphrase or not api_secret: + raise RuntimeError( + "Missing required Polymarket L2 credentials. Set " + "`POLY_API_KEY`, `POLY_PASSPHRASE`, and `POLY_SECRET`." + ) + + chain_id = safe_int(os.getenv("POLY_CHAIN_ID"), DEFAULT_CHAIN_ID) + creds = ApiCreds( + api_key=api_key, + api_secret=api_secret, + api_passphrase=api_passphrase, + ) + self._client = ClobClient( + host="https://clob.polymarket.com", + key=private_key, + chain_id=chain_id, + creds=creds, + ) + self.address = self._client.get_address() + + def create_order( + self, + *, + token_id: str, + side: str, + price: float, + size: float, + tick_size: str, + neg_risk: bool, + fee_rate_bps: int, + ) -> Any: + del tick_size, neg_risk, fee_rate_bps + from py_clob_client.clob_types import OrderArgs, OrderType + from py_clob_client.order_builder.constants import BUY, SELL + + clob_side = BUY if side.upper() == "BUY" else SELL + order_args = OrderArgs( + price=price, + size=size, + side=clob_side, + token_id=token_id, + ) + signed_order = self._client.create_order(order_args) + return self._client.post_order(signed_order, OrderType.GTC) + + def cancel_all(self) -> Any: + return self._client.cancel_all() + + def get_orders(self) -> Any: + return self._client.get_orders() + + def get_positions(self) -> Any: + try: + return self._client.get_balance_allowance() + except Exception: + return [] + + def single_market_inventory_notional( *, raw_positions: Any, @@ -1010,7 +1103,7 @@ def live_settings_from_execution(execution: dict[str, Any]) -> LiveExecutionSett def execute_single_market_quotes( *, - trader: PolymarketPublisherTrader, + trader: PolymarketPublisherTrader | DirectClobTrader, quotes: list[dict[str, Any]], markets: list[dict[str, Any]], execution_settings: LiveExecutionSettings, @@ -1038,16 +1131,18 @@ def execute_single_market_quotes( tick_size = safe_str(market.get("tick_size"), "0.01") neg_risk = bool(market.get("neg_risk", False)) fee_rate_bps = fetch_fee_rate_bps(token_id) - quote_notional = max(0.0, safe_float(quote.get("quote_notional_usd"), 0.0)) - if quote_notional <= 0.0: + fallback_notional = max(0.0, safe_float(quote.get("quote_notional_usd"), 0.0)) + bid_notional = max(0.0, safe_float(quote.get("bid_notional_usd"), fallback_notional)) + ask_notional = max(0.0, safe_float(quote.get("ask_notional_usd"), fallback_notional)) + if bid_notional <= 0.0 and ask_notional <= 0.0: skips.append({"market_id": market["market_id"], "reason": "zero_quote_notional"}) continue bid_price = snap_price(safe_float(quote.get("bid_price"), 0.0), tick_size, "BUY") ask_price = snap_price(safe_float(quote.get("ask_price"), 0.0), tick_size, "SELL") - if bid_price > 0.0: - bid_size = quote_notional / max(bid_price, 1e-9) + if bid_price > 0.0 and bid_notional > 0.0: + bid_size = bid_notional / max(bid_price, 1e-9) response = trader.create_order( token_id=token_id, side="BUY", @@ -1069,7 +1164,7 @@ def execute_single_market_quotes( ) available_shares = max(0.0, position_sizes.get(token_id, 0.0)) - sell_notional = min(quote_notional, available_shares * max(ask_price, 0.0)) + sell_notional = min(ask_notional, available_shares * max(ask_price, 0.0)) if ask_price > 0.0 and sell_notional > 0.0: ask_size = sell_notional / max(ask_price, 1e-9) response = trader.create_order( @@ -1124,7 +1219,7 @@ def execute_single_market_quotes( def execute_pair_trades( *, - trader: PolymarketPublisherTrader, + trader: PolymarketPublisherTrader | DirectClobTrader, pair_trades: list[dict[str, Any]], markets: list[dict[str, Any]], execution_settings: LiveExecutionSettings, diff --git a/polymarket/high-throughput-paired-basis-maker/SKILL.md b/polymarket/high-throughput-paired-basis-maker/SKILL.md index c3ece7e..3e8bce9 100644 --- a/polymarket/high-throughput-paired-basis-maker/SKILL.md +++ b/polymarket/high-throughput-paired-basis-maker/SKILL.md @@ -19,9 +19,9 @@ description: "Run a paired-market basis strategy on Polymarket with mandatory ba ## Workflow Summary -1. `load_backtest_pairs` pulls live market histories from the Seren Polymarket Publisher (Gamma + CLOB proxied), builds pairs from the active market universe, 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. +1. `load_backtest_pairs` pulls live market histories from the Seren Polymarket Publisher (Gamma + CLOB proxied), attaches per-leg order-book snapshots, builds pairs from the active market universe, and timestamp-aligns each pair. +2. `simulate_basis_reversion` runs an event-driven stateful replay with carried cash and inventory across both legs, order-book-aware fills, and pessimistic spread-decay. +3. `summarize_backtest` reports total return, annualized return, Sharpe-like score, max drawdown, hit rate, quoted/fill counts, order-book mode coverage, telemetry counts, and pair-level contributions. 4. `sample_gate` fails backtest if `events < backtest.min_events` (default `200`). 5. `backtest_gate` blocks trade mode by default if backtest return is non-positive. 6. `emit_pair_trades` outputs two-leg trade intents (`primary` + `pair`) with risk caps. @@ -65,6 +65,14 @@ If you are already running inside Seren Desktop, the runtime can use injected au python3 scripts/agent.py --config config.json --run-type trade ``` +## Optional Fixture Replay + +```bash +python3 scripts/agent.py --config config.json --backtest-file tests/fixtures/backtest_pairs.json +``` + +Set `backtest.telemetry_path` to capture JSONL replay telemetry for each decision step. + ## Disclaimer This skill can lose money. Basis spreads can persist or widen, hedge legs can slip, and liquidity can fail during volatility. Backtests are hypothetical and do not guarantee future results. This skill is software tooling and not financial advice. Use dry-run first and only trade with risk capital. diff --git a/polymarket/high-throughput-paired-basis-maker/config.example.json b/polymarket/high-throughput-paired-basis-maker/config.example.json index b50a6f6..bcfd25b 100644 --- a/polymarket/high-throughput-paired-basis-maker/config.example.json +++ b/polymarket/high-throughput-paired-basis-maker/config.example.json @@ -18,6 +18,14 @@ "participation_rate": 0.95, "min_history_points": 72, "min_events": 200, + "volatility_window_points": 24, + "require_orderbook_history": false, + "spread_decay_bps": 45, + "join_best_queue_factor": 0.85, + "off_best_queue_factor": 0.35, + "synthetic_orderbook_half_spread_bps": 18, + "synthetic_orderbook_depth_usd": 125, + "telemetry_path": "", "min_liquidity_usd": 5000, "markets_fetch_page_size": 500, "max_markets": 0, diff --git a/polymarket/high-throughput-paired-basis-maker/scripts/agent.py b/polymarket/high-throughput-paired-basis-maker/scripts/agent.py index 1a0d2f1..f11beb9 100644 --- a/polymarket/high-throughput-paired-basis-maker/scripts/agent.py +++ b/polymarket/high-throughput-paired-basis-maker/scripts/agent.py @@ -24,12 +24,19 @@ sys.path.insert(0, str(SHARED_DIR)) from polymarket_live import ( - PolymarketPublisherTrader, + DirectClobTrader, execute_pair_trades, live_settings_from_execution, load_live_pair_markets, pair_leg_exposure_notional, ) +from pair_stateful_replay import ( + PairReplayParams, + normalize_orderbook_snapshots, + simulate_pair_backtest, + snapshot_from_live_book, + write_telemetry_records, +) DISCLAIMER = ( @@ -76,6 +83,14 @@ class BacktestParams: participation_rate: float = 0.95 min_history_points: int = 72 min_events: int = 200 + volatility_window_points: int = 24 + require_orderbook_history: bool = False + spread_decay_bps: float = 45.0 + join_best_queue_factor: float = 0.85 + off_best_queue_factor: float = 0.35 + synthetic_orderbook_half_spread_bps: float = 18.0 + synthetic_orderbook_depth_usd: float = 125.0 + telemetry_path: str = "" min_liquidity_usd: float = 5000.0 markets_fetch_page_size: int = 500 max_markets: int = 0 @@ -96,6 +111,11 @@ def parse_args() -> argparse.Namespace: help="Run backtest only, or run trade mode after backtest gating.", ) parser.add_argument("--markets-file", default=None, help="Optional trade market JSON file.") + parser.add_argument( + "--backtest-file", + default=None, + help="Optional paired backtest fixture JSON file with history and orderbook snapshots.", + ) parser.add_argument("--backtest-days", type=int, default=None, help="Override backtest days.") parser.add_argument( "--allow-negative-backtest", @@ -210,6 +230,20 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: participation_rate=clamp(_safe_float(raw.get("participation_rate"), 0.95), 0.0, 1.0), min_history_points=max(8, _safe_int(raw.get("min_history_points"), 72)), min_events=max(1, _safe_int(raw.get("min_events"), 200)), + volatility_window_points=max(3, _safe_int(raw.get("volatility_window_points"), 24)), + require_orderbook_history=_safe_bool(raw.get("require_orderbook_history"), False), + spread_decay_bps=max(1.0, _safe_float(raw.get("spread_decay_bps"), 45.0)), + join_best_queue_factor=clamp(_safe_float(raw.get("join_best_queue_factor"), 0.85), 0.0, 1.0), + off_best_queue_factor=clamp(_safe_float(raw.get("off_best_queue_factor"), 0.35), 0.0, 1.0), + synthetic_orderbook_half_spread_bps=max( + 0.1, + _safe_float(raw.get("synthetic_orderbook_half_spread_bps"), 18.0), + ), + synthetic_orderbook_depth_usd=max( + 0.0, + _safe_float(raw.get("synthetic_orderbook_depth_usd"), 125.0), + ), + telemetry_path=_safe_str(raw.get("telemetry_path"), ""), min_liquidity_usd=max(0.0, _safe_float(raw.get("min_liquidity_usd"), 5000.0)), markets_fetch_page_size=max(25, _safe_int(raw.get("markets_fetch_page_size"), 500)), max_markets=max(0, _safe_int(raw.get("max_markets"), 0)), @@ -223,6 +257,34 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: ) +def _to_pair_replay_params(p: StrategyParams, bt: BacktestParams) -> PairReplayParams: + return PairReplayParams( + bankroll_usd=p.bankroll_usd, + min_seconds_to_resolution=p.min_seconds_to_resolution, + min_edge_bps=p.min_edge_bps, + maker_rebate_bps=p.maker_rebate_bps, + expected_unwind_cost_bps=p.expected_unwind_cost_bps, + adverse_selection_bps=p.adverse_selection_bps, + basis_entry_bps=p.basis_entry_bps, + basis_exit_bps=p.basis_exit_bps, + expected_convergence_ratio=p.expected_convergence_ratio, + base_pair_notional_usd=p.base_pair_notional_usd, + max_notional_per_pair_usd=p.max_notional_per_pair_usd, + max_total_notional_usd=p.max_total_notional_usd, + max_leg_notional_usd=p.max_leg_notional_usd, + participation_rate=bt.participation_rate, + min_history_points=bt.min_history_points, + volatility_window_points=bt.volatility_window_points, + require_orderbook_history=bt.require_orderbook_history, + spread_decay_bps=bt.spread_decay_bps, + join_best_queue_factor=bt.join_best_queue_factor, + off_best_queue_factor=bt.off_best_queue_factor, + synthetic_orderbook_half_spread_bps=bt.synthetic_orderbook_half_spread_bps, + synthetic_orderbook_depth_usd=bt.synthetic_orderbook_depth_usd, + telemetry_path=bt.telemetry_path, + ) + + def _normalize_history( raw_history: Any, start_ts: int, @@ -415,7 +477,14 @@ def _align_histories(primary: list[tuple[int, float]], secondary: list[tuple[int return aligned_primary, aligned_secondary +def _combine_orderbook_mode(primary_mode: str, pair_mode: str) -> str: + if primary_mode == pair_mode: + return primary_mode + return f"{primary_mode}|{pair_mode}" + + def _fetch_live_backtest_pairs(p: StrategyParams, bt: BacktestParams, start_ts: int, end_ts: int) -> list[dict[str, Any]]: + replay_params = _to_pair_replay_params(p, bt) offset = 0 candidates: list[dict[str, Any]] = [] seen_token_ids: set[str] = set() @@ -510,7 +579,23 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None ) if len(history) < bt.min_history_points: return None - return {**candidate, "history": history} + try: + book_payload = _http_get_json( + f"{SEREN_POLYMARKET_TRADING_PUBLISHER_PREFIX}book?{urlencode({'token_id': candidate['token_id']})}" + ) + except Exception: + book_payload = None + orderbooks, orderbook_mode = snapshot_from_live_book( + payload=book_payload, + history=history, + params=replay_params, + ) + return { + **candidate, + "history": history, + "orderbooks": orderbooks, + "orderbook_mode": orderbook_mode, + } with_history: list[dict[str, Any]] = [] with ThreadPoolExecutor(max_workers=max(1, bt.history_fetch_workers)) as executor: @@ -543,11 +628,19 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None "pair_market_id": pair_id, "question": _safe_str(primary.get("question"), market_id), "pair_question": _safe_str(secondary.get("question"), pair_id), + "token_id": _safe_str(primary.get("token_id"), ""), + "pair_token_id": _safe_str(secondary.get("token_id"), ""), "event_id": event_id, "end_ts": min(_safe_int(primary.get("end_ts"), end_ts + 86400), _safe_int(secondary.get("end_ts"), end_ts + 86400)), "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, + "orderbooks": primary.get("orderbooks", {}), + "pair_orderbooks": secondary.get("orderbooks", {}), + "orderbook_mode": _combine_orderbook_mode( + _safe_str(primary.get("orderbook_mode"), "unknown"), + _safe_str(secondary.get("orderbook_mode"), "unknown"), + ), "source": "live-api", } ) @@ -571,11 +664,19 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None "pair_market_id": pair_id, "question": _safe_str(primary.get("question"), market_id), "pair_question": _safe_str(secondary.get("question"), pair_id), + "token_id": _safe_str(primary.get("token_id"), ""), + "pair_token_id": _safe_str(secondary.get("token_id"), ""), "event_id": "fallback", "end_ts": min(_safe_int(primary.get("end_ts"), end_ts + 86400), _safe_int(secondary.get("end_ts"), end_ts + 86400)), "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, + "orderbooks": primary.get("orderbooks", {}), + "pair_orderbooks": secondary.get("orderbooks", {}), + "orderbook_mode": _combine_orderbook_mode( + _safe_str(primary.get("orderbook_mode"), "unknown"), + _safe_str(secondary.get("orderbook_mode"), "unknown"), + ), "source": "live-api-fallback", } ) @@ -583,12 +684,102 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None return pairs +def _load_markets_from_fixture( + *, + fixture_path: Path, + p: StrategyParams, + bt: BacktestParams, + start_ts: int, + end_ts: int, +) -> list[dict[str, Any]]: + replay_params = _to_pair_replay_params(p, bt) + payload = load_json(fixture_path) + if isinstance(payload, dict): + rows = payload.get("markets", []) + elif isinstance(payload, list): + rows = payload + else: + rows = [] + if not isinstance(rows, list): + return [] + + markets: list[dict[str, Any]] = [] + for idx, row in enumerate(rows): + if not isinstance(row, dict): + continue + history = _normalize_history( + row.get("history"), + start_ts=start_ts, + end_ts=end_ts, + token_id=_safe_str(row.get("token_id"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + ) + pair_history = _normalize_history( + row.get("pair_history"), + start_ts=start_ts, + end_ts=end_ts, + token_id=_safe_str(row.get("pair_token_id"), _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b")), + ) + if len(history) < bt.min_history_points or len(pair_history) < bt.min_history_points: + continue + orderbooks, primary_mode = normalize_orderbook_snapshots( + row.get("orderbooks"), + history, + replay_params, + ) + pair_orderbooks, pair_mode = normalize_orderbook_snapshots( + row.get("pair_orderbooks"), + pair_history, + replay_params, + ) + markets.append( + { + "market_id": _safe_str(row.get("market_id"), f"fixture-{idx}-a"), + "pair_market_id": _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + "question": _safe_str(row.get("question"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + "pair_question": _safe_str( + row.get("pair_question"), + _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + ), + "token_id": _safe_str(row.get("token_id"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + "pair_token_id": _safe_str( + row.get("pair_token_id"), + _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + ), + "event_id": _safe_str(row.get("event_id"), f"fixture-{idx}"), + "end_ts": _safe_int(row.get("end_ts"), end_ts + 86400), + "rebate_bps": _safe_float(row.get("rebate_bps"), p.maker_rebate_bps), + "history": history, + "pair_history": pair_history, + "orderbooks": orderbooks, + "pair_orderbooks": pair_orderbooks, + "orderbook_mode": _combine_orderbook_mode(primary_mode, pair_mode), + "source": "fixture", + } + ) + return markets + + def _load_backtest_markets( p: StrategyParams, bt: BacktestParams, start_ts: int, end_ts: int, + backtest_file: str | None = None, ) -> tuple[list[dict[str, Any]], str]: + if backtest_file: + fixture_path = Path(backtest_file) + if not fixture_path.exists(): + raise FileNotFoundError(f"Backtest fixture not found: {fixture_path}") + return ( + _load_markets_from_fixture( + fixture_path=fixture_path, + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + ), + f"fixture:{fixture_path.name}", + ) return _fetch_live_backtest_pairs(p=p, bt=bt, start_ts=start_ts, end_ts=end_ts), "live-api" @@ -625,86 +816,63 @@ def _sharpe_like_score(event_pnls: list[float], bankroll_usd: float, days: int) def _simulate_pair(market: dict[str, Any], p: StrategyParams, bt: BacktestParams) -> dict[str, Any]: - primary = market["history"] - pair = market["pair_history"] - n = min(len(primary), len(pair)) - if n < bt.min_history_points: - return { - "market_id": market["market_id"], - "pair_market_id": market["pair_market_id"], - "considered_points": 0, - "traded_points": 0, - "filled_notional_usd": 0.0, - "pnl_usd": 0.0, - "event_pnls": [], - } - - rebate_bps = _safe_float(market.get("rebate_bps"), p.maker_rebate_bps) - if rebate_bps <= 0: - rebate_bps = p.maker_rebate_bps - - basis_series_bps = [(primary[i][1] - pair[i][1]) * 10000.0 for i in range(n)] - considered = 0 - traded = 0 - filled_notional = 0.0 - pnl = 0.0 - event_pnls: list[float] = [] - - for i in range(0, n - 1): - t = primary[i][0] - ttl = max(0, _safe_int(market.get("end_ts"), t + 86400) - t) - if ttl < p.min_seconds_to_resolution: - continue - - basis_now = basis_series_bps[i] - basis_next = basis_series_bps[i + 1] - abs_basis_now = abs(basis_now) - if abs_basis_now < p.basis_entry_bps: - continue - - considered += 1 - basis_change = abs_basis_now - abs(basis_next) - expected_convergence = abs_basis_now * p.expected_convergence_ratio - expected_edge = expected_convergence + rebate_bps - p.expected_unwind_cost_bps - p.adverse_selection_bps - if expected_edge < p.min_edge_bps: - continue - - traded += 1 - fill_intensity = min(1.0, abs_basis_now / max(p.basis_entry_bps * 2.0, 1e-9)) - event_notional = p.base_pair_notional_usd * bt.participation_rate * fill_intensity - realized_edge = basis_change + rebate_bps - p.expected_unwind_cost_bps - p.adverse_selection_bps - event_pnl = event_notional * realized_edge / 10000.0 - - filled_notional += event_notional - pnl += event_pnl - event_pnls.append(event_pnl) - + replay_params = _to_pair_replay_params(p, bt) + history = market.get("history", []) + pair_history = market.get("pair_history", []) + orderbooks = market.get("orderbooks") + pair_orderbooks = market.get("pair_orderbooks") + orderbook_mode = _safe_str(market.get("orderbook_mode"), "") + if not isinstance(orderbooks, dict): + orderbooks, primary_mode = normalize_orderbook_snapshots([], history, replay_params) + market = {**market, "orderbooks": orderbooks} + orderbook_mode = primary_mode + else: + primary_mode = orderbook_mode.split("|", 1)[0] if orderbook_mode else "unknown" + if not isinstance(pair_orderbooks, dict): + pair_orderbooks, pair_mode = normalize_orderbook_snapshots([], pair_history, replay_params) + market = {**market, "pair_orderbooks": pair_orderbooks} + orderbook_mode = _combine_orderbook_mode(primary_mode, pair_mode) + if orderbook_mode: + market = {**market, "orderbook_mode": orderbook_mode} + result = simulate_pair_backtest(market=market, params=replay_params) return { - "market_id": market["market_id"], - "pair_market_id": market["pair_market_id"], - "considered_points": considered, - "traded_points": traded, - "filled_notional_usd": round(filled_notional, 4), - "pnl_usd": round(pnl, 6), - "event_pnls": event_pnls, + **result, + "traded_points": result["quoted_points"], } -def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, Any]: +def run_backtest( + config: dict[str, Any], + backtest_days: int | None, + backtest_file: str | None = None, +) -> dict[str, Any]: p = to_strategy_params(config) bt = to_backtest_params(config) days = int(clamp(backtest_days if backtest_days is not None else bt.days, bt.days_min, bt.days_max)) + configured_backtest_file = "" + if isinstance(config.get("backtest"), dict): + configured_backtest_file = _safe_str(config["backtest"].get("backtest_file"), "") + selected_backtest_file = backtest_file or configured_backtest_file or None end_ts = int(time.time()) start_ts = end_ts - (days * 24 * 60 * 60) try: - markets, source = _load_backtest_markets( - p=p, - bt=bt, - start_ts=start_ts, - end_ts=end_ts, - ) + if selected_backtest_file: + markets, source = _load_backtest_markets( + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + backtest_file=selected_backtest_file, + ) + else: + markets, source = _load_backtest_markets( + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + ) except Exception as exc: return { "status": "error", @@ -726,8 +894,12 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, summaries: list[dict[str, Any]] = [] event_pnls: list[float] = [] considered = 0 - traded = 0 + quoted = 0 + skipped = 0 + fill_events = 0 total_notional = 0.0 + telemetry: list[dict[str, Any]] = [] + orderbook_modes: dict[str, int] = defaultdict(int) for market in markets: result = _simulate_pair(market, p, bt) @@ -736,15 +908,23 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "market_id": result["market_id"], "pair_market_id": result["pair_market_id"], "considered_points": result["considered_points"], + "quoted_points": result["quoted_points"], "traded_points": result["traded_points"], + "skipped_points": result["skipped_points"], + "fill_events": result["fill_events"], "filled_notional_usd": result["filled_notional_usd"], "pnl_usd": result["pnl_usd"], + "orderbook_mode": result["orderbook_mode"], } ) considered += int(result["considered_points"]) - traded += int(result["traded_points"]) + quoted += int(result["quoted_points"]) + skipped += int(result["skipped_points"]) + fill_events += int(result["fill_events"]) total_notional += float(result["filled_notional_usd"]) event_pnls.extend(result["event_pnls"]) + telemetry.extend(result.get("telemetry", [])) + orderbook_modes[_safe_str(result.get("orderbook_mode"), "unknown")] += 1 equity_curve = [p.bankroll_usd] equity = p.bankroll_usd @@ -778,11 +958,16 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "source": source, "pairs_loaded": len(markets), "events_observed": events, + "quoted_points": quoted, + "fill_events": fill_events, "min_events_required": bt.min_events, + "orderbook_modes": dict(sorted(orderbook_modes.items())), }, "disclaimer": DISCLAIMER, } + write_telemetry_records(bt.telemetry_path, telemetry) + return { "status": "ok", "skill": "high-throughput-paired-basis-maker", @@ -796,8 +981,14 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "source": source, "pairs_selected": len(summaries), "considered_points": considered, - "traded_points": traded, - "trade_rate_pct": round((traded / considered) * 100.0 if considered else 0.0, 4), + "quoted_points": quoted, + "traded_points": quoted, + "skipped_points": skipped, + "fill_events": fill_events, + "trade_rate_pct": round((quoted / considered) * 100.0 if considered else 0.0, 4), + "orderbook_modes": dict(sorted(orderbook_modes.items())), + "telemetry_path": bt.telemetry_path, + "telemetry_records": len(telemetry), }, "results": { "starting_bankroll_usd": round(p.bankroll_usd, 2), @@ -811,6 +1002,7 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "filled_notional_usd": round(total_notional, 2), "turnover_multiple": round(turnover_multiple, 4), "events": events, + "fill_events": fill_events, "min_events_required": bt.min_events, "max_drawdown_usd": round(max_drawdown_usd, 4), "max_drawdown_pct": round(display_max_drawdown_pct, 4), @@ -1163,10 +1355,10 @@ def run_trade(config: dict[str, Any], markets_file: str | None, yes_live: bool) exposure = config.get("state", {}).get("leg_exposure", {}) leg_exposure = {str(k): _safe_float(v, 0.0) for k, v in exposure.items()} - live_trader: PolymarketPublisherTrader | None = None + live_trader: DirectClobTrader | None = None if live_mode: try: - live_trader = PolymarketPublisherTrader( + live_trader = DirectClobTrader( skill_root=Path(__file__).resolve().parents[1], client_name="high-throughput-paired-basis-maker", ) @@ -1250,6 +1442,7 @@ def main() -> int: backtest = run_backtest( config=config, backtest_days=args.backtest_days, + backtest_file=args.backtest_file, ) if backtest.get("status") != "ok": print(json.dumps(backtest, sort_keys=True)) 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 e525589..863fda9 100644 --- a/polymarket/high-throughput-paired-basis-maker/tests/test_smoke.py +++ b/polymarket/high-throughput-paired-basis-maker/tests/test_smoke.py @@ -68,7 +68,7 @@ def test_dry_run_fixture_blocks_live_execution() -> None: assert payload["blocked_action"] == "live_execution" -def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None: +def test_config_example_runs_stateful_backtest_and_reports_replay_metrics(monkeypatch) -> None: module = _load_agent_module() payload = json.loads(CONFIG_EXAMPLE_PATH.read_text(encoding="utf-8")) @@ -100,7 +100,11 @@ def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None output = module.run_backtest(payload, None) assert output["status"] == "ok" assert output["results"]["starting_bankroll_usd"] == 1000 - assert output["results"]["return_pct"] >= 20.0 + assert output["results"]["fill_events"] > 0 + assert output["backtest_summary"]["quoted_points"] > 0 + assert sum(output["backtest_summary"]["orderbook_modes"].values()) == len(synthetic_markets) + assert output["results"]["return_pct"] >= -100.0 + assert output["pairs"][0]["orderbook_mode"] in output["backtest_summary"]["orderbook_modes"] def test_trade_mode_fetches_live_pairs_when_config_markets_is_empty(monkeypatch) -> None: diff --git a/polymarket/liquidity-paired-basis-maker/SKILL.md b/polymarket/liquidity-paired-basis-maker/SKILL.md index 003f807..27ff2c2 100644 --- a/polymarket/liquidity-paired-basis-maker/SKILL.md +++ b/polymarket/liquidity-paired-basis-maker/SKILL.md @@ -19,10 +19,10 @@ description: "Run a liquidity-filtered paired-market basis strategy on Polymarke ## Workflow Summary -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`). +1. `load_backtest_pairs` pulls live market histories from Seren Polymarket Publisher (Gamma markets + CLOB history), attaches per-leg order-book snapshots, applies a liquidity-filtered universe cap, builds pairs, and timestamp-aligns each pair. +2. `simulate_basis_reversion` runs an event-driven stateful replay with carried cash and inventory across both legs, order-book-aware fills, and pessimistic spread-decay. +3. `summarize_backtest` reports total return, annualized return, Sharpe-like score, max drawdown, hit rate, quoted/fill counts, order-book mode coverage, telemetry counts, and pair-level contributions. +4. `sample_gate` fails backtest if `events < backtest.min_events` (default `120`). 5. `backtest_gate` blocks trade mode by default if backtest return is non-positive. 6. `emit_pair_trades` outputs two-leg trade intents (`primary` + `pair`) with risk caps. @@ -65,6 +65,14 @@ If you are already running inside Seren Desktop, the runtime can use injected au python3 scripts/agent.py --config config.json --run-type trade ``` +## Optional Fixture Replay + +```bash +python3 scripts/agent.py --config config.json --backtest-file tests/fixtures/backtest_pairs.json +``` + +Set `backtest.telemetry_path` to capture JSONL replay telemetry for each decision step. + ## Disclaimer This skill can lose money. Basis spreads can persist or widen, hedge legs can slip, and liquidity can fail during volatility. Backtests are hypothetical and do not guarantee future results. This skill is software tooling and not financial advice. Use dry-run first and only trade with risk capital. diff --git a/polymarket/liquidity-paired-basis-maker/config.example.json b/polymarket/liquidity-paired-basis-maker/config.example.json index 860e45f..2da722c 100644 --- a/polymarket/liquidity-paired-basis-maker/config.example.json +++ b/polymarket/liquidity-paired-basis-maker/config.example.json @@ -18,6 +18,14 @@ "participation_rate": 0.9, "min_history_points": 72, "min_events": 120, + "volatility_window_points": 24, + "require_orderbook_history": false, + "spread_decay_bps": 45, + "join_best_queue_factor": 0.85, + "off_best_queue_factor": 0.35, + "synthetic_orderbook_half_spread_bps": 18, + "synthetic_orderbook_depth_usd": 125, + "telemetry_path": "", "min_liquidity_usd": 5000, "markets_fetch_page_size": 120, "max_markets": 80, diff --git a/polymarket/liquidity-paired-basis-maker/scripts/agent.py b/polymarket/liquidity-paired-basis-maker/scripts/agent.py index 25477ac..e3d20b1 100644 --- a/polymarket/liquidity-paired-basis-maker/scripts/agent.py +++ b/polymarket/liquidity-paired-basis-maker/scripts/agent.py @@ -24,12 +24,19 @@ sys.path.insert(0, str(SHARED_DIR)) from polymarket_live import ( - PolymarketPublisherTrader, + DirectClobTrader, execute_pair_trades, live_settings_from_execution, load_live_pair_markets, pair_leg_exposure_notional, ) +from pair_stateful_replay import ( + PairReplayParams, + normalize_orderbook_snapshots, + simulate_pair_backtest, + snapshot_from_live_book, + write_telemetry_records, +) SEREN_POLYMARKET_PUBLISHER_HOST = "api.serendb.com" SEREN_PUBLISHERS_PREFIX = "/publishers/" @@ -85,6 +92,14 @@ class BacktestParams: participation_rate: float = 0.9 min_history_points: int = 72 min_events: int = 120 + volatility_window_points: int = 24 + require_orderbook_history: bool = False + spread_decay_bps: float = 45.0 + join_best_queue_factor: float = 0.85 + off_best_queue_factor: float = 0.35 + synthetic_orderbook_half_spread_bps: float = 18.0 + synthetic_orderbook_depth_usd: float = 125.0 + telemetry_path: str = "" min_liquidity_usd: float = 5000.0 markets_fetch_page_size: int = 120 max_markets: int = 80 @@ -105,6 +120,11 @@ def parse_args() -> argparse.Namespace: help="Run backtest only, or run trade mode after backtest gating.", ) parser.add_argument("--markets-file", default=None, help="Optional trade market JSON file.") + parser.add_argument( + "--backtest-file", + default=None, + help="Optional paired backtest fixture JSON file with history and orderbook snapshots.", + ) parser.add_argument("--backtest-days", type=int, default=None, help="Override backtest days.") parser.add_argument( "--allow-negative-backtest", @@ -220,6 +240,20 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: participation_rate=clamp(_safe_float(raw.get("participation_rate"), 0.9), 0.0, 1.0), min_history_points=max(8, _safe_int(raw.get("min_history_points"), 72)), min_events=max(1, _safe_int(raw.get("min_events"), 120)), + volatility_window_points=max(3, _safe_int(raw.get("volatility_window_points"), 24)), + require_orderbook_history=_safe_bool(raw.get("require_orderbook_history"), False), + spread_decay_bps=max(1.0, _safe_float(raw.get("spread_decay_bps"), 45.0)), + join_best_queue_factor=clamp(_safe_float(raw.get("join_best_queue_factor"), 0.85), 0.0, 1.0), + off_best_queue_factor=clamp(_safe_float(raw.get("off_best_queue_factor"), 0.35), 0.0, 1.0), + synthetic_orderbook_half_spread_bps=max( + 0.1, + _safe_float(raw.get("synthetic_orderbook_half_spread_bps"), 18.0), + ), + synthetic_orderbook_depth_usd=max( + 0.0, + _safe_float(raw.get("synthetic_orderbook_depth_usd"), 125.0), + ), + telemetry_path=_safe_str(raw.get("telemetry_path"), ""), min_liquidity_usd=max(0.0, _safe_float(raw.get("min_liquidity_usd"), 5000.0)), markets_fetch_page_size=max(25, _safe_int(raw.get("markets_fetch_page_size"), 120)), max_markets=max(0, _safe_int(raw.get("max_markets"), 80)), @@ -233,6 +267,34 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: ) +def _to_pair_replay_params(p: StrategyParams, bt: BacktestParams) -> PairReplayParams: + return PairReplayParams( + bankroll_usd=p.bankroll_usd, + min_seconds_to_resolution=p.min_seconds_to_resolution, + min_edge_bps=p.min_edge_bps, + maker_rebate_bps=p.maker_rebate_bps, + expected_unwind_cost_bps=p.expected_unwind_cost_bps, + adverse_selection_bps=p.adverse_selection_bps, + basis_entry_bps=p.basis_entry_bps, + basis_exit_bps=p.basis_exit_bps, + expected_convergence_ratio=p.expected_convergence_ratio, + base_pair_notional_usd=p.base_pair_notional_usd, + max_notional_per_pair_usd=p.max_notional_per_pair_usd, + max_total_notional_usd=p.max_total_notional_usd, + max_leg_notional_usd=p.max_leg_notional_usd, + participation_rate=bt.participation_rate, + min_history_points=bt.min_history_points, + volatility_window_points=bt.volatility_window_points, + require_orderbook_history=bt.require_orderbook_history, + spread_decay_bps=bt.spread_decay_bps, + join_best_queue_factor=bt.join_best_queue_factor, + off_best_queue_factor=bt.off_best_queue_factor, + synthetic_orderbook_half_spread_bps=bt.synthetic_orderbook_half_spread_bps, + synthetic_orderbook_depth_usd=bt.synthetic_orderbook_depth_usd, + telemetry_path=bt.telemetry_path, + ) + + def _normalize_history( raw_history: Any, start_ts: int, @@ -477,7 +539,14 @@ def _align_histories(primary: list[tuple[int, float]], secondary: list[tuple[int return aligned_primary, aligned_secondary +def _combine_orderbook_mode(primary_mode: str, pair_mode: str) -> str: + if primary_mode == pair_mode: + return primary_mode + return f"{primary_mode}|{pair_mode}" + + def _fetch_live_backtest_pairs(p: StrategyParams, bt: BacktestParams, start_ts: int, end_ts: int) -> list[dict[str, Any]]: + replay_params = _to_pair_replay_params(p, bt) offset = 0 candidates: list[dict[str, Any]] = [] seen_token_ids: set[str] = set() @@ -572,7 +641,23 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None ) if len(history) < bt.min_history_points: return None - return {**candidate, "history": history} + try: + book_payload = _http_get_json( + f"{SEREN_POLYMARKET_TRADING_URL_PREFIX}/book?{urlencode({'token_id': candidate['token_id']})}" + ) + except Exception: + book_payload = None + orderbooks, orderbook_mode = snapshot_from_live_book( + payload=book_payload, + history=history, + params=replay_params, + ) + return { + **candidate, + "history": history, + "orderbooks": orderbooks, + "orderbook_mode": orderbook_mode, + } with_history: list[dict[str, Any]] = [] with ThreadPoolExecutor(max_workers=max(1, bt.history_fetch_workers)) as executor: @@ -605,11 +690,19 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None "pair_market_id": pair_id, "question": _safe_str(primary.get("question"), market_id), "pair_question": _safe_str(secondary.get("question"), pair_id), + "token_id": _safe_str(primary.get("token_id"), ""), + "pair_token_id": _safe_str(secondary.get("token_id"), ""), "event_id": event_id, "end_ts": min(_safe_int(primary.get("end_ts"), end_ts + 86400), _safe_int(secondary.get("end_ts"), end_ts + 86400)), "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, + "orderbooks": primary.get("orderbooks", {}), + "pair_orderbooks": secondary.get("orderbooks", {}), + "orderbook_mode": _combine_orderbook_mode( + _safe_str(primary.get("orderbook_mode"), "unknown"), + _safe_str(secondary.get("orderbook_mode"), "unknown"), + ), "source": "live-seren-publisher", } ) @@ -633,11 +726,19 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None "pair_market_id": pair_id, "question": _safe_str(primary.get("question"), market_id), "pair_question": _safe_str(secondary.get("question"), pair_id), + "token_id": _safe_str(primary.get("token_id"), ""), + "pair_token_id": _safe_str(secondary.get("token_id"), ""), "event_id": "fallback", "end_ts": min(_safe_int(primary.get("end_ts"), end_ts + 86400), _safe_int(secondary.get("end_ts"), end_ts + 86400)), "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, + "orderbooks": primary.get("orderbooks", {}), + "pair_orderbooks": secondary.get("orderbooks", {}), + "orderbook_mode": _combine_orderbook_mode( + _safe_str(primary.get("orderbook_mode"), "unknown"), + _safe_str(secondary.get("orderbook_mode"), "unknown"), + ), "source": "live-seren-publisher-fallback", } ) @@ -645,12 +746,102 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None return pairs +def _load_markets_from_fixture( + *, + fixture_path: Path, + p: StrategyParams, + bt: BacktestParams, + start_ts: int, + end_ts: int, +) -> list[dict[str, Any]]: + replay_params = _to_pair_replay_params(p, bt) + payload = load_json(fixture_path) + if isinstance(payload, dict): + rows = payload.get("markets", []) + elif isinstance(payload, list): + rows = payload + else: + rows = [] + if not isinstance(rows, list): + return [] + + markets: list[dict[str, Any]] = [] + for idx, row in enumerate(rows): + if not isinstance(row, dict): + continue + history = _normalize_history( + row.get("history"), + start_ts=start_ts, + end_ts=end_ts, + token_id=_safe_str(row.get("token_id"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + ) + pair_history = _normalize_history( + row.get("pair_history"), + start_ts=start_ts, + end_ts=end_ts, + token_id=_safe_str(row.get("pair_token_id"), _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b")), + ) + if len(history) < bt.min_history_points or len(pair_history) < bt.min_history_points: + continue + orderbooks, primary_mode = normalize_orderbook_snapshots( + row.get("orderbooks"), + history, + replay_params, + ) + pair_orderbooks, pair_mode = normalize_orderbook_snapshots( + row.get("pair_orderbooks"), + pair_history, + replay_params, + ) + markets.append( + { + "market_id": _safe_str(row.get("market_id"), f"fixture-{idx}-a"), + "pair_market_id": _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + "question": _safe_str(row.get("question"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + "pair_question": _safe_str( + row.get("pair_question"), + _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + ), + "token_id": _safe_str(row.get("token_id"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + "pair_token_id": _safe_str( + row.get("pair_token_id"), + _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + ), + "event_id": _safe_str(row.get("event_id"), f"fixture-{idx}"), + "end_ts": _safe_int(row.get("end_ts"), end_ts + 86400), + "rebate_bps": _safe_float(row.get("rebate_bps"), p.maker_rebate_bps), + "history": history, + "pair_history": pair_history, + "orderbooks": orderbooks, + "pair_orderbooks": pair_orderbooks, + "orderbook_mode": _combine_orderbook_mode(primary_mode, pair_mode), + "source": "fixture", + } + ) + return markets + + def _load_backtest_markets( p: StrategyParams, bt: BacktestParams, start_ts: int, end_ts: int, + backtest_file: str | None = None, ) -> tuple[list[dict[str, Any]], str]: + if backtest_file: + fixture_path = Path(backtest_file) + if not fixture_path.exists(): + raise FileNotFoundError(f"Backtest fixture not found: {fixture_path}") + return ( + _load_markets_from_fixture( + fixture_path=fixture_path, + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + ), + f"fixture:{fixture_path.name}", + ) return _fetch_live_backtest_pairs(p=p, bt=bt, start_ts=start_ts, end_ts=end_ts), "live-seren-publisher" @@ -687,86 +878,63 @@ def _sharpe_like_score(event_pnls: list[float], bankroll_usd: float, days: int) def _simulate_pair(market: dict[str, Any], p: StrategyParams, bt: BacktestParams) -> dict[str, Any]: - primary = market["history"] - pair = market["pair_history"] - n = min(len(primary), len(pair)) - if n < bt.min_history_points: - return { - "market_id": market["market_id"], - "pair_market_id": market["pair_market_id"], - "considered_points": 0, - "traded_points": 0, - "filled_notional_usd": 0.0, - "pnl_usd": 0.0, - "event_pnls": [], - } - - rebate_bps = _safe_float(market.get("rebate_bps"), p.maker_rebate_bps) - if rebate_bps <= 0: - rebate_bps = p.maker_rebate_bps - - basis_series_bps = [(primary[i][1] - pair[i][1]) * 10000.0 for i in range(n)] - considered = 0 - traded = 0 - filled_notional = 0.0 - pnl = 0.0 - event_pnls: list[float] = [] - - for i in range(0, n - 1): - t = primary[i][0] - ttl = max(0, _safe_int(market.get("end_ts"), t + 86400) - t) - if ttl < p.min_seconds_to_resolution: - continue - - basis_now = basis_series_bps[i] - basis_next = basis_series_bps[i + 1] - abs_basis_now = abs(basis_now) - if abs_basis_now < p.basis_entry_bps: - continue - - considered += 1 - basis_change = abs_basis_now - abs(basis_next) - expected_convergence = abs_basis_now * p.expected_convergence_ratio - expected_edge = expected_convergence + rebate_bps - p.expected_unwind_cost_bps - p.adverse_selection_bps - if expected_edge < p.min_edge_bps: - continue - - traded += 1 - fill_intensity = min(1.0, abs_basis_now / max(p.basis_entry_bps * 2.0, 1e-9)) - event_notional = p.base_pair_notional_usd * bt.participation_rate * fill_intensity - realized_edge = basis_change + rebate_bps - p.expected_unwind_cost_bps - p.adverse_selection_bps - event_pnl = event_notional * realized_edge / 10000.0 - - filled_notional += event_notional - pnl += event_pnl - event_pnls.append(event_pnl) - + replay_params = _to_pair_replay_params(p, bt) + history = market.get("history", []) + pair_history = market.get("pair_history", []) + orderbooks = market.get("orderbooks") + pair_orderbooks = market.get("pair_orderbooks") + orderbook_mode = _safe_str(market.get("orderbook_mode"), "") + if not isinstance(orderbooks, dict): + orderbooks, primary_mode = normalize_orderbook_snapshots([], history, replay_params) + market = {**market, "orderbooks": orderbooks} + orderbook_mode = primary_mode + else: + primary_mode = orderbook_mode.split("|", 1)[0] if orderbook_mode else "unknown" + if not isinstance(pair_orderbooks, dict): + pair_orderbooks, pair_mode = normalize_orderbook_snapshots([], pair_history, replay_params) + market = {**market, "pair_orderbooks": pair_orderbooks} + orderbook_mode = _combine_orderbook_mode(primary_mode, pair_mode) + if orderbook_mode: + market = {**market, "orderbook_mode": orderbook_mode} + result = simulate_pair_backtest(market=market, params=replay_params) return { - "market_id": market["market_id"], - "pair_market_id": market["pair_market_id"], - "considered_points": considered, - "traded_points": traded, - "filled_notional_usd": round(filled_notional, 4), - "pnl_usd": round(pnl, 6), - "event_pnls": event_pnls, + **result, + "traded_points": result["quoted_points"], } -def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, Any]: +def run_backtest( + config: dict[str, Any], + backtest_days: int | None, + backtest_file: str | None = None, +) -> dict[str, Any]: p = to_strategy_params(config) bt = to_backtest_params(config) days = int(clamp(backtest_days if backtest_days is not None else bt.days, bt.days_min, bt.days_max)) + configured_backtest_file = "" + if isinstance(config.get("backtest"), dict): + configured_backtest_file = _safe_str(config["backtest"].get("backtest_file"), "") + selected_backtest_file = backtest_file or configured_backtest_file or None end_ts = int(time.time()) start_ts = end_ts - (days * 24 * 60 * 60) try: - markets, source = _load_backtest_markets( - p=p, - bt=bt, - start_ts=start_ts, - end_ts=end_ts, - ) + if selected_backtest_file: + markets, source = _load_backtest_markets( + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + backtest_file=selected_backtest_file, + ) + else: + markets, source = _load_backtest_markets( + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + ) except Exception as exc: return { "status": "error", @@ -788,8 +956,12 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, summaries: list[dict[str, Any]] = [] event_pnls: list[float] = [] considered = 0 - traded = 0 + quoted = 0 + skipped = 0 + fill_events = 0 total_notional = 0.0 + telemetry: list[dict[str, Any]] = [] + orderbook_modes: dict[str, int] = defaultdict(int) for market in markets: result = _simulate_pair(market, p, bt) @@ -798,15 +970,23 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "market_id": result["market_id"], "pair_market_id": result["pair_market_id"], "considered_points": result["considered_points"], + "quoted_points": result["quoted_points"], "traded_points": result["traded_points"], + "skipped_points": result["skipped_points"], + "fill_events": result["fill_events"], "filled_notional_usd": result["filled_notional_usd"], "pnl_usd": result["pnl_usd"], + "orderbook_mode": result["orderbook_mode"], } ) considered += int(result["considered_points"]) - traded += int(result["traded_points"]) + quoted += int(result["quoted_points"]) + skipped += int(result["skipped_points"]) + fill_events += int(result["fill_events"]) total_notional += float(result["filled_notional_usd"]) event_pnls.extend(result["event_pnls"]) + telemetry.extend(result.get("telemetry", [])) + orderbook_modes[_safe_str(result.get("orderbook_mode"), "unknown")] += 1 equity_curve = [p.bankroll_usd] equity = p.bankroll_usd @@ -840,11 +1020,16 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "source": source, "pairs_loaded": len(markets), "events_observed": events, + "quoted_points": quoted, + "fill_events": fill_events, "min_events_required": bt.min_events, + "orderbook_modes": dict(sorted(orderbook_modes.items())), }, "disclaimer": DISCLAIMER, } + write_telemetry_records(bt.telemetry_path, telemetry) + return { "status": "ok", "skill": "liquidity-paired-basis-maker", @@ -858,8 +1043,14 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "source": source, "pairs_selected": len(summaries), "considered_points": considered, - "traded_points": traded, - "trade_rate_pct": round((traded / considered) * 100.0 if considered else 0.0, 4), + "quoted_points": quoted, + "traded_points": quoted, + "skipped_points": skipped, + "fill_events": fill_events, + "trade_rate_pct": round((quoted / considered) * 100.0 if considered else 0.0, 4), + "orderbook_modes": dict(sorted(orderbook_modes.items())), + "telemetry_path": bt.telemetry_path, + "telemetry_records": len(telemetry), }, "results": { "starting_bankroll_usd": round(p.bankroll_usd, 2), @@ -873,6 +1064,7 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "filled_notional_usd": round(total_notional, 2), "turnover_multiple": round(turnover_multiple, 4), "events": events, + "fill_events": fill_events, "min_events_required": bt.min_events, "max_drawdown_usd": round(max_drawdown_usd, 4), "max_drawdown_pct": round(display_max_drawdown_pct, 4), @@ -1225,10 +1417,10 @@ def run_trade(config: dict[str, Any], markets_file: str | None, yes_live: bool) exposure = config.get("state", {}).get("leg_exposure", {}) leg_exposure = {str(k): _safe_float(v, 0.0) for k, v in exposure.items()} - live_trader: PolymarketPublisherTrader | None = None + live_trader: DirectClobTrader | None = None if live_mode: try: - live_trader = PolymarketPublisherTrader( + live_trader = DirectClobTrader( skill_root=Path(__file__).resolve().parents[1], client_name="liquidity-paired-basis-maker", ) @@ -1312,6 +1504,7 @@ def main() -> int: backtest = run_backtest( config=config, backtest_days=args.backtest_days, + backtest_file=args.backtest_file, ) if backtest.get("status") != "ok": print(json.dumps(backtest, sort_keys=True)) diff --git a/polymarket/liquidity-paired-basis-maker/tests/test_smoke.py b/polymarket/liquidity-paired-basis-maker/tests/test_smoke.py index f13fa9a..745730b 100644 --- a/polymarket/liquidity-paired-basis-maker/tests/test_smoke.py +++ b/polymarket/liquidity-paired-basis-maker/tests/test_smoke.py @@ -68,7 +68,7 @@ def test_dry_run_fixture_blocks_live_execution() -> None: assert payload["blocked_action"] == "live_execution" -def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None: +def test_config_example_runs_stateful_backtest_and_reports_replay_metrics(monkeypatch) -> None: module = _load_agent_module() payload = json.loads(CONFIG_EXAMPLE_PATH.read_text(encoding="utf-8")) @@ -100,7 +100,11 @@ def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None output = module.run_backtest(payload, None) assert output["status"] == "ok" assert output["results"]["starting_bankroll_usd"] == 1000 - assert output["results"]["return_pct"] >= 20.0 + assert output["results"]["fill_events"] > 0 + assert output["backtest_summary"]["quoted_points"] > 0 + assert sum(output["backtest_summary"]["orderbook_modes"].values()) == len(synthetic_markets) + assert output["results"]["return_pct"] >= -100.0 + assert output["pairs"][0]["orderbook_mode"] in output["backtest_summary"]["orderbook_modes"] def test_trade_mode_fetches_live_pairs_when_config_markets_is_empty(monkeypatch) -> None: diff --git a/polymarket/maker-rebate-bot/SKILL.md b/polymarket/maker-rebate-bot/SKILL.md index 25175c1..675bf10 100644 --- a/polymarket/maker-rebate-bot/SKILL.md +++ b/polymarket/maker-rebate-bot/SKILL.md @@ -13,10 +13,10 @@ description: "Provide two-sided liquidity on Polymarket with rebate-aware quotin ## Workflow Summary -1. `fetch_backtest_universe` loads candidate markets from Polymarket APIs (or local fixtures). -2. `replay_90d_history` replays historical prices and simulates maker fills. -3. `score_edge_and_pnl` estimates realized edge and PnL (spread + rebate - pickoff/unwind costs). -4. `summarize_backtest` returns return %, drawdown, quoted rate, and market-level results. +1. `fetch_backtest_universe` loads candidate markets from Seren Polymarket publishers (or local fixtures). +2. `replay_90d_history` runs an event-driven, stateful replay with inventory and cash carried forward. +3. `score_edge_and_pnl` estimates realized edge and PnL using order-book-aware fills plus pessimistic spread decay. +4. `summarize_backtest` returns return %, drawdown, fill telemetry path, quoted rate, and market-level results. 5. `filter_markets` removes markets near resolution or outside quality thresholds. 6. `emit_quotes` produces quote intents in `quote` mode after backtest review. 7. `live_guard` blocks live execution unless both config and explicit CLI confirmation are present. @@ -80,6 +80,7 @@ Each backtest market object should include: - `token_id` (string) - `end_ts` or `endDate` (market resolution timestamp) - `history` array of `{ "t": unix_ts, "p": probability_0_to_1 }` +- optional `orderbooks` array of `{ "t": unix_ts, "best_bid": ..., "best_ask": ..., "bid_size_usd": ..., "ask_size_usd": ... }` - optional `rebate_bps` (number; otherwise default rebate from config) ## Safety Notes @@ -87,6 +88,8 @@ Each backtest market object should include: - Live execution is never enabled by default. - Live quote cycles cancel stale orders, fetch fresh market snapshots, and then poll open orders/positions after requoting. - Backtests are estimates and can materially differ from live outcomes. +- Replay enforces the same market, total, and position caps used by quote mode. +- Backtests emit JSONL quote/fill telemetry for later calibration when `backtest.telemetry_path` is set. - Quotes are blocked when estimated edge is negative. - Markets close to resolution are excluded. - Position and notional caps are enforced before orders are emitted. diff --git a/polymarket/maker-rebate-bot/config.example.json b/polymarket/maker-rebate-bot/config.example.json index 0b4c4ea..c012f6c 100644 --- a/polymarket/maker-rebate-bot/config.example.json +++ b/polymarket/maker-rebate-bot/config.example.json @@ -16,6 +16,13 @@ "min_liquidity_usd": 25000, "markets_fetch_limit": 500, "min_history_points": 480, + "require_orderbook_history": false, + "spread_decay_bps": 45, + "join_best_queue_factor": 0.85, + "off_best_queue_factor": 0.35, + "synthetic_orderbook_half_spread_bps": 18, + "synthetic_orderbook_depth_usd": 125, + "telemetry_path": "logs/polymarket-maker-rebate-backtest-telemetry.jsonl", "gamma_markets_url": "https://api.serendb.com/publishers/polymarket-data/markets", "clob_history_url": "https://api.serendb.com/publishers/polymarket-trading-serenai/trades" }, diff --git a/polymarket/maker-rebate-bot/scripts/agent.py b/polymarket/maker-rebate-bot/scripts/agent.py index 83cc00e..3eaace6 100644 --- a/polymarket/maker-rebate-bot/scripts/agent.py +++ b/polymarket/maker-rebate-bot/scripts/agent.py @@ -5,6 +5,7 @@ import argparse import json +import math import os import sys import time @@ -21,7 +22,7 @@ sys.path.insert(0, str(SHARED_DIR)) from polymarket_live import ( - PolymarketPublisherTrader, + DirectClobTrader, execute_single_market_quotes, live_settings_from_execution, load_live_single_markets, @@ -77,10 +78,41 @@ class BacktestParams: min_liquidity_usd: float = 25000.0 markets_fetch_limit: int = 500 min_history_points: int = 480 + require_orderbook_history: bool = False + spread_decay_bps: float = 45.0 + join_best_queue_factor: float = 0.85 + off_best_queue_factor: float = 0.35 + synthetic_orderbook_half_spread_bps: float = 18.0 + synthetic_orderbook_depth_usd: float = 125.0 + telemetry_path: str = "logs/polymarket-maker-rebate-backtest-telemetry.jsonl" gamma_markets_url: str = f"{SEREN_POLYMARKET_DATA_URL_PREFIX}/markets" clob_history_url: str = f"{SEREN_POLYMARKET_TRADING_URL_PREFIX}/trades" +@dataclass(frozen=True) +class OrderBookSnapshot: + t: int + best_bid: float + best_ask: float + bid_size_usd: float + ask_size_usd: float + + +@dataclass(frozen=True) +class QuotePlan: + status: str + market_id: str + edge_bps: float + spread_bps: float + rebate_bps: float + bid_price: float = 0.0 + ask_price: float = 0.0 + bid_notional_usd: float = 0.0 + ask_notional_usd: float = 0.0 + inventory_notional_usd: float = 0.0 + reason: str = "" + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run Polymarket maker/rebate strategy.") parser.add_argument("--config", default="config.json", help="Config file path.") @@ -263,6 +295,30 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: min_liquidity_usd=max(0.0, _safe_float(backtest.get("min_liquidity_usd"), 25000.0)), markets_fetch_limit=max(1, _safe_int(backtest.get("markets_fetch_limit"), 500)), min_history_points=max(10, _safe_int(backtest.get("min_history_points"), 480)), + require_orderbook_history=bool(backtest.get("require_orderbook_history", False)), + spread_decay_bps=max(1.0, _safe_float(backtest.get("spread_decay_bps"), 45.0)), + join_best_queue_factor=clamp( + _safe_float(backtest.get("join_best_queue_factor"), 0.85), + 0.0, + 1.0, + ), + off_best_queue_factor=clamp( + _safe_float(backtest.get("off_best_queue_factor"), 0.35), + 0.0, + 1.0, + ), + synthetic_orderbook_half_spread_bps=max( + 1.0, + _safe_float(backtest.get("synthetic_orderbook_half_spread_bps"), 18.0), + ), + synthetic_orderbook_depth_usd=max( + 1.0, + _safe_float(backtest.get("synthetic_orderbook_depth_usd"), 125.0), + ), + telemetry_path=_safe_str( + backtest.get("telemetry_path"), + "logs/polymarket-maker-rebate-backtest-telemetry.jsonl", + ), gamma_markets_url=_safe_str( backtest.get("gamma_markets_url"), f"{SEREN_POLYMARKET_DATA_URL_PREFIX}/markets", @@ -314,7 +370,7 @@ def _parse_iso_ts(value: Any) -> int | None: def _coerce_unix_ts(value: Any) -> int: - if isinstance(value, int | float): + if isinstance(value, (int, float)): ts = int(value) if ts > 10_000_000_000: ts //= 1000 @@ -344,6 +400,95 @@ def _json_to_list(value: Any) -> list[Any]: return [] +def _extract_size_usd(raw: dict[str, Any], price: float) -> float: + direct = _safe_float(raw.get("size_usd"), -1.0) + if direct >= 0.0: + return direct + size = _safe_float( + raw.get("size", raw.get("quantity", raw.get("amount", raw.get("shares", 0.0)))), + 0.0, + ) + if size <= 0.0: + return 0.0 + return size if price <= 0.0 else size * price + + +def _top_level_price(levels: Any) -> tuple[float, float]: + if isinstance(levels, list) and levels: + first = levels[0] + if isinstance(first, dict): + price = _safe_float(first.get("price"), -1.0) + return price, _extract_size_usd(first, price=max(price, 0.0)) + return -1.0, 0.0 + + +def _normalize_orderbook_snapshots( + raw_snapshots: Any, + history: list[tuple[int, float]], + backtest_params: BacktestParams, +) -> tuple[dict[int, OrderBookSnapshot], str]: + snapshots: dict[int, OrderBookSnapshot] = {} + if isinstance(raw_snapshots, list): + for item in raw_snapshots: + if not isinstance(item, dict): + continue + ts = _safe_int(item.get("t"), -1) + if ts < 0: + continue + best_bid = _safe_float(item.get("best_bid"), -1.0) + best_ask = _safe_float(item.get("best_ask"), -1.0) + bid_size_usd = _safe_float(item.get("bid_size_usd"), -1.0) + ask_size_usd = _safe_float(item.get("ask_size_usd"), -1.0) + if best_bid < 0.0: + best_bid, inferred_bid_size = _top_level_price(item.get("bids")) + if bid_size_usd < 0.0: + bid_size_usd = inferred_bid_size + if best_ask < 0.0: + best_ask, inferred_ask_size = _top_level_price(item.get("asks")) + if ask_size_usd < 0.0: + ask_size_usd = inferred_ask_size + if best_bid < 0.0 or best_ask < 0.0 or best_bid > best_ask: + continue + snapshots[ts] = OrderBookSnapshot( + t=ts, + best_bid=best_bid, + best_ask=best_ask, + bid_size_usd=max(0.0, bid_size_usd), + ask_size_usd=max(0.0, ask_size_usd), + ) + if snapshots: + return snapshots, "historical" + + if backtest_params.require_orderbook_history: + raise RuntimeError( + "Stateful backtest requires historical order-book snapshots. " + "Provide orderbooks in --backtest-file / backtest_markets or disable require_orderbook_history." + ) + + synthetic: dict[int, OrderBookSnapshot] = {} + half_spread = backtest_params.synthetic_orderbook_half_spread_bps / 10000.0 + for ts, mid in history: + synthetic[ts] = OrderBookSnapshot( + t=ts, + best_bid=clamp(mid - half_spread, 0.001, 0.999), + best_ask=clamp(mid + half_spread, 0.001, 0.999), + bid_size_usd=backtest_params.synthetic_orderbook_depth_usd, + ask_size_usd=backtest_params.synthetic_orderbook_depth_usd, + ) + return synthetic, "synthetic" + + +def _write_telemetry_records(path: str, records: list[dict[str, Any]]) -> None: + if not path or not records: + return + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("w", encoding="utf-8") as handle: + for record in records: + handle.write(json.dumps(record, sort_keys=True)) + handle.write("\n") + + def _extract_history_rows(payload: Any) -> list[Any]: if isinstance(payload, list): return payload @@ -399,7 +544,7 @@ def _row_matches_token(row: dict[str, Any], token_id: str) -> bool: def _history_point_from_row(row: Any, token_id: str) -> tuple[int, float] | None: - if isinstance(row, list | tuple) and len(row) >= 2: + if isinstance(row, (list, tuple)) and len(row) >= 2: ts = _coerce_unix_ts(row[0]) p = _normalize_probability(row[1]) if ts < 0 or not (0.0 <= p <= 1.0): @@ -586,6 +731,7 @@ def _load_markets_from_fixture( payload: dict[str, Any] | list[Any], start_ts: int, end_ts: int, + backtest_params: BacktestParams, ) -> list[dict[str, Any]]: raw_markets: list[Any] if isinstance(payload, dict): @@ -606,6 +752,11 @@ def _load_markets_from_fixture( ) if len(history) < 2: continue + orderbooks, orderbook_mode = _normalize_orderbook_snapshots( + raw_snapshots=raw.get("orderbooks", raw.get("book_history")), + history=history, + backtest_params=backtest_params, + ) market_id = _safe_str(raw.get("market_id"), _safe_str(raw.get("token_id"), "unknown")) markets.append( { @@ -615,18 +766,71 @@ def _load_markets_from_fixture( "end_ts": _safe_int(raw.get("end_ts"), _parse_iso_ts(raw.get("endDate")) or 0), "rebate_bps": _safe_float(raw.get("rebate_bps"), 0.0), "history": history, + "orderbooks": orderbooks, + "orderbook_mode": orderbook_mode, "source": "fixture", } ) return markets +def _snapshot_from_live_book( + payload: dict[str, Any] | list[Any] | None, + history: list[tuple[int, float]], + backtest_params: BacktestParams, +) -> dict[int, OrderBookSnapshot]: + if not history: + return {} + best_bid = -1.0 + best_ask = -1.0 + bid_size = 0.0 + ask_size = 0.0 + if isinstance(payload, dict): + best_bid = _safe_float(payload.get("best_bid", payload.get("bid")), -1.0) + best_ask = _safe_float(payload.get("best_ask", payload.get("ask")), -1.0) + bid_size = _safe_float(payload.get("bid_size_usd"), -1.0) + ask_size = _safe_float(payload.get("ask_size_usd"), -1.0) + if best_bid < 0.0: + best_bid, inferred_bid_size = _top_level_price(payload.get("bids")) + if bid_size < 0.0: + bid_size = inferred_bid_size + if best_ask < 0.0: + best_ask, inferred_ask_size = _top_level_price(payload.get("asks")) + if ask_size < 0.0: + ask_size = inferred_ask_size + if best_bid < 0.0 or best_ask < 0.0 or best_bid >= best_ask: + synthetic, _ = _normalize_orderbook_snapshots([], history, backtest_params) + return synthetic + + half_spread = max( + (best_ask - best_bid) / 2.0, + backtest_params.synthetic_orderbook_half_spread_bps / 10000.0, + ) + reference_bid_size = max(0.0, bid_size) or backtest_params.synthetic_orderbook_depth_usd + reference_ask_size = max(0.0, ask_size) or backtest_params.synthetic_orderbook_depth_usd + snapshots: dict[int, OrderBookSnapshot] = {} + for ts, mid in history: + snapshots[ts] = OrderBookSnapshot( + t=ts, + best_bid=clamp(mid - half_spread, 0.001, 0.999), + best_ask=clamp(mid + half_spread, 0.001, 0.999), + bid_size_usd=reference_bid_size, + ask_size_usd=reference_ask_size, + ) + return snapshots + + def _fetch_live_markets( strategy_params: StrategyParams, backtest_params: BacktestParams, start_ts: int, end_ts: int, ) -> list[dict[str, Any]]: + if backtest_params.require_orderbook_history: + raise RuntimeError( + "Historical order-book replay is required. Provide --backtest-file or backtest_markets " + "with orderbooks because live publisher fetch does not supply historical book snapshots." + ) query = urlencode( { "active": "true", @@ -679,10 +883,23 @@ def _fetch_live_markets( ) if len(history) < backtest_params.min_history_points: continue + try: + book_payload = _http_get_json( + f"{SEREN_POLYMARKET_TRADING_URL_PREFIX}/book?{urlencode({'token_id': candidate['token_id']})}" + ) + except Exception: + book_payload = None + orderbooks = _snapshot_from_live_book( + payload=book_payload, + history=history, + backtest_params=backtest_params, + ) selected.append( { **candidate, "history": history, + "orderbooks": orderbooks, + "orderbook_mode": "synthetic-from-live-book", "source": "live-seren-publisher", } ) @@ -767,12 +984,181 @@ def _max_drawdown(equity_curve: list[float]) -> float: return max_dd +def _build_quote_plan( + *, + market_id: str, + mid_price: float, + volatility_bps: float, + rebate_bps: float, + inventory_notional: float, + outstanding_notional: float, + strategy_params: StrategyParams, +) -> QuotePlan: + spread_bps = compute_spread_bps(volatility_bps, strategy_params) + edge_bps = expected_edge_bps(spread_bps, rebate_bps, strategy_params) + if edge_bps < strategy_params.min_edge_bps: + return QuotePlan( + status="skipped", + market_id=market_id, + reason="negative_or_thin_edge", + edge_bps=round(edge_bps, 3), + spread_bps=round(spread_bps, 3), + rebate_bps=round(rebate_bps, 3), + inventory_notional_usd=round(inventory_notional, 2), + ) + + inventory_ratio = 0.0 + if strategy_params.max_position_notional_usd > 0: + inventory_ratio = clamp( + inventory_notional / strategy_params.max_position_notional_usd, + -1.0, + 1.0, + ) + skew_bps = -inventory_ratio * strategy_params.inventory_skew_strength_bps + half_spread_prob = (spread_bps / 2.0) / 10000.0 + skew_prob = skew_bps / 10000.0 + bid_price = clamp(mid_price - half_spread_prob + skew_prob, 0.001, 0.999) + ask_price = clamp(mid_price + half_spread_prob + skew_prob, 0.001, 0.999) + if bid_price >= ask_price: + return QuotePlan( + status="skipped", + market_id=market_id, + reason="crossed_quote_after_skew", + edge_bps=round(edge_bps, 3), + spread_bps=round(spread_bps, 3), + rebate_bps=round(rebate_bps, 3), + inventory_notional_usd=round(inventory_notional, 2), + ) + + remaining_market = max(0.0, strategy_params.max_notional_per_market_usd - abs(inventory_notional)) + remaining_total = max(0.0, strategy_params.max_total_notional_usd - max(0.0, outstanding_notional)) + bid_position_capacity = max(0.0, strategy_params.max_position_notional_usd - inventory_notional) + ask_position_capacity = max(0.0, strategy_params.max_position_notional_usd + inventory_notional) + per_side_market_budget = remaining_market / 2.0 + per_side_total_budget = remaining_total / 2.0 + bid_notional = min( + strategy_params.base_order_notional_usd, + per_side_market_budget, + per_side_total_budget, + bid_position_capacity, + ) + ask_notional = min( + strategy_params.base_order_notional_usd, + per_side_market_budget, + per_side_total_budget, + ask_position_capacity, + ) + if bid_notional <= 0.0 and ask_notional <= 0.0: + return QuotePlan( + status="skipped", + market_id=market_id, + reason="risk_capacity_exhausted", + edge_bps=round(edge_bps, 3), + spread_bps=round(spread_bps, 3), + rebate_bps=round(rebate_bps, 3), + inventory_notional_usd=round(inventory_notional, 2), + ) + + return QuotePlan( + status="quoted", + market_id=market_id, + edge_bps=round(edge_bps, 3), + spread_bps=round(spread_bps, 3), + rebate_bps=round(rebate_bps, 3), + bid_price=round(bid_price, 4), + ask_price=round(ask_price, 4), + bid_notional_usd=round(max(0.0, bid_notional), 2), + ask_notional_usd=round(max(0.0, ask_notional), 2), + inventory_notional_usd=round(inventory_notional, 2), + ) + + +def _liquidation_equity( + *, + cash_usd: float, + position_shares: float, + mark_price: float, + unwind_cost_bps: float, +) -> float: + inventory_value = position_shares * mark_price + liquidation_cost = abs(inventory_value) * unwind_cost_bps / 10000.0 + return cash_usd + inventory_value - liquidation_cost + + +def _fill_fraction( + *, + side: str, + quote_price: float, + quote_notional: float, + current_book: OrderBookSnapshot, + next_book: OrderBookSnapshot, + next_mid: float, + spread_bps: float, + backtest_params: BacktestParams, + strategy_params: StrategyParams, +) -> float: + if quote_notional <= 0.0: + return 0.0 + if side == "buy": + touched_price = min(next_mid, next_book.best_bid) + touched_distance_bps = max(0.0, (quote_price - touched_price) * 10000.0) + displayed_size = next_book.ask_size_usd + queue_factor = ( + backtest_params.join_best_queue_factor + if quote_price >= current_book.best_bid + else backtest_params.off_best_queue_factor + ) + else: + touched_price = max(next_mid, next_book.best_ask) + touched_distance_bps = max(0.0, (touched_price - quote_price) * 10000.0) + displayed_size = next_book.bid_size_usd + queue_factor = ( + backtest_params.join_best_queue_factor + if quote_price <= current_book.best_ask + else backtest_params.off_best_queue_factor + ) + if touched_distance_bps <= 0.0: + return 0.0 + half_spread_bps = max(spread_bps / 2.0, 1.0) + touch_ratio = clamp(touched_distance_bps / half_spread_bps, 0.0, 1.0) + spread_decay = math.exp( + -max(0.0, spread_bps - strategy_params.min_spread_bps) / backtest_params.spread_decay_bps + ) + depth_factor = clamp(displayed_size / max(quote_notional, 1e-9), 0.0, 1.0) + return clamp( + backtest_params.participation_rate * touch_ratio * spread_decay * queue_factor * depth_factor, + 0.0, + 1.0, + ) + + +def _apply_fill( + *, + side: str, + fill_notional: float, + fill_price: float, + rebate_bps: float, + cash_usd: float, + position_shares: float, +) -> tuple[float, float]: + shares = fill_notional / max(fill_price, 0.01) + if side == "buy": + cash_usd -= shares * fill_price + position_shares += shares + else: + cash_usd += shares * fill_price + position_shares -= shares + cash_usd += fill_notional * rebate_bps / 10000.0 + return cash_usd, position_shares + + def _simulate_market_backtest( market: dict[str, Any], strategy_params: StrategyParams, backtest_params: BacktestParams, ) -> dict[str, Any]: history: list[tuple[int, float]] = market["history"] + orderbooks: dict[int, OrderBookSnapshot] = market.get("orderbooks", {}) window = backtest_params.volatility_window_points if len(history) < window + 2: return { @@ -781,76 +1167,196 @@ def _simulate_market_backtest( "considered_points": 0, "quoted_points": 0, "skipped_points": 0, + "fill_events": 0, "filled_notional_usd": 0.0, "pnl_usd": 0.0, - "event_pnls": [], + "equity_curve": [strategy_params.bankroll_usd], + "telemetry": [], + "orderbook_mode": market.get("orderbook_mode", "unknown"), } - moves_bps = [ - abs((history[i][1] - history[i - 1][1]) * 10000.0) - for i in range(1, len(history)) - ] rebate_bps = _safe_float(market.get("rebate_bps"), strategy_params.default_rebate_bps) if rebate_bps <= 0: rebate_bps = strategy_params.default_rebate_bps end_ts = _safe_int(market.get("end_ts"), 0) + moves_bps = [abs((history[i][1] - history[i - 1][1]) * 10000.0) for i in range(1, len(history))] + cash_usd = strategy_params.bankroll_usd + position_shares = 0.0 considered = 0 quoted = 0 skipped = 0 + fill_events = 0 filled_notional = 0.0 - pnl = 0.0 - event_pnls: list[float] = [] + telemetry: list[dict[str, Any]] = [] + equity_curve = [strategy_params.bankroll_usd] for i in range(window, len(history) - 1): t, mid_price = history[i] - _, next_price = history[i + 1] - considered += 1 + next_t, next_price = history[i + 1] + current_book = orderbooks.get(t) + next_book = orderbooks.get(next_t, current_book) + if current_book is None or next_book is None: + skipped += 1 + continue + considered += 1 + record: dict[str, Any] = { + "t": t, + "market_id": market["market_id"], + "mid_price": round(mid_price, 6), + "next_mid_price": round(next_price, 6), + "best_bid": round(current_book.best_bid, 6), + "best_ask": round(current_book.best_ask, 6), + "inventory_notional_before_usd": round(position_shares * mid_price, 6), + "orderbook_mode": market.get("orderbook_mode", "unknown"), + } if end_ts and end_ts - t < strategy_params.min_seconds_to_resolution: skipped += 1 + record["status"] = "skipped" + record["reason"] = "near_resolution" + telemetry.append(record) continue if mid_price <= 0.01 or mid_price >= 0.99: skipped += 1 + record["status"] = "skipped" + record["reason"] = "extreme_probability" + telemetry.append(record) continue vol_slice = moves_bps[i - window : i] vol_bps = pstdev(vol_slice) if len(vol_slice) > 1 else strategy_params.min_spread_bps - spread_bps = compute_spread_bps(vol_bps, strategy_params) - expected_edge = expected_edge_bps(spread_bps, rebate_bps, strategy_params) - if expected_edge < strategy_params.min_edge_bps: + quote_plan = _build_quote_plan( + market_id=_safe_str(market.get("market_id"), "unknown"), + mid_price=mid_price, + volatility_bps=vol_bps, + rebate_bps=rebate_bps, + inventory_notional=position_shares * mid_price, + outstanding_notional=abs(position_shares * mid_price), + strategy_params=strategy_params, + ) + record.update( + { + "status": quote_plan.status, + "reason": quote_plan.reason, + "spread_bps": quote_plan.spread_bps, + "edge_bps": quote_plan.edge_bps, + "bid_price": quote_plan.bid_price, + "ask_price": quote_plan.ask_price, + "bid_notional_usd": quote_plan.bid_notional_usd, + "ask_notional_usd": quote_plan.ask_notional_usd, + } + ) + if quote_plan.status != "quoted": skipped += 1 + telemetry.append(record) + equity_curve.append( + _liquidation_equity( + cash_usd=cash_usd, + position_shares=position_shares, + mark_price=next_price, + unwind_cost_bps=strategy_params.expected_unwind_cost_bps, + ) + ) continue quoted += 1 - half_spread_bps = spread_bps / 2.0 - next_move_bps = abs((next_price - mid_price) * 10000.0) - touch_ratio = min(1.0, next_move_bps / max(half_spread_bps, 1e-9)) - fill_fraction = backtest_params.participation_rate * touch_ratio - event_notional = strategy_params.base_order_notional_usd * fill_fraction - - extra_pickoff_bps = max(0.0, next_move_bps - half_spread_bps) - realized_edge_bps = ( - half_spread_bps - + rebate_bps - - strategy_params.expected_unwind_cost_bps - - strategy_params.adverse_selection_bps - - extra_pickoff_bps + side: str | None = None + if next_price < mid_price and quote_plan.bid_notional_usd > 0.0: + side = "buy" + elif next_price > mid_price and quote_plan.ask_notional_usd > 0.0: + side = "sell" + + previous_equity = _liquidation_equity( + cash_usd=cash_usd, + position_shares=position_shares, + mark_price=mid_price, + unwind_cost_bps=strategy_params.expected_unwind_cost_bps, + ) + fill_fraction = 0.0 + fill_notional = 0.0 + fill_price = 0.0 + if side == "buy": + fill_fraction = _fill_fraction( + side="buy", + quote_price=quote_plan.bid_price, + quote_notional=quote_plan.bid_notional_usd, + current_book=current_book, + next_book=next_book, + next_mid=next_price, + spread_bps=quote_plan.spread_bps, + backtest_params=backtest_params, + strategy_params=strategy_params, + ) + fill_notional = quote_plan.bid_notional_usd * fill_fraction + fill_price = quote_plan.bid_price + elif side == "sell": + fill_fraction = _fill_fraction( + side="sell", + quote_price=quote_plan.ask_price, + quote_notional=quote_plan.ask_notional_usd, + current_book=current_book, + next_book=next_book, + next_mid=next_price, + spread_bps=quote_plan.spread_bps, + backtest_params=backtest_params, + strategy_params=strategy_params, + ) + fill_notional = quote_plan.ask_notional_usd * fill_fraction + fill_price = quote_plan.ask_price + + if fill_notional > 0.0 and side is not None: + cash_usd, position_shares = _apply_fill( + side=side, + fill_notional=fill_notional, + fill_price=fill_price, + rebate_bps=rebate_bps, + cash_usd=cash_usd, + position_shares=position_shares, + ) + filled_notional += fill_notional + fill_events += 1 + + equity_after = _liquidation_equity( + cash_usd=cash_usd, + position_shares=position_shares, + mark_price=next_price, + unwind_cost_bps=strategy_params.expected_unwind_cost_bps, ) - event_pnl = event_notional * realized_edge_bps / 10000.0 - filled_notional += event_notional - pnl += event_pnl - event_pnls.append(event_pnl) + equity_curve.append(equity_after) + record.update( + { + "fill_side": side or "", + "fill_fraction": round(fill_fraction, 6), + "fill_notional_usd": round(fill_notional, 6), + "inventory_notional_after_usd": round(position_shares * next_price, 6), + "equity_before_usd": round(previous_equity, 6), + "equity_after_usd": round(equity_after, 6), + "event_pnl_usd": round(equity_after - previous_equity, 6), + } + ) + telemetry.append(record) + ending_equity = _liquidation_equity( + cash_usd=cash_usd, + position_shares=position_shares, + mark_price=history[-1][1], + unwind_cost_bps=strategy_params.expected_unwind_cost_bps, + ) + if not equity_curve or ending_equity != equity_curve[-1]: + equity_curve.append(ending_equity) return { "market_id": market["market_id"], "question": market["question"], "considered_points": considered, "quoted_points": quoted, "skipped_points": skipped, + "fill_events": fill_events, "filled_notional_usd": round(filled_notional, 4), - "pnl_usd": round(pnl, 6), - "event_pnls": event_pnls, + "pnl_usd": round(ending_equity - strategy_params.bankroll_usd, 6), + "equity_curve": equity_curve, + "telemetry": telemetry, + "orderbook_mode": market.get("orderbook_mode", "unknown"), } @@ -872,6 +1378,7 @@ def run_backtest( payload=fixture_payload, start_ts=start_ts, end_ts=end_ts, + backtest_params=backtest_params, ) source = "file" elif config.get("backtest_markets"): @@ -879,6 +1386,7 @@ def run_backtest( payload=config.get("backtest_markets", []), start_ts=start_ts, end_ts=end_ts, + backtest_params=backtest_params, ) source = "config" else: @@ -910,10 +1418,13 @@ def run_backtest( } market_summaries: list[dict[str, Any]] = [] - event_pnls: list[float] = [] + equity_curve = [strategy_params.bankroll_usd] total_considered = 0 total_quoted = 0 total_notional = 0.0 + total_fill_events = 0 + telemetry_records: list[dict[str, Any]] = [] + orderbook_modes: set[str] = set() for market in markets[: strategy_params.markets_max]: summary = _simulate_market_backtest( @@ -928,26 +1439,32 @@ def run_backtest( "considered_points": summary["considered_points"], "quoted_points": summary["quoted_points"], "skipped_points": summary["skipped_points"], + "fill_events": summary["fill_events"], "filled_notional_usd": summary["filled_notional_usd"], "pnl_usd": summary["pnl_usd"], + "orderbook_mode": summary["orderbook_mode"], } ) total_considered += int(summary["considered_points"]) total_quoted += int(summary["quoted_points"]) total_notional += float(summary["filled_notional_usd"]) - event_pnls.extend(summary["event_pnls"]) - - equity_curve = [strategy_params.bankroll_usd] - running_equity = strategy_params.bankroll_usd - for event_pnl in event_pnls: - running_equity += event_pnl - equity_curve.append(running_equity) - - ending_equity = running_equity + total_fill_events += int(summary["fill_events"]) + telemetry_records.extend(summary["telemetry"]) + orderbook_modes.add(_safe_str(summary.get("orderbook_mode"), "unknown")) + + market_equity_curve = summary["equity_curve"] + if len(market_equity_curve) > len(equity_curve): + equity_curve.extend([equity_curve[-1]] * (len(market_equity_curve) - len(equity_curve))) + for idx, value in enumerate(market_equity_curve): + if idx < len(equity_curve): + equity_curve[idx] += value - strategy_params.bankroll_usd + + ending_equity = equity_curve[-1] total_pnl = ending_equity - strategy_params.bankroll_usd return_pct = (total_pnl / strategy_params.bankroll_usd) * 100.0 max_drawdown = _max_drawdown(equity_curve) decision = "consider_live_guarded" if total_pnl > 0 else "paper_only_or_tune" + _write_telemetry_records(backtest_params.telemetry_path, telemetry_records) return { "status": "ok", @@ -962,6 +1479,8 @@ def run_backtest( "markets_selected": len(market_summaries), "considered_points": total_considered, "quoted_points": total_quoted, + "fill_events": total_fill_events, + "orderbook_mode": ",".join(sorted(orderbook_modes)), "quote_rate_pct": round( (total_quoted / total_considered) * 100.0 if total_considered else 0.0, 4, @@ -973,8 +1492,9 @@ def run_backtest( "total_pnl_usd": round(total_pnl, 4), "return_pct": round(return_pct, 4), "filled_notional_usd": round(total_notional, 4), - "events": len(event_pnls), + "events": total_fill_events, "max_drawdown_usd": round(max_drawdown, 4), + "telemetry_path": backtest_params.telemetry_path or None, "decision_hint": decision, "disclaimer": ( "Backtests are estimates and do not guarantee future performance." @@ -999,61 +1519,35 @@ def quote_market( mid = _safe_float(market.get("mid_price"), 0.5) vol_bps = _safe_float(market.get("volatility_bps"), p.min_spread_bps) rebate_bps = _safe_float(market.get("rebate_bps"), p.default_rebate_bps) - spread_bps = compute_spread_bps(vol_bps, p) - edge_bps = expected_edge_bps(spread_bps, rebate_bps, p) - - if edge_bps < p.min_edge_bps: - return { - "market_id": market_id, - "status": "skipped", - "reason": "negative_or_thin_edge", - "edge_bps": round(edge_bps, 3), - } - - # Positive inventory -> lower ask / higher bid to de-risk longs. - inventory_ratio = 0.0 - if p.max_position_notional_usd > 0: - inventory_ratio = clamp( - inventory_notional / p.max_position_notional_usd, - -1.0, - 1.0, - ) - skew_bps = -inventory_ratio * p.inventory_skew_strength_bps - half_spread_prob = (spread_bps / 2.0) / 10000.0 - skew_prob = skew_bps / 10000.0 - - bid_px = clamp(mid - half_spread_prob + skew_prob, 0.001, 0.999) - ask_px = clamp(mid + half_spread_prob + skew_prob, 0.001, 0.999) - if bid_px >= ask_px: - return { - "market_id": market_id, - "status": "skipped", - "reason": "crossed_quote_after_skew", - "edge_bps": round(edge_bps, 3), - } - - remaining_market = max(0.0, p.max_notional_per_market_usd - abs(inventory_notional)) - remaining_total = max(0.0, p.max_total_notional_usd - max(0.0, outstanding_notional)) - quote_notional = min(p.base_order_notional_usd, remaining_market, remaining_total) - - if quote_notional <= 0: + quote_plan = _build_quote_plan( + market_id=market_id, + mid_price=mid, + volatility_bps=vol_bps, + rebate_bps=rebate_bps, + inventory_notional=inventory_notional, + outstanding_notional=outstanding_notional, + strategy_params=p, + ) + if quote_plan.status != "quoted": return { "market_id": market_id, "status": "skipped", - "reason": "risk_capacity_exhausted", - "edge_bps": round(edge_bps, 3), + "reason": quote_plan.reason, + "edge_bps": quote_plan.edge_bps, } - + total_notional = quote_plan.bid_notional_usd + quote_plan.ask_notional_usd return { "market_id": market_id, - "status": "quoted", - "edge_bps": round(edge_bps, 3), - "spread_bps": round(spread_bps, 3), - "rebate_bps": round(rebate_bps, 3), - "quote_notional_usd": round(quote_notional, 2), - "bid_price": round(bid_px, 4), - "ask_price": round(ask_px, 4), - "inventory_notional_usd": round(inventory_notional, 2), + "status": quote_plan.status, + "edge_bps": quote_plan.edge_bps, + "spread_bps": quote_plan.spread_bps, + "rebate_bps": quote_plan.rebate_bps, + "quote_notional_usd": round(total_notional, 2), + "bid_notional_usd": quote_plan.bid_notional_usd, + "ask_notional_usd": quote_plan.ask_notional_usd, + "bid_price": quote_plan.bid_price, + "ask_price": quote_plan.ask_price, + "inventory_notional_usd": quote_plan.inventory_notional_usd, } @@ -1118,10 +1612,10 @@ def run_once( inventory_notional_by_market = { str(k): _safe_float(v, 0.0) for k, v in inventory.items() } - live_trader: PolymarketPublisherTrader | None = None + live_trader: DirectClobTrader | None = None if live_mode: try: - live_trader = PolymarketPublisherTrader( + live_trader = DirectClobTrader( skill_root=Path(__file__).resolve().parents[1], client_name="polymarket-maker-rebate-bot", ) diff --git a/polymarket/maker-rebate-bot/tests/test_smoke.py b/polymarket/maker-rebate-bot/tests/test_smoke.py index da6729d..fa70f10 100644 --- a/polymarket/maker-rebate-bot/tests/test_smoke.py +++ b/polymarket/maker-rebate-bot/tests/test_smoke.py @@ -2,11 +2,13 @@ import importlib.util import json +import math import subprocess import sys import time from pathlib import Path + 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" @@ -25,6 +27,102 @@ def _load_agent_module(): return module +def _build_history_and_orderbooks( + now_ts: int, + points: int = 240, +) -> tuple[list[dict[str, float]], list[dict[str, float]]]: + start_ts = now_ts - (points * 3600) + history: list[dict[str, float]] = [] + orderbooks: list[dict[str, float]] = [] + for i in range(points): + px = max(0.05, min(0.95, 0.5 + (0.012 * math.sin(i / 5.0)) + (0.003 * math.cos(i / 11.0)))) + ts = start_ts + (i * 3600) + history.append({"t": ts, "p": round(px, 6)}) + orderbooks.append( + { + "t": ts, + "best_bid": round(px - 0.0015, 6), + "best_ask": round(px + 0.0015, 6), + "bid_size_usd": 250.0, + "ask_size_usd": 250.0, + } + ) + return history, orderbooks + + +def _base_backtest_payload(now_ts: int, telemetry_path: Path) -> dict: + history, orderbooks = _build_history_and_orderbooks(now_ts) + return { + "execution": {"dry_run": True, "live_mode": False}, + "backtest": { + "days": 90, + "fidelity_minutes": 60, + "participation_rate": 0.25, + "volatility_window_points": 24, + "min_history_points": 120, + "min_liquidity_usd": 0, + "require_orderbook_history": True, + "spread_decay_bps": 45, + "join_best_queue_factor": 0.85, + "off_best_queue_factor": 0.35, + "telemetry_path": str(telemetry_path), + }, + "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": 60, + "volatility_spread_multiplier": 0.0, + "base_order_notional_usd": 40, + "max_notional_per_market_usd": 120, + "max_total_notional_usd": 120, + "max_position_notional_usd": 90, + "inventory_skew_strength_bps": 25, + }, + "backtest_markets": [ + { + "market_id": "TEST-STATEFUL", + "question": "Synthetic stateful market", + "token_id": "TEST-STATEFUL", + "rebate_bps": 3, + "end_ts": now_ts + (14 * 24 * 3600), + "history": history, + "orderbooks": orderbooks, + } + ], + } + + +def _run_backtest(tmp_path: Path, payload: dict) -> dict: + tmp_path.mkdir(parents=True, exist_ok=True) + 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.stdout, result.stderr + output = json.loads(result.stdout) + assert result.returncode == (0 if output["status"] == "ok" else 1), result.stderr + return output + + def test_happy_path_fixture_is_successful() -> None: payload = _read_fixture("happy_path.json") assert payload["status"] == "ok" @@ -99,61 +197,84 @@ def test_live_guard_fixture_blocks_execution() -> None: assert payload["error_code"] == "live_confirmation_required" -def test_backtest_run_type_returns_result_from_config_history(tmp_path: Path) -> None: - payload = json.loads(CONFIG_EXAMPLE_PATH.read_text(encoding="utf-8")) - assert payload["strategy"]["bankroll_usd"] == 1000 - +def test_backtest_run_type_returns_stateful_result_and_telemetry(tmp_path: Path) -> None: now_ts = int(time.time()) - start_ts = now_ts - (90 * 24 * 3600) - history = [] - for i in range(720): - wave = ((i % 24) - 12) / 600.0 - drift = ((i % 11) - 5) / 3000.0 - px = max(0.05, min(0.95, 0.5 + wave + drift)) - history.append({"t": start_ts + (i * 3600), "p": round(px, 6)}) - - payload["backtest"]["min_history_points"] = 200 - payload["backtest"]["min_liquidity_usd"] = 0 - payload["backtest_markets"] = [ - { - "market_id": f"TEST-90D-{idx}", - "question": "Synthetic 90D market", - "token_id": f"TEST-90D-{idx}", - "rebate_bps": 3, - "end_ts": now_ts + (7 * 24 * 3600), - "history": history, - } - for idx in range(payload["strategy"]["markets_max"]) - ] - config_path = tmp_path / "config.json" - config_path.write_text(json.dumps(payload), encoding="utf-8") + telemetry_path = tmp_path / "telemetry.jsonl" + payload = _base_backtest_payload(now_ts, telemetry_path) - 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, - ) + output = _run_backtest(tmp_path, payload) - assert result.returncode == 0, result.stderr - output = json.loads(result.stdout) assert output["status"] == "ok" assert output["mode"] == "backtest" - assert output["backtest_summary"]["days"] == 90 assert output["backtest_summary"]["source"] == "config" - assert output["backtest_summary"]["markets_selected"] >= 1 + assert output["backtest_summary"]["markets_selected"] == 1 + assert output["backtest_summary"]["orderbook_mode"] == "historical" assert output["results"]["events"] > 0 - assert output["results"]["starting_bankroll_usd"] == 1000 - assert output["results"]["return_pct"] >= 20.0 + assert output["results"]["telemetry_path"] == str(telemetry_path) + telemetry_lines = telemetry_path.read_text(encoding="utf-8").strip().splitlines() + assert telemetry_lines + first = json.loads(telemetry_lines[0]) + assert first["market_id"] == "TEST-STATEFUL" + assert "fill_fraction" in first or first["status"] == "skipped" + + +def test_stateful_backtest_enforces_risk_caps_in_replay(tmp_path: Path) -> None: + now_ts = int(time.time()) + telemetry_path = tmp_path / "risk-caps.jsonl" + payload = _base_backtest_payload(now_ts, telemetry_path) + payload["strategy"].update( + { + "base_order_notional_usd": 200, + "max_notional_per_market_usd": 60, + "max_total_notional_usd": 60, + "max_position_notional_usd": 40, + } + ) + + output = _run_backtest(tmp_path, payload) + + assert output["status"] == "ok" + records = [json.loads(line) for line in telemetry_path.read_text(encoding="utf-8").splitlines() if line.strip()] + quoted = [record for record in records if record.get("status") == "quoted"] + assert quoted + assert all((record["bid_notional_usd"] + record["ask_notional_usd"]) <= 60.0001 for record in quoted) + assert all( + abs(record.get("inventory_notional_after_usd", 0.0)) <= 40.0001 + for record in records + if "inventory_notional_after_usd" in record + ) + + +def test_spread_decay_reduces_filled_notional_when_spread_widens(tmp_path: Path) -> None: + now_ts = int(time.time()) + narrow_payload = _base_backtest_payload(now_ts, tmp_path / "narrow.jsonl") + narrow_payload["strategy"].update( + {"min_spread_bps": 20, "max_spread_bps": 20, "volatility_spread_multiplier": 0.0} + ) + wide_payload = _base_backtest_payload(now_ts, tmp_path / "wide.jsonl") + wide_payload["strategy"].update( + {"min_spread_bps": 120, "max_spread_bps": 120, "volatility_spread_multiplier": 0.0} + ) + + narrow_output = _run_backtest(tmp_path / "narrow", narrow_payload) + wide_output = _run_backtest(tmp_path / "wide", wide_payload) + + assert narrow_output["status"] == "ok" + assert wide_output["status"] == "ok" + assert wide_output["results"]["filled_notional_usd"] < narrow_output["results"]["filled_notional_usd"] + + +def test_backtest_requires_orderbook_history_when_configured(tmp_path: Path) -> None: + now_ts = int(time.time()) + telemetry_path = tmp_path / "missing-books.jsonl" + payload = _base_backtest_payload(now_ts, telemetry_path) + payload["backtest_markets"][0].pop("orderbooks") + + output = _run_backtest(tmp_path, payload) + + assert output["status"] == "error" + assert output["error_code"] == "backtest_data_load_failed" + assert "historical order-book snapshots" in output["message"] def test_config_example_uses_seren_polymarket_publisher_urls() -> None: @@ -169,8 +290,6 @@ def test_config_example_uses_seren_polymarket_publisher_urls() -> None: 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 = { @@ -277,7 +396,7 @@ def fake_execute_single_market_quotes(*, trader, quotes, markets, execution_sett "updated_inventory": {"LIVE-MKT-1": 12.5}, } - monkeypatch.setattr(agent, "PolymarketPublisherTrader", FakeTrader) + monkeypatch.setattr(agent, "DirectClobTrader", FakeTrader) monkeypatch.setattr(agent, "load_live_single_markets", fake_load_live_single_markets) monkeypatch.setattr(agent, "execute_single_market_quotes", fake_execute_single_market_quotes) diff --git a/polymarket/paired-market-basis-maker/SKILL.md b/polymarket/paired-market-basis-maker/SKILL.md index e84f8ae..3db9565 100644 --- a/polymarket/paired-market-basis-maker/SKILL.md +++ b/polymarket/paired-market-basis-maker/SKILL.md @@ -19,9 +19,9 @@ description: "Run a paired-market basis strategy on Polymarket with mandatory ba ## Workflow Summary -1. `load_backtest_pairs` pulls live market histories from the Seren Polymarket Publisher (Gamma + CLOB proxied), builds pairs from the active market universe, 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. +1. `load_backtest_pairs` pulls live market histories from the Seren Polymarket Publisher (Gamma + CLOB proxied), attaches per-leg order-book snapshots, builds pairs from the active market universe, and timestamp-aligns each pair. +2. `simulate_basis_reversion` runs an event-driven stateful replay with carried cash and inventory across both legs, order-book-aware fills, and pessimistic spread-decay. +3. `summarize_backtest` reports total return, annualized return, Sharpe-like score, max drawdown, hit rate, quoted/fill counts, order-book mode coverage, telemetry counts, and pair-level contributions. 4. `sample_gate` fails backtest if `events < backtest.min_events` (default `200`). 5. `backtest_gate` blocks trade mode by default if backtest return is non-positive. 6. `emit_pair_trades` outputs two-leg trade intents (`primary` + `pair`) with risk caps. @@ -65,6 +65,14 @@ If you are already running inside Seren Desktop, the runtime can use injected au python3 scripts/agent.py --config config.json --run-type trade ``` +## Optional Fixture Replay + +```bash +python3 scripts/agent.py --config config.json --backtest-file tests/fixtures/backtest_pairs.json +``` + +Set `backtest.telemetry_path` to capture JSONL replay telemetry for each decision step. + ## Disclaimer This skill can lose money. Basis spreads can persist or widen, hedge legs can slip, and liquidity can fail during volatility. Backtests are hypothetical and do not guarantee future results. This skill is software tooling and not financial advice. Use dry-run first and only trade with risk capital. diff --git a/polymarket/paired-market-basis-maker/config.example.json b/polymarket/paired-market-basis-maker/config.example.json index 31b158d..4ae7e2e 100644 --- a/polymarket/paired-market-basis-maker/config.example.json +++ b/polymarket/paired-market-basis-maker/config.example.json @@ -18,6 +18,14 @@ "participation_rate": 0.9, "min_history_points": 72, "min_events": 200, + "volatility_window_points": 24, + "require_orderbook_history": false, + "spread_decay_bps": 45, + "join_best_queue_factor": 0.85, + "off_best_queue_factor": 0.35, + "synthetic_orderbook_half_spread_bps": 18, + "synthetic_orderbook_depth_usd": 125, + "telemetry_path": "", "min_liquidity_usd": 5000, "markets_fetch_page_size": 500, "max_markets": 0, diff --git a/polymarket/paired-market-basis-maker/scripts/agent.py b/polymarket/paired-market-basis-maker/scripts/agent.py index 59ccd22..b931026 100644 --- a/polymarket/paired-market-basis-maker/scripts/agent.py +++ b/polymarket/paired-market-basis-maker/scripts/agent.py @@ -24,12 +24,19 @@ sys.path.insert(0, str(SHARED_DIR)) from polymarket_live import ( - PolymarketPublisherTrader, + DirectClobTrader, execute_pair_trades, live_settings_from_execution, load_live_pair_markets, pair_leg_exposure_notional, ) +from pair_stateful_replay import ( + PairReplayParams, + normalize_orderbook_snapshots, + simulate_pair_backtest, + snapshot_from_live_book, + write_telemetry_records, +) DISCLAIMER = ( @@ -76,6 +83,14 @@ class BacktestParams: participation_rate: float = 0.9 min_history_points: int = 72 min_events: int = 200 + volatility_window_points: int = 24 + require_orderbook_history: bool = False + spread_decay_bps: float = 45.0 + join_best_queue_factor: float = 0.85 + off_best_queue_factor: float = 0.35 + synthetic_orderbook_half_spread_bps: float = 18.0 + synthetic_orderbook_depth_usd: float = 125.0 + telemetry_path: str = "" min_liquidity_usd: float = 5000.0 markets_fetch_page_size: int = 500 max_markets: int = 0 @@ -96,6 +111,11 @@ def parse_args() -> argparse.Namespace: help="Run backtest only, or run trade mode after backtest gating.", ) parser.add_argument("--markets-file", default=None, help="Optional trade market JSON file.") + parser.add_argument( + "--backtest-file", + default=None, + help="Optional paired backtest fixture JSON file with history and orderbook snapshots.", + ) parser.add_argument("--backtest-days", type=int, default=None, help="Override backtest days.") parser.add_argument( "--allow-negative-backtest", @@ -210,6 +230,20 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: participation_rate=clamp(_safe_float(raw.get("participation_rate"), 0.9), 0.0, 1.0), min_history_points=max(8, _safe_int(raw.get("min_history_points"), 72)), min_events=max(1, _safe_int(raw.get("min_events"), 200)), + volatility_window_points=max(3, _safe_int(raw.get("volatility_window_points"), 24)), + require_orderbook_history=_safe_bool(raw.get("require_orderbook_history"), False), + spread_decay_bps=max(1.0, _safe_float(raw.get("spread_decay_bps"), 45.0)), + join_best_queue_factor=clamp(_safe_float(raw.get("join_best_queue_factor"), 0.85), 0.0, 1.0), + off_best_queue_factor=clamp(_safe_float(raw.get("off_best_queue_factor"), 0.35), 0.0, 1.0), + synthetic_orderbook_half_spread_bps=max( + 0.1, + _safe_float(raw.get("synthetic_orderbook_half_spread_bps"), 18.0), + ), + synthetic_orderbook_depth_usd=max( + 0.0, + _safe_float(raw.get("synthetic_orderbook_depth_usd"), 125.0), + ), + telemetry_path=_safe_str(raw.get("telemetry_path"), ""), min_liquidity_usd=max(0.0, _safe_float(raw.get("min_liquidity_usd"), 5000.0)), markets_fetch_page_size=max(25, _safe_int(raw.get("markets_fetch_page_size"), 500)), max_markets=max(0, _safe_int(raw.get("max_markets"), 0)), @@ -223,6 +257,34 @@ def to_backtest_params(config: dict[str, Any]) -> BacktestParams: ) +def _to_pair_replay_params(p: StrategyParams, bt: BacktestParams) -> PairReplayParams: + return PairReplayParams( + bankroll_usd=p.bankroll_usd, + min_seconds_to_resolution=p.min_seconds_to_resolution, + min_edge_bps=p.min_edge_bps, + maker_rebate_bps=p.maker_rebate_bps, + expected_unwind_cost_bps=p.expected_unwind_cost_bps, + adverse_selection_bps=p.adverse_selection_bps, + basis_entry_bps=p.basis_entry_bps, + basis_exit_bps=p.basis_exit_bps, + expected_convergence_ratio=p.expected_convergence_ratio, + base_pair_notional_usd=p.base_pair_notional_usd, + max_notional_per_pair_usd=p.max_notional_per_pair_usd, + max_total_notional_usd=p.max_total_notional_usd, + max_leg_notional_usd=p.max_leg_notional_usd, + participation_rate=bt.participation_rate, + min_history_points=bt.min_history_points, + volatility_window_points=bt.volatility_window_points, + require_orderbook_history=bt.require_orderbook_history, + spread_decay_bps=bt.spread_decay_bps, + join_best_queue_factor=bt.join_best_queue_factor, + off_best_queue_factor=bt.off_best_queue_factor, + synthetic_orderbook_half_spread_bps=bt.synthetic_orderbook_half_spread_bps, + synthetic_orderbook_depth_usd=bt.synthetic_orderbook_depth_usd, + telemetry_path=bt.telemetry_path, + ) + + def _normalize_history( raw_history: Any, start_ts: int, @@ -415,7 +477,14 @@ def _align_histories(primary: list[tuple[int, float]], secondary: list[tuple[int return aligned_primary, aligned_secondary +def _combine_orderbook_mode(primary_mode: str, pair_mode: str) -> str: + if primary_mode == pair_mode: + return primary_mode + return f"{primary_mode}|{pair_mode}" + + def _fetch_live_backtest_pairs(p: StrategyParams, bt: BacktestParams, start_ts: int, end_ts: int) -> list[dict[str, Any]]: + replay_params = _to_pair_replay_params(p, bt) offset = 0 candidates: list[dict[str, Any]] = [] seen_token_ids: set[str] = set() @@ -510,7 +579,23 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None ) if len(history) < bt.min_history_points: return None - return {**candidate, "history": history} + try: + book_payload = _http_get_json( + f"{SEREN_POLYMARKET_TRADING_PUBLISHER_PREFIX}book?{urlencode({'token_id': candidate['token_id']})}" + ) + except Exception: + book_payload = None + orderbooks, orderbook_mode = snapshot_from_live_book( + payload=book_payload, + history=history, + params=replay_params, + ) + return { + **candidate, + "history": history, + "orderbooks": orderbooks, + "orderbook_mode": orderbook_mode, + } with_history: list[dict[str, Any]] = [] with ThreadPoolExecutor(max_workers=max(1, bt.history_fetch_workers)) as executor: @@ -543,11 +628,19 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None "pair_market_id": pair_id, "question": _safe_str(primary.get("question"), market_id), "pair_question": _safe_str(secondary.get("question"), pair_id), + "token_id": _safe_str(primary.get("token_id"), ""), + "pair_token_id": _safe_str(secondary.get("token_id"), ""), "event_id": event_id, "end_ts": min(_safe_int(primary.get("end_ts"), end_ts + 86400), _safe_int(secondary.get("end_ts"), end_ts + 86400)), "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, + "orderbooks": primary.get("orderbooks", {}), + "pair_orderbooks": secondary.get("orderbooks", {}), + "orderbook_mode": _combine_orderbook_mode( + _safe_str(primary.get("orderbook_mode"), "unknown"), + _safe_str(secondary.get("orderbook_mode"), "unknown"), + ), "source": "live-api", } ) @@ -571,11 +664,19 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None "pair_market_id": pair_id, "question": _safe_str(primary.get("question"), market_id), "pair_question": _safe_str(secondary.get("question"), pair_id), + "token_id": _safe_str(primary.get("token_id"), ""), + "pair_token_id": _safe_str(secondary.get("token_id"), ""), "event_id": "fallback", "end_ts": min(_safe_int(primary.get("end_ts"), end_ts + 86400), _safe_int(secondary.get("end_ts"), end_ts + 86400)), "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, + "orderbooks": primary.get("orderbooks", {}), + "pair_orderbooks": secondary.get("orderbooks", {}), + "orderbook_mode": _combine_orderbook_mode( + _safe_str(primary.get("orderbook_mode"), "unknown"), + _safe_str(secondary.get("orderbook_mode"), "unknown"), + ), "source": "live-api-fallback", } ) @@ -583,12 +684,102 @@ def _fetch_candidate_history(candidate: dict[str, Any]) -> dict[str, Any] | None return pairs +def _load_markets_from_fixture( + *, + fixture_path: Path, + p: StrategyParams, + bt: BacktestParams, + start_ts: int, + end_ts: int, +) -> list[dict[str, Any]]: + replay_params = _to_pair_replay_params(p, bt) + payload = load_json(fixture_path) + if isinstance(payload, dict): + rows = payload.get("markets", []) + elif isinstance(payload, list): + rows = payload + else: + rows = [] + if not isinstance(rows, list): + return [] + + markets: list[dict[str, Any]] = [] + for idx, row in enumerate(rows): + if not isinstance(row, dict): + continue + history = _normalize_history( + row.get("history"), + start_ts=start_ts, + end_ts=end_ts, + token_id=_safe_str(row.get("token_id"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + ) + pair_history = _normalize_history( + row.get("pair_history"), + start_ts=start_ts, + end_ts=end_ts, + token_id=_safe_str(row.get("pair_token_id"), _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b")), + ) + if len(history) < bt.min_history_points or len(pair_history) < bt.min_history_points: + continue + orderbooks, primary_mode = normalize_orderbook_snapshots( + row.get("orderbooks"), + history, + replay_params, + ) + pair_orderbooks, pair_mode = normalize_orderbook_snapshots( + row.get("pair_orderbooks"), + pair_history, + replay_params, + ) + markets.append( + { + "market_id": _safe_str(row.get("market_id"), f"fixture-{idx}-a"), + "pair_market_id": _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + "question": _safe_str(row.get("question"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + "pair_question": _safe_str( + row.get("pair_question"), + _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + ), + "token_id": _safe_str(row.get("token_id"), _safe_str(row.get("market_id"), f"fixture-{idx}-a")), + "pair_token_id": _safe_str( + row.get("pair_token_id"), + _safe_str(row.get("pair_market_id"), f"fixture-{idx}-b"), + ), + "event_id": _safe_str(row.get("event_id"), f"fixture-{idx}"), + "end_ts": _safe_int(row.get("end_ts"), end_ts + 86400), + "rebate_bps": _safe_float(row.get("rebate_bps"), p.maker_rebate_bps), + "history": history, + "pair_history": pair_history, + "orderbooks": orderbooks, + "pair_orderbooks": pair_orderbooks, + "orderbook_mode": _combine_orderbook_mode(primary_mode, pair_mode), + "source": "fixture", + } + ) + return markets + + def _load_backtest_markets( p: StrategyParams, bt: BacktestParams, start_ts: int, end_ts: int, + backtest_file: str | None = None, ) -> tuple[list[dict[str, Any]], str]: + if backtest_file: + fixture_path = Path(backtest_file) + if not fixture_path.exists(): + raise FileNotFoundError(f"Backtest fixture not found: {fixture_path}") + return ( + _load_markets_from_fixture( + fixture_path=fixture_path, + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + ), + f"fixture:{fixture_path.name}", + ) return _fetch_live_backtest_pairs(p=p, bt=bt, start_ts=start_ts, end_ts=end_ts), "live-api" @@ -625,86 +816,63 @@ def _sharpe_like_score(event_pnls: list[float], bankroll_usd: float, days: int) def _simulate_pair(market: dict[str, Any], p: StrategyParams, bt: BacktestParams) -> dict[str, Any]: - primary = market["history"] - pair = market["pair_history"] - n = min(len(primary), len(pair)) - if n < bt.min_history_points: - return { - "market_id": market["market_id"], - "pair_market_id": market["pair_market_id"], - "considered_points": 0, - "traded_points": 0, - "filled_notional_usd": 0.0, - "pnl_usd": 0.0, - "event_pnls": [], - } - - rebate_bps = _safe_float(market.get("rebate_bps"), p.maker_rebate_bps) - if rebate_bps <= 0: - rebate_bps = p.maker_rebate_bps - - basis_series_bps = [(primary[i][1] - pair[i][1]) * 10000.0 for i in range(n)] - considered = 0 - traded = 0 - filled_notional = 0.0 - pnl = 0.0 - event_pnls: list[float] = [] - - for i in range(0, n - 1): - t = primary[i][0] - ttl = max(0, _safe_int(market.get("end_ts"), t + 86400) - t) - if ttl < p.min_seconds_to_resolution: - continue - - basis_now = basis_series_bps[i] - basis_next = basis_series_bps[i + 1] - abs_basis_now = abs(basis_now) - if abs_basis_now < p.basis_entry_bps: - continue - - considered += 1 - basis_change = abs_basis_now - abs(basis_next) - expected_convergence = abs_basis_now * p.expected_convergence_ratio - expected_edge = expected_convergence + rebate_bps - p.expected_unwind_cost_bps - p.adverse_selection_bps - if expected_edge < p.min_edge_bps: - continue - - traded += 1 - fill_intensity = min(1.0, abs_basis_now / max(p.basis_entry_bps * 2.0, 1e-9)) - event_notional = p.base_pair_notional_usd * bt.participation_rate * fill_intensity - realized_edge = basis_change + rebate_bps - p.expected_unwind_cost_bps - p.adverse_selection_bps - event_pnl = event_notional * realized_edge / 10000.0 - - filled_notional += event_notional - pnl += event_pnl - event_pnls.append(event_pnl) - + replay_params = _to_pair_replay_params(p, bt) + history = market.get("history", []) + pair_history = market.get("pair_history", []) + orderbooks = market.get("orderbooks") + pair_orderbooks = market.get("pair_orderbooks") + orderbook_mode = _safe_str(market.get("orderbook_mode"), "") + if not isinstance(orderbooks, dict): + orderbooks, primary_mode = normalize_orderbook_snapshots([], history, replay_params) + market = {**market, "orderbooks": orderbooks} + orderbook_mode = primary_mode + else: + primary_mode = orderbook_mode.split("|", 1)[0] if orderbook_mode else "unknown" + if not isinstance(pair_orderbooks, dict): + pair_orderbooks, pair_mode = normalize_orderbook_snapshots([], pair_history, replay_params) + market = {**market, "pair_orderbooks": pair_orderbooks} + orderbook_mode = _combine_orderbook_mode(primary_mode, pair_mode) + if orderbook_mode: + market = {**market, "orderbook_mode": orderbook_mode} + result = simulate_pair_backtest(market=market, params=replay_params) return { - "market_id": market["market_id"], - "pair_market_id": market["pair_market_id"], - "considered_points": considered, - "traded_points": traded, - "filled_notional_usd": round(filled_notional, 4), - "pnl_usd": round(pnl, 6), - "event_pnls": event_pnls, + **result, + "traded_points": result["quoted_points"], } -def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, Any]: +def run_backtest( + config: dict[str, Any], + backtest_days: int | None, + backtest_file: str | None = None, +) -> dict[str, Any]: p = to_strategy_params(config) bt = to_backtest_params(config) days = int(clamp(backtest_days if backtest_days is not None else bt.days, bt.days_min, bt.days_max)) + configured_backtest_file = "" + if isinstance(config.get("backtest"), dict): + configured_backtest_file = _safe_str(config["backtest"].get("backtest_file"), "") + selected_backtest_file = backtest_file or configured_backtest_file or None end_ts = int(time.time()) start_ts = end_ts - (days * 24 * 60 * 60) try: - markets, source = _load_backtest_markets( - p=p, - bt=bt, - start_ts=start_ts, - end_ts=end_ts, - ) + if selected_backtest_file: + markets, source = _load_backtest_markets( + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + backtest_file=selected_backtest_file, + ) + else: + markets, source = _load_backtest_markets( + p=p, + bt=bt, + start_ts=start_ts, + end_ts=end_ts, + ) except Exception as exc: return { "status": "error", @@ -726,8 +894,12 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, summaries: list[dict[str, Any]] = [] event_pnls: list[float] = [] considered = 0 - traded = 0 + quoted = 0 + skipped = 0 + fill_events = 0 total_notional = 0.0 + telemetry: list[dict[str, Any]] = [] + orderbook_modes: dict[str, int] = defaultdict(int) for market in markets: result = _simulate_pair(market, p, bt) @@ -736,15 +908,23 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "market_id": result["market_id"], "pair_market_id": result["pair_market_id"], "considered_points": result["considered_points"], + "quoted_points": result["quoted_points"], "traded_points": result["traded_points"], + "skipped_points": result["skipped_points"], + "fill_events": result["fill_events"], "filled_notional_usd": result["filled_notional_usd"], "pnl_usd": result["pnl_usd"], + "orderbook_mode": result["orderbook_mode"], } ) considered += int(result["considered_points"]) - traded += int(result["traded_points"]) + quoted += int(result["quoted_points"]) + skipped += int(result["skipped_points"]) + fill_events += int(result["fill_events"]) total_notional += float(result["filled_notional_usd"]) event_pnls.extend(result["event_pnls"]) + telemetry.extend(result.get("telemetry", [])) + orderbook_modes[_safe_str(result.get("orderbook_mode"), "unknown")] += 1 equity_curve = [p.bankroll_usd] equity = p.bankroll_usd @@ -778,11 +958,16 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "source": source, "pairs_loaded": len(markets), "events_observed": events, + "quoted_points": quoted, + "fill_events": fill_events, "min_events_required": bt.min_events, + "orderbook_modes": dict(sorted(orderbook_modes.items())), }, "disclaimer": DISCLAIMER, } + write_telemetry_records(bt.telemetry_path, telemetry) + return { "status": "ok", "skill": "paired-market-basis-maker", @@ -796,8 +981,14 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "source": source, "pairs_selected": len(summaries), "considered_points": considered, - "traded_points": traded, - "trade_rate_pct": round((traded / considered) * 100.0 if considered else 0.0, 4), + "quoted_points": quoted, + "traded_points": quoted, + "skipped_points": skipped, + "fill_events": fill_events, + "trade_rate_pct": round((quoted / considered) * 100.0 if considered else 0.0, 4), + "orderbook_modes": dict(sorted(orderbook_modes.items())), + "telemetry_path": bt.telemetry_path, + "telemetry_records": len(telemetry), }, "results": { "starting_bankroll_usd": round(p.bankroll_usd, 2), @@ -811,6 +1002,7 @@ def run_backtest(config: dict[str, Any], backtest_days: int | None) -> dict[str, "filled_notional_usd": round(total_notional, 2), "turnover_multiple": round(turnover_multiple, 4), "events": events, + "fill_events": fill_events, "min_events_required": bt.min_events, "max_drawdown_usd": round(max_drawdown_usd, 4), "max_drawdown_pct": round(display_max_drawdown_pct, 4), @@ -1163,10 +1355,10 @@ def run_trade(config: dict[str, Any], markets_file: str | None, yes_live: bool) exposure = config.get("state", {}).get("leg_exposure", {}) leg_exposure = {str(k): _safe_float(v, 0.0) for k, v in exposure.items()} - live_trader: PolymarketPublisherTrader | None = None + live_trader: DirectClobTrader | None = None if live_mode: try: - live_trader = PolymarketPublisherTrader( + live_trader = DirectClobTrader( skill_root=Path(__file__).resolve().parents[1], client_name="paired-market-basis-maker", ) @@ -1250,6 +1442,7 @@ def main() -> int: backtest = run_backtest( config=config, backtest_days=args.backtest_days, + backtest_file=args.backtest_file, ) if backtest.get("status") != "ok": print(json.dumps(backtest, sort_keys=True)) diff --git a/polymarket/paired-market-basis-maker/tests/test_smoke.py b/polymarket/paired-market-basis-maker/tests/test_smoke.py index 5527eb2..0d39139 100644 --- a/polymarket/paired-market-basis-maker/tests/test_smoke.py +++ b/polymarket/paired-market-basis-maker/tests/test_smoke.py @@ -68,7 +68,7 @@ def test_dry_run_fixture_blocks_live_execution() -> None: assert payload["blocked_action"] == "live_execution" -def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None: +def test_config_example_runs_stateful_backtest_and_reports_replay_metrics(monkeypatch) -> None: module = _load_agent_module() payload = json.loads(CONFIG_EXAMPLE_PATH.read_text(encoding="utf-8")) @@ -100,7 +100,11 @@ def test_config_example_targets_promotional_backtest_return(monkeypatch) -> None output = module.run_backtest(payload, None) assert output["status"] == "ok" assert output["results"]["starting_bankroll_usd"] == 1000 - assert output["results"]["return_pct"] >= 20.0 + assert output["results"]["fill_events"] > 0 + assert output["backtest_summary"]["quoted_points"] > 0 + assert sum(output["backtest_summary"]["orderbook_modes"].values()) == len(synthetic_markets) + assert output["results"]["return_pct"] >= -100.0 + assert output["pairs"][0]["orderbook_mode"] in output["backtest_summary"]["orderbook_modes"] def test_trade_mode_fetches_live_pairs_when_config_markets_is_empty(monkeypatch) -> None: @@ -211,7 +215,7 @@ def fake_execute_pair_trades(*, trader, pair_trades, markets, execution_settings "updated_leg_exposure": {"LIVE-PAIR-1A": 7.5, "LIVE-PAIR-1B": -7.5}, } - monkeypatch.setattr(module, "PolymarketPublisherTrader", FakeTrader) + monkeypatch.setattr(module, "DirectClobTrader", FakeTrader) monkeypatch.setattr(module, "load_live_pair_markets", fake_load_live_pair_markets) monkeypatch.setattr(module, "execute_pair_trades", fake_execute_pair_trades)