Skip to content

Commit dbabf93

Browse files
authored
Merge pull request #15 from mlsys-io/claude/polymarket-event-trading-7BkpS
Integrate Polymarket event contract trading and backtesting
2 parents 5b90b60 + 3b8332c commit dbabf93

59 files changed

Lines changed: 1293 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

alpha/PolymarketFactors.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Alpha factors for Polymarket event contract analysis.
2+
3+
Polymarket contracts are probability-priced [0, 1] binary outcomes.
4+
Traditional momentum/mean-reversion indicators need adaptation:
5+
- Prices are bounded, so unbounded indicators can mislead
6+
- Probability space means RSI-like indicators are natural fits
7+
- Volume spikes often precede resolution or major news
8+
- Complementary contracts (YES+NO=1) create natural arbitrage signals
9+
"""
10+
11+
import numpy as np
12+
from pandas import DataFrame
13+
14+
from alpha.interface import IAlpha
15+
16+
17+
class PolymarketAlpha(IAlpha):
18+
"""Alpha factors tailored for binary outcome prediction market contracts.
19+
20+
Indicators produced:
21+
- prob_momentum: Rate of probability change (smoothed)
22+
- prob_rsi: RSI adapted for probability space
23+
- volume_surge: Volume relative to rolling average (news detection)
24+
- prob_zscore: Z-score of current price vs rolling window
25+
- mean_reversion_signal: Distance from rolling mean (mean-reversion)
26+
- resolution_proximity: How close price is to 0 or 1 (conviction signal)
27+
- prob_ema_fast: Fast EMA of probability
28+
- prob_ema_slow: Slow EMA of probability
29+
"""
30+
31+
def __init__(self, dataframe: DataFrame, metadata: dict = {}):
32+
self.fast_period = 12
33+
self.slow_period = 26
34+
self.rsi_period = 14
35+
self.zscore_period = 20
36+
self.volume_period = 20
37+
super().__init__(dataframe, metadata)
38+
39+
def process(self) -> DataFrame:
40+
df = self.dataframe
41+
42+
close = df["close"]
43+
44+
# --- Probability EMAs ---
45+
df["prob_ema_fast"] = close.ewm(span=self.fast_period, adjust=False).mean()
46+
df["prob_ema_slow"] = close.ewm(span=self.slow_period, adjust=False).mean()
47+
48+
# --- Probability Momentum ---
49+
# Rate of change in probability, smoothed
50+
df["prob_momentum"] = close.diff(5).rolling(3).mean()
51+
52+
# --- RSI in probability space ---
53+
delta = close.diff()
54+
gain = delta.clip(lower=0).rolling(self.rsi_period).mean()
55+
loss = (-delta.clip(upper=0)).rolling(self.rsi_period).mean()
56+
rs = gain / loss.replace(0, np.nan)
57+
df["prob_rsi"] = 100 - (100 / (1 + rs))
58+
df["prob_rsi"] = df["prob_rsi"].fillna(50)
59+
60+
# --- Volume Surge ---
61+
# Ratio of current volume to rolling average; >2 suggests news
62+
mean_vol = df["volume"].rolling(self.volume_period).mean()
63+
df["volume_surge"] = df["volume"] / mean_vol.replace(0, 1)
64+
65+
# --- Z-score of probability ---
66+
rolling_mean = close.rolling(self.zscore_period).mean()
67+
rolling_std = close.rolling(self.zscore_period).std()
68+
df["prob_zscore"] = (close - rolling_mean) / rolling_std.replace(0, np.nan)
69+
df["prob_zscore"] = df["prob_zscore"].fillna(0)
70+
71+
# --- Mean reversion signal ---
72+
# Distance from rolling mean, normalized. Positive = above mean
73+
df["mean_reversion_signal"] = close - rolling_mean
74+
75+
# --- Resolution proximity ---
76+
# How close to 0 or 1: min(price, 1-price). Low = high conviction
77+
df["resolution_proximity"] = np.minimum(close, 1 - close)
78+
79+
# --- Volume-weighted mean volume for volume filter ---
80+
df["mean_volume"] = mean_vol
81+
82+
return df

freqtrade/exchange/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,5 @@
4848
from freqtrade.exchange.luno import Luno
4949
from freqtrade.exchange.modetrade import Modetrade
5050
from freqtrade.exchange.okx import Myokx, Okx, Okxus
51+
from freqtrade.exchange.polymarket import Polymarket
5152
from freqtrade.exchange.portfoliobench import Portfoliobench

freqtrade/exchange/common.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def _get_logging_mixin():
6565
"kraken",
6666
"okx",
6767
"myokx",
68+
"polymarket",
6869
"portfoliobench",
6970
]
7071

freqtrade/exchange/polymarket.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Polymarket Mean-Reversion Strategy — fade overreactions in prediction markets.
2+
3+
Trades event contracts by betting against short-term overreactions:
4+
- Buy when probability drops significantly below its rolling mean (oversold)
5+
- Sell when probability reverts to mean or overshoots above it
6+
7+
Designed for contracts where sharp moves are driven by noise/overreaction
8+
rather than fundamental shifts (e.g., speculative events, sentiment spikes).
9+
"""
10+
11+
import numpy as np
12+
import pandas as pd
13+
from datetime import datetime
14+
from typing import Optional
15+
16+
from freqtrade.strategy import IStrategy, Trade
17+
18+
from alpha.PolymarketFactors import PolymarketAlpha
19+
20+
21+
class PolymarketMeanReversionStrategy(IStrategy):
22+
INTERFACE_VERSION = 3
23+
24+
can_short: bool = False
25+
minimal_roi = {"0": 0.15} # Take profit at 15% gain
26+
stoploss = -0.30
27+
trailing_stop = False
28+
29+
process_only_new_candles = True
30+
use_exit_signal = True
31+
exit_profit_only = False
32+
ignore_roi_if_entry_signal = False
33+
startup_candle_count: int = 30
34+
35+
def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
36+
dataframe = PolymarketAlpha(dataframe, metadata).process()
37+
return dataframe
38+
39+
def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
40+
dataframe.loc[
41+
(
42+
# Z-score strongly negative (price well below mean)
43+
(dataframe["prob_zscore"] < -1.5)
44+
# Mean reversion signal confirms (below rolling mean)
45+
& (dataframe["mean_reversion_signal"] < -0.03)
46+
# Volume surge suggests reactionary move, not fundamental
47+
& (dataframe["volume_surge"] > 1.5)
48+
# Contract still has room to move (not near resolution)
49+
& (dataframe["resolution_proximity"] > 0.10)
50+
# Price in tradeable range
51+
& (dataframe["close"] > 0.10)
52+
& (dataframe["close"] < 0.90)
53+
),
54+
"enter_long",
55+
] = 1
56+
57+
return dataframe
58+
59+
def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
60+
dataframe.loc[
61+
(
62+
# Price reverted above mean
63+
(dataframe["prob_zscore"] > 0.5)
64+
# OR momentum shifted positive (reversion complete)
65+
| (
66+
(dataframe["mean_reversion_signal"] > 0.02)
67+
& (dataframe["prob_momentum"] > 0)
68+
)
69+
),
70+
"exit_long",
71+
] = 1
72+
73+
return dataframe
74+
75+
def confirm_trade_entry(
76+
self,
77+
pair: str,
78+
order_type: str,
79+
amount: float,
80+
rate: float,
81+
time_in_force: str,
82+
current_time: datetime,
83+
entry_tag: str | None,
84+
side: str,
85+
**kwargs,
86+
) -> bool:
87+
if rate < 0.05 or rate > 0.95:
88+
return False
89+
return True

0 commit comments

Comments
 (0)