|
| 1 | +"""Polymarket exchange subclass for event contract trading. |
| 2 | +
|
| 3 | +Extends the Portfoliobench exchange to support Polymarket binary outcome |
| 4 | +contracts (YES/NO shares priced $0–$1) for backtesting prediction market |
| 5 | +strategies. |
| 6 | +
|
| 7 | +Key differences from crypto/stock trading: |
| 8 | + - Prices are bounded [0, 1] representing probability of an outcome |
| 9 | + - Each event has two complementary contracts: YES + NO (prices sum to ~$1) |
| 10 | + - Contracts settle at exactly $0 or $1 at resolution |
| 11 | + - There is no "volume" in the traditional sense; liquidity comes from the CLOB |
| 12 | + - Profit = (settlement_price - entry_price) * shares |
| 13 | +
|
| 14 | +Pair convention: {EVENT_SLUG}-{YES|NO}/USDT |
| 15 | + e.g. "TRUMP-WIN-YES/USDT", "ETH-10K-NO/USDT" |
| 16 | +""" |
| 17 | + |
| 18 | +import logging |
| 19 | +from copy import deepcopy |
| 20 | +from typing import Any |
| 21 | + |
| 22 | +import ccxt |
| 23 | + |
| 24 | +from freqtrade.exchange.portfoliobench import Portfoliobench |
| 25 | + |
| 26 | +logger = logging.getLogger(__name__) |
| 27 | + |
| 28 | + |
| 29 | +# --------------------------------------------------------------------------- |
| 30 | +# Contract type constants |
| 31 | +# --------------------------------------------------------------------------- |
| 32 | +CONTRACT_YES = "YES" |
| 33 | +CONTRACT_NO = "NO" |
| 34 | +PRICE_FLOOR = 0.001 # Minimum contract price ($0.001) |
| 35 | +PRICE_CEIL = 0.999 # Maximum contract price ($0.999) |
| 36 | + |
| 37 | + |
| 38 | +def is_polymarket_pair(pair: str) -> bool: |
| 39 | + """Return True if the pair follows Polymarket naming: *-YES/USDT or *-NO/USDT.""" |
| 40 | + base = pair.split("/")[0] if "/" in pair else pair |
| 41 | + return base.endswith(f"-{CONTRACT_YES}") or base.endswith(f"-{CONTRACT_NO}") |
| 42 | + |
| 43 | + |
| 44 | +def get_complement_pair(pair: str) -> str: |
| 45 | + """Return the complementary contract pair (YES <-> NO).""" |
| 46 | + if f"-{CONTRACT_YES}/" in pair: |
| 47 | + return pair.replace(f"-{CONTRACT_YES}/", f"-{CONTRACT_NO}/") |
| 48 | + elif f"-{CONTRACT_NO}/" in pair: |
| 49 | + return pair.replace(f"-{CONTRACT_NO}/", f"-{CONTRACT_YES}/") |
| 50 | + return pair |
| 51 | + |
| 52 | + |
| 53 | +def get_event_slug(pair: str) -> str: |
| 54 | + """Extract the event slug from a Polymarket pair. |
| 55 | +
|
| 56 | + 'TRUMP-WIN-YES/USDT' -> 'TRUMP-WIN' |
| 57 | + """ |
| 58 | + base = pair.split("/")[0] |
| 59 | + for suffix in (f"-{CONTRACT_YES}", f"-{CONTRACT_NO}"): |
| 60 | + if base.endswith(suffix): |
| 61 | + return base[: -len(suffix)] |
| 62 | + return base |
| 63 | + |
| 64 | + |
| 65 | +def _polymarket_synthetic_market(pair: str) -> dict: |
| 66 | + """Build a synthetic market entry for a Polymarket event contract. |
| 67 | +
|
| 68 | + Polymarket contracts differ from regular assets: |
| 69 | + - Prices are bounded [0, 1] (probability) |
| 70 | + - Amount precision is whole shares (integer) |
| 71 | + - Minimum order is 1 share |
| 72 | + """ |
| 73 | + return { |
| 74 | + "symbol": pair, |
| 75 | + "base": pair.split("/")[0], |
| 76 | + "quote": pair.split("/")[1] if "/" in pair else "USDT", |
| 77 | + "spot": True, |
| 78 | + "swap": False, |
| 79 | + "future": False, |
| 80 | + "linear": True, |
| 81 | + "type": "spot", |
| 82 | + "contract": False, |
| 83 | + "active": True, |
| 84 | + "precision": {"amount": 0, "price": 4}, # whole shares, 4-decimal prices |
| 85 | + "limits": { |
| 86 | + "amount": {"min": 1, "max": 1e8}, |
| 87 | + "price": {"min": PRICE_FLOOR, "max": PRICE_CEIL}, |
| 88 | + "cost": {"min": 0.01, "max": 1e8}, |
| 89 | + }, |
| 90 | + "info": { |
| 91 | + "polymarket": True, |
| 92 | + "contract_type": CONTRACT_YES if f"-{CONTRACT_YES}/" in pair else CONTRACT_NO, |
| 93 | + "event_slug": get_event_slug(pair), |
| 94 | + }, |
| 95 | + } |
| 96 | + |
| 97 | + |
| 98 | +class Polymarket(Portfoliobench): |
| 99 | + """Exchange adapter for Polymarket event contract backtesting. |
| 100 | +
|
| 101 | + Extends Portfoliobench to handle: |
| 102 | + - Binary outcome contract market entries (YES/NO shares) |
| 103 | + - Price bounds enforcement [0, 1] |
| 104 | + - Zero trading fees (Polymarket's maker model) |
| 105 | + - Contract-specific synthetic market injection |
| 106 | + - Complement pair awareness (YES + NO = $1) |
| 107 | + """ |
| 108 | + |
| 109 | + _ft_has = { |
| 110 | + "needs_trading_fees": False, |
| 111 | + } |
| 112 | + |
| 113 | + # ------------------------------------------------------------------ |
| 114 | + # ccxt init — reuse binance underneath |
| 115 | + # ------------------------------------------------------------------ |
| 116 | + |
| 117 | + def _init_ccxt( |
| 118 | + self, exchange_config: dict[str, Any], sync: bool, ccxt_kwargs: dict[str, Any] |
| 119 | + ) -> ccxt.Exchange: |
| 120 | + """Map 'polymarket' back to 'binance' for ccxt initialisation.""" |
| 121 | + patched = deepcopy(exchange_config) |
| 122 | + patched["name"] = "binance" |
| 123 | + return super()._init_ccxt(patched, sync, ccxt_kwargs) |
| 124 | + |
| 125 | + # ------------------------------------------------------------------ |
| 126 | + # Market injection — Polymarket-aware |
| 127 | + # ------------------------------------------------------------------ |
| 128 | + |
| 129 | + def _inject_synthetic_markets(self) -> None: |
| 130 | + """Inject synthetic market entries for event contracts and regular pairs.""" |
| 131 | + whitelist = self._config.get("exchange", {}).get("pair_whitelist", []) |
| 132 | + cli_pairs = self._config.get("pairs", []) |
| 133 | + for pair in set(whitelist + cli_pairs): |
| 134 | + if pair not in self._markets: |
| 135 | + if is_polymarket_pair(pair): |
| 136 | + logger.info("Auto-injecting Polymarket contract: %s", pair) |
| 137 | + self._markets[pair] = _polymarket_synthetic_market(pair) |
| 138 | + else: |
| 139 | + # Fall back to parent's generic synthetic market |
| 140 | + from freqtrade.exchange.portfoliobench import _synthetic_market |
| 141 | + |
| 142 | + logger.info("Auto-injecting pair: %s", pair) |
| 143 | + self._markets[pair] = _synthetic_market(pair) |
| 144 | + |
| 145 | + # ------------------------------------------------------------------ |
| 146 | + # Fees — Polymarket uses 0 maker fees |
| 147 | + # ------------------------------------------------------------------ |
| 148 | + |
| 149 | + def get_fee( |
| 150 | + self, |
| 151 | + symbol: str, |
| 152 | + order_type: str = "", |
| 153 | + side: str = "", |
| 154 | + amount: float = 1, |
| 155 | + price: float = 1, |
| 156 | + taker_or_maker: str = "maker", |
| 157 | + ) -> float: |
| 158 | + """Polymarket charges 0% maker fees; small taker fee on some markets.""" |
| 159 | + if is_polymarket_pair(symbol): |
| 160 | + return 0.0 |
| 161 | + return super().get_fee(symbol, order_type, side, amount, price, taker_or_maker) |
0 commit comments